feat: add zone layout selector

This commit is contained in:
2026-05-25 03:17:23 +02:00
parent 558e0dc157
commit f09f110e77
5 changed files with 111 additions and 5 deletions

View File

@@ -219,6 +219,11 @@ Container state belongs in saved or copied graph view state, not in the Fabric
payload. It is an operator workspace preference, similar to manual visibility payload. It is an operator workspace preference, similar to manual visibility
overrides. overrides.
Zone-local layout is also view state. The first selectable algorithms are a
compact grid and a circle layout. Switching between them should rearrange the
assigned nodes inside each stable container without moving the container itself
or changing the underlying Fabric relationships.
### Context Edges ### Context Edges
Display-only context edges are not zone connectivity. Repository `declares` Display-only context edges are not zone connectivity. Repository `declares`

View File

@@ -210,9 +210,10 @@ zone surface position and size in graph coordinates. Global graph layout may
place unzoned nodes and provide an initial center for new zones, but existing place unzoned nodes and provide an initial center for new zones, but existing
zone containers should keep their operator-chosen positions when the layout zone containers should keep their operator-chosen positions when the layout
algorithm changes. After the global layout pass, each zone may project its algorithm changes. After the global layout pass, each zone may project its
assigned visible nodes into local coordinates inside its container. The first assigned visible nodes into local coordinates inside its container. The current
local layout may be a deterministic compact layout; later engines can replace local layout choices are compact grid and circle. The selected zone-local
that with per-zone Cytoscape or engine-owned algorithms. layout algorithm belongs in the nested `zone.layout.algorithm` view state so it
can be restored by saved or copied views without changing the Fabric payload.
Zone collapse is a view-only operation. A collapsed zone should hide its visible Zone collapse is a view-only operation. A collapsed zone should hide its visible
member nodes, replace them with a synthetic zone node, and draw synthetic member nodes, replace them with a synthetic zone node, and draw synthetic

View File

