diff --git a/docs/ZoneEntityVisualization.md b/docs/ZoneEntityVisualization.md
index 66f055d..446c3f7 100644
--- a/docs/ZoneEntityVisualization.md
+++ b/docs/ZoneEntityVisualization.md
@@ -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
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
Display-only context edges are not zone connectivity. Repository `declares`
diff --git a/docs/graph-explorer-contract.md b/docs/graph-explorer-contract.md
index 1e45d54..5af6dc7 100644
--- a/docs/graph-explorer-contract.md
+++ b/docs/graph-explorer-contract.md
@@ -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
zone containers should keep their operator-chosen positions when the layout
algorithm changes. After the global layout pass, each zone may project its
-assigned visible nodes into local coordinates inside its container. The first
-local layout may be a deterministic compact layout; later engines can replace
-that with per-zone Cytoscape or engine-owned algorithms.
+assigned visible nodes into local coordinates inside its container. The current
+local layout choices are compact grid and circle. The selected zone-local
+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
member nodes, replace them with a synthetic zone node, and draw synthetic
diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py
index 7a8d623..193257c 100644
--- a/railiance_fabric/graph_explorer_ui.py
+++ b/railiance_fabric/graph_explorer_ui.py
@@ -455,6 +455,12 @@ def graph_explorer_page() -> str:
Access Zone
+ Zone Layout ?
+
+ Grid
+ Circle
+
+
Labels ?
Auto
@@ -570,6 +576,7 @@ def graph_explorer_page() -> str:
const labelSelect = document.getElementById("label-select");
const zoneBoundaryToggle = document.getElementById("zone-boundary-toggle");
const zoneGroupSelect = document.getElementById("zone-group-select");
+ const zoneLayoutSelect = document.getElementById("zone-layout-select");
const nodeTypeFilter = document.getElementById("node-type-filter");
const nodeTypeSummary = document.getElementById("node-type-summary");
const edgeTypeFilter = document.getElementById("edge-type-filter");
@@ -610,6 +617,7 @@ def graph_explorer_page() -> str:
let activeLabelMode = "auto";
let activeZoneGrouping = "deploymentEnvironment";
let activeZoneDefinitionSet = "fabric-default";
+ let activeZoneLayoutAlgorithm = "compact-grid";
let profilePersistence = "none";
let profiles = [];
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) => {
if (!element) return {};
if (typeof element.data === "function") return element.data();
@@ -1495,7 +1508,7 @@ def graph_explorer_page() -> str:
.slice()
.sort((left, right) => zoneNodeSortKey(left).localeCompare(zoneNodeSortKey(right)));
if (!nodes.length || !container) return;
- const algorithm = String(zone.layout?.algorithm || "compact-grid");
+ const algorithm = normalizeZoneLayoutAlgorithm(activeZoneLayoutAlgorithm || zone.layout?.algorithm);
if (algorithm === "circle") {
layoutZoneNodesInCircle(nodes, container);
return;
@@ -2031,6 +2044,9 @@ def graph_explorer_page() -> str:
const currentZoneViewState = () => {
const visible = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true;
const grouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment";
+ const layoutAlgorithm = normalizeZoneLayoutAlgorithm(
+ zoneLayoutSelect ? zoneLayoutSelect.value : activeZoneLayoutAlgorithm
+ );
const definitionSet = zoneDefinitionSets[activeZoneDefinitionSet]
? activeZoneDefinitionSet
: "fabric-default";
@@ -2038,6 +2054,9 @@ def graph_explorer_page() -> str:
visible,
grouping,
definitionSet,
+ layout: {
+ algorithm: layoutAlgorithm,
+ },
presentation: {
boundaries: visible,
labels: true,
@@ -2061,6 +2080,7 @@ def graph_explorer_page() -> str:
zoneBoundaries: zone.visible,
zoneGrouping: zone.grouping,
zoneDefinitionSet: zone.definitionSet,
+ zoneLayoutAlgorithm: zone.layout.algorithm,
rules: filterRules.map((rule) => ({...rule})),
manualOverrides: {...manualOverrides},
};
@@ -2093,6 +2113,7 @@ def graph_explorer_page() -> str:
if (params.has("zoneBoundaries")) state.zoneBoundaries = params.get("zoneBoundaries") !== "0";
if (params.has("zoneGrouping")) state.zoneGrouping = params.get("zoneGrouping") || "";
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("state")) {
try {
@@ -2118,6 +2139,7 @@ def graph_explorer_page() -> str:
if (state.zoneBoundaries === false) params.set("zoneBoundaries", "0");
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.zoneLayoutAlgorithm && state.zoneLayoutAlgorithm !== "compact-grid") params.set("zoneLayout", state.zoneLayoutAlgorithm);
if (currentProfileId) params.set("profile", currentProfileId);
if (includeStateBlob || hasManualOverrides() || filterRules.length > 0) {
params.set("state", encodeStateBlob(state));
@@ -2142,6 +2164,7 @@ def graph_explorer_page() -> str:
const containers = nested.containers && typeof nested.containers === "object"
? nested.containers
: state.zoneContainers;
+ const layout = nested.layout && typeof nested.layout === "object" ? nested.layout : {};
const grouping = nested.grouping || state.zoneGrouping || "deploymentEnvironment";
const definitionSet = zoneDefinitionSets[nested.definitionSet || state.zoneDefinitionSet]
? nested.definitionSet || state.zoneDefinitionSet
@@ -2155,6 +2178,9 @@ def graph_explorer_page() -> str:
visible,
grouping: zoneGroupSelect && optionExists(zoneGroupSelect, grouping) ? grouping : "deploymentEnvironment",
definitionSet,
+ layout: {
+ algorithm: normalizeZoneLayoutAlgorithm(layout.algorithm || state.zoneLayoutAlgorithm),
+ },
presentation: {
boundaries: "boundaries" in presentation ? presentation.boundaries !== false : visible,
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 ("review" in state) reviewFilter.value = optionExists(reviewFilter, state.review) ? state.review : "";
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);
activeZoneDefinitionSet = zone.definitionSet;
if (zoneBoundaryToggle) zoneBoundaryToggle.checked = zone.visible;
if (zoneGroupSelect) zoneGroupSelect.value = zone.grouping;
+ activeZoneLayoutAlgorithm = zone.layout.algorithm;
+ if (zoneLayoutSelect) zoneLayoutSelect.value = activeZoneLayoutAlgorithm;
zoneContainerState = zone.containers;
}
if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") {
@@ -2192,6 +2220,9 @@ def graph_explorer_page() -> str:
activeMode = modeSelect.value || "full";
activeLabelMode = labelSelect.value || "auto";
activeZoneGrouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment";
+ activeZoneLayoutAlgorithm = normalizeZoneLayoutAlgorithm(
+ zoneLayoutSelect ? zoneLayoutSelect.value : activeZoneLayoutAlgorithm
+ );
selectedZoneId = "";
collapsedZoneSnapshots = new Map();
focusSet = null;
@@ -2233,6 +2264,9 @@ def graph_explorer_page() -> str:
if (zoneGroupSelect && (zoneGroupSelect.value || "deploymentEnvironment") !== "deploymentEnvironment") {
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"}`);
const overrideCount = Object.keys(manualOverrides).length;
if (overrideCount) parts.push(`${overrideCount} override${overrideCount === 1 ? "" : "s"}`);
@@ -2968,6 +3002,18 @@ def graph_explorer_page() -> str:
updateProfileSummary();
updateUrlState();
});
+ zoneLayoutSelect.addEventListener("input", () => {
+ activeZoneLayoutAlgorithm = normalizeZoneLayoutAlgorithm(zoneLayoutSelect.value);
+ currentProfileId = "";
+ profileSelect.value = "";
+ applyZoneContainerLayout();
+ fitVisibleGraph();
+ renderZoneOverlay();
+ if (!selected) renderMapOverview();
+ updateProfileControls();
+ updateProfileSummary();
+ updateUrlState();
+ });
zoneOverlay.addEventListener("click", (event) => {
if (suppressZoneClick) {
suppressZoneClick = false;
@@ -3089,6 +3135,8 @@ def graph_explorer_page() -> str:
zoneBoundaryToggle.checked = true;
zoneGroupSelect.value = "deploymentEnvironment";
activeZoneGrouping = "deploymentEnvironment";
+ zoneLayoutSelect.value = "compact-grid";
+ activeZoneLayoutAlgorithm = "compact-grid";
activeZoneDefinitionSet = "fabric-default";
selectedZoneId = "";
collapsedZoneSnapshots = new Map();
diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py
index dce0c82..ec5f192 100644
--- a/tests/test_graph_explorer.py
+++ b/tests/test_graph_explorer.py
@@ -402,6 +402,7 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert 'id="zone-overlay"' in page
assert 'id="zone-boundary-toggle"' 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="edge-type-filter"' 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 "zoneDefinitionSets" 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 "ensureZoneContainer" in page
assert "packZoneContainers" in page
diff --git a/workplans/ADHOC-2026-05-25.md b/workplans/ADHOC-2026-05-25.md
new file mode 100644
index 0000000..7d518ee
--- /dev/null
+++ b/workplans/ADHOC-2026-05-25.md
@@ -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.