@@ -455,6 +455,12 @@ def graph_explorer_page() -> str:
<option value="accessZone">Access Zone</option> <option value="accessZone">Access Zone</option>
</select> </select>
</label> </label>
<label class="map-control-field"><span class="field-label">Zone Layout <button type="button" class="help-tip" aria-label="Zone layout help" data-help-title="Zone Layout" data-help="Zone layout changes how each zone arranges its assigned visible nodes inside the stable zone rectangle. It does not change the underlying Fabric graph.">?</button></span>
<select id="zone-layout-select" title="Choose zone-local layout algorithm">
<option value="compact-grid">Grid</option>
<option value="circle">Circle</option>
</select>
</label>
<label class="map-control-field"><span class="field-label">Labels <button type="button" class="help-tip" aria-label="Labels help" data-help-title="Labels" data-help="Label density changes only text visibility. Auto hides low-priority labels when the map is dense; Key keeps repositories, services, deployments, servers, and issue markers visible.">?</button></span> <label class="map-control-field"><span class="field-label">Labels <button type="button" class="help-tip" aria-label="Labels help" data-help-title="Labels" data-help="Label density changes only text visibility. Auto hides low-priority labels when the map is dense; Key keeps repositories, services, deployments, servers, and issue markers visible.">?</button></span>
<select id="label-select" title="Control node label density"> <select id="label-select" title="Control node label density">
<option value="auto">Auto</option> <option value="auto">Auto</option>
@@ -570,6 +576,7 @@ def graph_explorer_page() -> str:
const labelSelect = document.getElementById("label-select"); const labelSelect = document.getElementById("label-select");
const zoneBoundaryToggle = document.getElementById("zone-boundary-toggle"); const zoneBoundaryToggle = document.getElementById("zone-boundary-toggle");
const zoneGroupSelect = document.getElementById("zone-group-select"); const zoneGroupSelect = document.getElementById("zone-group-select");
const zoneLayoutSelect = document.getElementById("zone-layout-select");
const nodeTypeFilter = document.getElementById("node-type-filter"); const nodeTypeFilter = document.getElementById("node-type-filter");
const nodeTypeSummary = document.getElementById("node-type-summary"); const nodeTypeSummary = document.getElementById("node-type-summary");
const edgeTypeFilter = document.getElementById("edge-type-filter"); const edgeTypeFilter = document.getElementById("edge-type-filter");
@@ -610,6 +617,7 @@ def graph_explorer_page() -> str:
let activeLabelMode = "auto"; let activeLabelMode = "auto";
let activeZoneGrouping = "deploymentEnvironment"; let activeZoneGrouping = "deploymentEnvironment";
let activeZoneDefinitionSet = "fabric-default"; let activeZoneDefinitionSet = "fabric-default";
let activeZoneLayoutAlgorithm = "compact-grid";
let profilePersistence = "none"; let profilePersistence = "none";
let profiles = []; let profiles = [];
let currentProfileId = ""; let currentProfileId = "";
@@ -1062,6 +1070,11 @@ def graph_explorer_page() -> str:
}, },
}; };
const zoneLayoutAlgorithms = new Set(["compact-grid", "circle"]);
const normalizeZoneLayoutAlgorithm = (value) =>
zoneLayoutAlgorithms.has(String(value || "")) ? String(value) : "compact-grid";
const zoneElementData = (element) => { const zoneElementData = (element) => {
if (!element) return {}; if (!element) return {};
if (typeof element.data === "function") return element.data(); if (typeof element.data === "function") return element.data();
@@ -1495,7 +1508,7 @@ def graph_explorer_page() -> str:
.slice() .slice()
.sort((left, right) => zoneNodeSortKey(left).localeCompare(zoneNodeSortKey(right))); .sort((left, right) => zoneNodeSortKey(left).localeCompare(zoneNodeSortKey(right)));
if (!nodes.length || !container) return; if (!nodes.length || !container) return;
const algorithm = String(zone.layout?.algorithm || "compact-grid"); const algorithm = normalizeZoneLayoutAlgorithm(activeZoneLayoutAlgorithm || zone.layout?.algorithm);
if (algorithm === "circle") { if (algorithm === "circle") {
layoutZoneNodesInCircle(nodes, container); layoutZoneNodesInCircle(nodes, container);
return; return;
@@ -2031,6 +2044,9 @@ def graph_explorer_page() -> str:
const currentZoneViewState = () => { const currentZoneViewState = () => {
const visible = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true; const visible = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true;
const grouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment"; const grouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment";
const layoutAlgorithm = normalizeZoneLayoutAlgorithm(
zoneLayoutSelect ? zoneLayoutSelect.value : activeZoneLayoutAlgorithm
);
const definitionSet = zoneDefinitionSets[activeZoneDefinitionSet] const definitionSet = zoneDefinitionSets[activeZoneDefinitionSet]
? activeZoneDefinitionSet ? activeZoneDefinitionSet
: "fabric-default"; : "fabric-default";
@@ -2038,6 +2054,9 @@ def graph_explorer_page() -> str:
visible, visible,
grouping, grouping,
definitionSet, definitionSet,
layout: {
algorithm: layoutAlgorithm,
},
presentation: { presentation: {
boundaries: visible, boundaries: visible,
labels: true, labels: true,
@@ -2061,6 +2080,7 @@ def graph_explorer_page() -> str:
zoneBoundaries: zone.visible, zoneBoundaries: zone.visible,
zoneGrouping: zone.grouping, zoneGrouping: zone.grouping,
zoneDefinitionSet: zone.definitionSet, zoneDefinitionSet: zone.definitionSet,
zoneLayoutAlgorithm: zone.layout.algorithm,
rules: filterRules.map((rule) => ({...rule})), rules: filterRules.map((rule) => ({...rule})),
manualOverrides: {...manualOverrides}, manualOverrides: {...manualOverrides},
}; };
@@ -2093,6 +2113,7 @@ def graph_explorer_page() -> str:
if (params.has("zoneBoundaries")) state.zoneBoundaries = params.get("zoneBoundaries") !== "0"; if (params.has("zoneBoundaries")) state.zoneBoundaries = params.get("zoneBoundaries") !== "0";
if (params.has("zoneGrouping")) state.zoneGrouping = params.get("zoneGrouping") || ""; if (params.has("zoneGrouping")) state.zoneGrouping = params.get("zoneGrouping") || "";
if (params.has("zoneDefinitionSet")) state.zoneDefinitionSet = params.get("zoneDefinitionSet") || ""; if (params.has("zoneDefinitionSet")) state.zoneDefinitionSet = params.get("zoneDefinitionSet") || "";
if (params.has("zoneLayout")) state.zoneLayoutAlgorithm = params.get("zoneLayout") || "";
if (params.has("profile")) state.profile = params.get("profile") || ""; if (params.has("profile")) state.profile = params.get("profile") || "";
if (params.has("state")) { if (params.has("state")) {
try { try {
@@ -2118,6 +2139,7 @@ def graph_explorer_page() -> str:
if (state.zoneBoundaries === false) params.set("zoneBoundaries", "0"); if (state.zoneBoundaries === false) params.set("zoneBoundaries", "0");
if (state.zoneGrouping && state.zoneGrouping !== "deploymentEnvironment") params.set("zoneGrouping", state.zoneGrouping); if (state.zoneGrouping && state.zoneGrouping !== "deploymentEnvironment") params.set("zoneGrouping", state.zoneGrouping);
if (state.zoneDefinitionSet && state.zoneDefinitionSet !== "fabric-default") params.set("zoneDefinitionSet", state.zoneDefinitionSet); if (state.zoneDefinitionSet && state.zoneDefinitionSet !== "fabric-default") params.set("zoneDefinitionSet", state.zoneDefinitionSet);
if (state.zoneLayoutAlgorithm && state.zoneLayoutAlgorithm !== "compact-grid") params.set("zoneLayout", state.zoneLayoutAlgorithm);
if (currentProfileId) params.set("profile", currentProfileId); if (currentProfileId) params.set("profile", currentProfileId);
if (includeStateBlob || hasManualOverrides() || filterRules.length > 0) { if (includeStateBlob || hasManualOverrides() || filterRules.length > 0) {
params.set("state", encodeStateBlob(state)); params.set("state", encodeStateBlob(state));
@@ -2142,6 +2164,7 @@ def graph_explorer_page() -> str:
const containers = nested.containers && typeof nested.containers === "object" const containers = nested.containers && typeof nested.containers === "object"
? nested.containers ? nested.containers
: state.zoneContainers; : state.zoneContainers;
const layout = nested.layout && typeof nested.layout === "object" ? nested.layout : {};
const grouping = nested.grouping || state.zoneGrouping || "deploymentEnvironment"; const grouping = nested.grouping || state.zoneGrouping || "deploymentEnvironment";
const definitionSet = zoneDefinitionSets[nested.definitionSet || state.zoneDefinitionSet] const definitionSet = zoneDefinitionSets[nested.definitionSet || state.zoneDefinitionSet]
? nested.definitionSet || state.zoneDefinitionSet ? nested.definitionSet || state.zoneDefinitionSet
@@ -2155,6 +2178,9 @@ def graph_explorer_page() -> str:
visible, visible,
grouping: zoneGroupSelect && optionExists(zoneGroupSelect, grouping) ? grouping : "deploymentEnvironment", grouping: zoneGroupSelect && optionExists(zoneGroupSelect, grouping) ? grouping : "deploymentEnvironment",
definitionSet, definitionSet,
layout: {
algorithm: normalizeZoneLayoutAlgorithm(layout.algorithm || state.zoneLayoutAlgorithm),
},
presentation: { presentation: {
boundaries: "boundaries" in presentation ? presentation.boundaries !== false : visible, boundaries: "boundaries" in presentation ? presentation.boundaries !== false : visible,
labels: "labels" in presentation ? presentation.labels !== false : true, labels: "labels" in presentation ? presentation.labels !== false : true,
@@ -2173,11 +2199,13 @@ def graph_explorer_page() -> str:
if ("edgeTypes" in state) setCheckedValues(edgeTypeFilter, (state.edgeTypes || []).filter((value) => allEdgeTypes.includes(value))); if ("edgeTypes" in state) setCheckedValues(edgeTypeFilter, (state.edgeTypes || []).filter((value) => allEdgeTypes.includes(value)));
if ("review" in state) reviewFilter.value = optionExists(reviewFilter, state.review) ? state.review : ""; if ("review" in state) reviewFilter.value = optionExists(reviewFilter, state.review) ? state.review : "";
if ("unresolved" in state) unresolvedFilter.value = optionExists(unresolvedFilter, state.unresolved) ? state.unresolved : ""; if ("unresolved" in state) unresolvedFilter.value = optionExists(unresolvedFilter, state.unresolved) ? state.unresolved : "";
if ("zone" in state || "zoneBoundaries" in state || "zoneGrouping" in state || "zoneDefinitionSet" in state) { if ("zone" in state || "zoneBoundaries" in state || "zoneGrouping" in state || "zoneDefinitionSet" in state || "zoneLayoutAlgorithm" in state) {
const zone = normalizeZoneViewState(state); const zone = normalizeZoneViewState(state);
activeZoneDefinitionSet = zone.definitionSet; activeZoneDefinitionSet = zone.definitionSet;
if (zoneBoundaryToggle) zoneBoundaryToggle.checked = zone.visible; if (zoneBoundaryToggle) zoneBoundaryToggle.checked = zone.visible;
if (zoneGroupSelect) zoneGroupSelect.value = zone.grouping; if (zoneGroupSelect) zoneGroupSelect.value = zone.grouping;
activeZoneLayoutAlgorithm = zone.layout.algorithm;
if (zoneLayoutSelect) zoneLayoutSelect.value = activeZoneLayoutAlgorithm;
zoneContainerState = zone.containers; zoneContainerState = zone.containers;
} }
if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") { if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") {
@@ -2192,6 +2220,9 @@ def graph_explorer_page() -> str:
activeMode = modeSelect.value || "full"; activeMode = modeSelect.value || "full";
activeLabelMode = labelSelect.value || "auto"; activeLabelMode = labelSelect.value || "auto";
activeZoneGrouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment"; activeZoneGrouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment";
activeZoneLayoutAlgorithm = normalizeZoneLayoutAlgorithm(
zoneLayoutSelect ? zoneLayoutSelect.value : activeZoneLayoutAlgorithm
);
selectedZoneId = ""; selectedZoneId = "";
collapsedZoneSnapshots = new Map(); collapsedZoneSnapshots = new Map();
focusSet = null; focusSet = null;
@@ -2233,6 +2264,9 @@ def graph_explorer_page() -> str:
if (zoneGroupSelect && (zoneGroupSelect.value || "deploymentEnvironment") !== "deploymentEnvironment") { if (zoneGroupSelect && (zoneGroupSelect.value || "deploymentEnvironment") !== "deploymentEnvironment") {
parts.push("access zone boundaries"); parts.push("access zone boundaries");
} }
if (zoneLayoutSelect && (zoneLayoutSelect.value || "compact-grid") !== "compact-grid") {
parts.push(`${zoneLayoutSelect.options[zoneLayoutSelect.selectedIndex]?.textContent || zoneLayoutSelect.value} zone layout`);
}
if (filterRules.length) parts.push(`${filterRules.length} rule${filterRules.length === 1 ? "" : "s"}`); if (filterRules.length) parts.push(`${filterRules.length} rule${filterRules.length === 1 ? "" : "s"}`);
const overrideCount = Object.keys(manualOverrides).length; const overrideCount = Object.keys(manualOverrides).length;
if (overrideCount) parts.push(`${overrideCount} override${overrideCount === 1 ? "" : "s"}`); if (overrideCount) parts.push(`${overrideCount} override${overrideCount === 1 ? "" : "s"}`);
@@ -2968,6 +3002,18 @@ def graph_explorer_page() -> str:
updateProfileSummary(); updateProfileSummary();
updateUrlState(); updateUrlState();
}); });
zoneLayoutSelect.addEventListener("input", () => {
activeZoneLayoutAlgorithm = normalizeZoneLayoutAlgorithm(zoneLayoutSelect.value);
currentProfileId = "";
profileSelect.value = "";
applyZoneContainerLayout();
fitVisibleGraph();
renderZoneOverlay();
if (!selected) renderMapOverview();
updateProfileControls();
updateProfileSummary();
updateUrlState();
});
zoneOverlay.addEventListener("click", (event) => { zoneOverlay.addEventListener("click", (event) => {
if (suppressZoneClick) { if (suppressZoneClick) {
suppressZoneClick = false; suppressZoneClick = false;
@@ -3089,6 +3135,8 @@ def graph_explorer_page() -> str:
zoneBoundaryToggle.checked = true; zoneBoundaryToggle.checked = true;
zoneGroupSelect.value = "deploymentEnvironment"; zoneGroupSelect.value = "deploymentEnvironment";
activeZoneGrouping = "deploymentEnvironment"; activeZoneGrouping = "deploymentEnvironment";
zoneLayoutSelect.value = "compact-grid";
activeZoneLayoutAlgorithm = "compact-grid";
activeZoneDefinitionSet = "fabric-default"; activeZoneDefinitionSet = "fabric-default";
selectedZoneId = ""; selectedZoneId = "";
collapsedZoneSnapshots = new Map(); collapsedZoneSnapshots = new Map();

View File

@@ -402,6 +402,7 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert 'id="zone-overlay"' in page assert 'id="zone-overlay"' in page
assert 'id="zone-boundary-toggle"' in page assert 'id="zone-boundary-toggle"' in page
assert 'id="zone-group-select"' in page assert 'id="zone-group-select"' in page
assert 'id="zone-layout-select"' in page
assert 'id="node-type-filter"' in page assert 'id="node-type-filter"' in page
assert 'id="edge-type-filter"' in page assert 'id="edge-type-filter"' in page
assert 'id="rule-panel"' in page assert 'id="rule-panel"' in page
@@ -429,6 +430,13 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert "zoneDefinitionSet" in page assert "zoneDefinitionSet" in page
assert "zoneDefinitionSets" in page assert "zoneDefinitionSets" in page
assert "fabric-default" in page assert "fabric-default" in page
assert "activeZoneLayoutAlgorithm" in page
assert "zoneLayoutAlgorithms" in page
assert "normalizeZoneLayoutAlgorithm" in page
assert "zoneLayoutAlgorithm" in page
assert "zoneLayout" in page
assert "compact-grid" in page
assert "circle" in page
assert "zoneContainerState" in page assert "zoneContainerState" in page
assert "ensureZoneContainer" in page assert "ensureZoneContainer" in page
assert "packZoneContainers" in page assert "packZoneContainers" in page

View File

@@ -0,0 +1,44 @@
---
id: ADHOC-2026-05-25
type: workplan
title: "Ad Hoc Fixes 2026-05-25"
domain: railiance
repo: railiance-fabric
status: finished
owner: codex
topic_slug: railiance
created: "2026-05-25"
updated: "2026-05-25"
---
# ADHOC-2026-05-25 - Ad Hoc Fixes
## Add Zone Layout Algorithm Control
```task
id: ADHOC-2026-05-25-T01
status: done
priority: medium
```
The graph explorer now lays zone subgraphs out as a grid inside each zone
container. Add an operator-facing control that can switch the zone-local layout
algorithm while keeping stable zone containers intact.
Expected result: the map controls expose a zone layout selector, the selected
algorithm applies to each zone subgraph, and the setting persists in saved or
copied view state.
Result: Added a `Zone Layout` selector with Grid and Circle algorithms. The
selected algorithm is stored in nested zone view state, reflected by the
`zoneLayout` URL alias for non-default layout, and reapplies zone-local node
placement without moving stable zone containers.
Verification:
- `python3 -m pytest tests/test_graph_explorer.py tests/test_zone_view.py -q`
passed.
- Generated graph explorer JavaScript passed `node --check`.
- `python3 -m railiance_fabric.cli validate .` passed.
- `python3 -m pytest -q` passed with 72 tests.
- Headless Edge screenshots confirmed Grid and Circle zone layouts render.