feat: stabilize graph zone containers

This commit is contained in:
2026-05-25 02:08:45 +02:00
parent 0f7b7d1fed
commit 558e0dc157
7 changed files with 496 additions and 13 deletions

View File

@@ -203,6 +203,31 @@ pieces. A follow-up should introduce a zone container placement phase before
attempting per-zone node layout. That follow-up can keep Cytoscape as the final
renderer while moving layout decisions into a Fabric-owned view model.
### Stable Zone Containers
The first container implementation keeps zone surfaces as view state keyed by
stable zone id. When a zone first appears, the global graph layout supplies its
initial center. Once created, the container owns the zone surface position while
the global layout continues to arrange the base canvas and unzoned nodes.
Dragging a zone moves the container and its currently assigned visible member
nodes together. Rerunning layout or switching the layout algorithm should keep
the container in its stored graph coordinates and then project the zone's
visible subgraph back into that container.
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.
### Context Edges
Display-only context edges are not zone connectivity. Repository `declares`
edges, for example, show which repository declared a node, but they should not
create boundary diagnostics, attraction paths, or collapsed-zone boundary
edges. A host can still show them as explanatory evidence in details, but a
zone boundary should only react to canonical or host-promoted graph
relationships.
## Layer Height And Overlap
Zone presentation includes a height. Height is a visual stacking concept, not a

View File

@@ -192,13 +192,27 @@ initial diagnostic set includes empty zone seed sets, visible nodes matched by
multiple zone definitions, and edges crossing zone boundaries. Attraction
diagnostics such as multiple attraction candidates or depth-limit stops belong
to the same resolver diagnostic channel when attraction rules are enabled.
Display-only context edges, such as repository `declares` edges, are evidence
for where declarations came from. They must not count as zone boundary
connectivity, attraction paths, or collapsed-zone boundary edges unless a host
explicitly promotes them to canonical graph relationships.
Saved graph profiles should persist zone view state as an explicit nested
`zone` object. The initial fields are `visible`, `grouping`, `definitionSet`,
and `presentation`. URL parameters may continue to expose compatibility aliases
such as `zoneBoundaries`, `zoneGrouping`, and `zoneDefinitionSet`, but saved
profiles should prefer the nested object so future zone definition sets and
presentation preferences can be restored without another state migration.
`presentation`, and `containers`. URL parameters may continue to expose
compatibility aliases such as `zoneBoundaries`, `zoneGrouping`, and
`zoneDefinitionSet`, but saved profiles should prefer the nested object so
future zone definition sets, presentation preferences, and operator-placed zone
surfaces can be restored without another state migration.
Zone containers are view state, not fabric data. A container stores a stable
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.
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

View File

@@ -619,6 +619,7 @@ def graph_explorer_page() -> str:
let selectedZoneId = "";
let zoneOverlayFrame = 0;
let zoneSummaries = new Map();
let zoneContainerState = new Map();
let zoneDragState = null;
let suppressZoneClick = false;
let collapsedZoneSnapshots = new Map();
@@ -1102,12 +1103,24 @@ def graph_explorer_page() -> str:
return String(actual) === String(rule.value ?? "");
};
const isTrueish = (value) => value === true || String(value).toLowerCase() === "true";
const isZoneContextOnlyEdge = (element) => {
const data = zoneElementData(element);
const edgeType = String(data.edgeType || data.dependencyType || "").trim();
return isTrueish(data.displayOnly) || edgeType === "declares";
};
const isZoneConnectivityEdge = (element) =>
element && element.isEdge && element.isEdge() && !isZoneContextOnlyEdge(element);
const zoneDescriptorFromDefinition = (definition) => ({
field: definition.field,
id: definition.id,
label: definition.label || definition.id,
value: definition.value || definition.label || definition.id,
rank: definition.rank,
layout: definition.layout || {},
presentation: definition.presentation || {},
membership: definition.membership || {},
diagnostics: [],
@@ -1225,15 +1238,18 @@ def graph_explorer_page() -> str:
}
});
elements.filter((element) => element.isEdge && element.isEdge()).forEach((edge) => {
const isConnectivity = isZoneConnectivityEdge(edge);
const data = zoneElementData(edge);
const zoneIds = new Set(
definitions
isConnectivity
? definitions
.filter((definition) => zoneRuleMatches(data, definition.membership))
.map((definition) => definition.id)
: []
);
const sourceZoneId = assignments.get(edge.data("source"));
const targetZoneId = assignments.get(edge.data("target"));
if ((sourceZoneId || targetZoneId) && sourceZoneId !== targetZoneId) {
if (isConnectivity && (sourceZoneId || targetZoneId) && sourceZoneId !== targetZoneId) {
const diagnostic = {
severity: "info",
code: zoneDiagnosticCodes.edgeCrossesZoneBoundary,
@@ -1243,8 +1259,8 @@ def graph_explorer_page() -> str:
.filter(Boolean)
.forEach((zoneId) => zones.get(zoneId)?.diagnostics.push(diagnostic));
}
if (sourceZoneId) zoneIds.add(sourceZoneId);
if (targetZoneId) zoneIds.add(targetZoneId);
if (isConnectivity && sourceZoneId) zoneIds.add(sourceZoneId);
if (isConnectivity && targetZoneId) zoneIds.add(targetZoneId);
zoneIds.forEach((zoneId) => addElementToZone(zoneId, edge));
});
return zones;
@@ -1327,6 +1343,221 @@ def graph_explorer_page() -> str:
return {left, top, width, height};
};
const zoneNodeSortKey = (node) =>
String(node.data("stableKey") || node.data("label") || node.id());
const zoneCenterForNodes = (nodes) => {
const positions = nodes
.map((node) => node.position())
.filter((position) => Number.isFinite(position.x) && Number.isFinite(position.y));
if (!positions.length) return null;
return {
x: positions.reduce((total, position) => total + position.x, 0) / positions.length,
y: positions.reduce((total, position) => total + position.y, 0) / positions.length,
};
};
const zonePreferredSize = (zone) => {
const count = Math.max(1, (zone.nodes || []).length);
const columns = Math.max(1, Math.ceil(Math.sqrt(count * 1.35)));
const rows = Math.max(1, Math.ceil(count / columns));
const cellWidth = Number(zone.layout?.options?.cellWidth) || 118;
const cellHeight = Number(zone.layout?.options?.cellHeight) || 94;
const padding = Number(zone.layout?.options?.padding) || 60;
return {
width: Math.max(180, columns * cellWidth + padding * 2),
height: Math.max(128, rows * cellHeight + padding * 2),
padding,
};
};
const ensureZoneContainer = (zone) => {
if (!zone || !(zone.nodes || []).length) return null;
const preferred = zonePreferredSize(zone);
const existing = zoneContainerState.get(zone.id);
if (existing) {
existing.width = preferred.width;
existing.height = preferred.height;
existing.padding = preferred.padding;
return existing;
}
const center = zoneCenterForNodes(zone.nodes) || {x: 0, y: 0};
const container = {
id: zone.id,
x: center.x,
y: center.y,
width: preferred.width,
height: preferred.height,
padding: preferred.padding,
userPlaced: false,
};
zoneContainerState.set(zone.id, container);
return container;
};
const packZoneContainers = (zones) => {
const items = Array.from(zones.values())
.filter((zone) => (zone.nodes || []).length > 0)
.map((zone) => ({zone, container: ensureZoneContainer(zone)}))
.filter((item) => item.container);
if (items.length < 2 || items.some((item) => item.container.userPlaced)) return;
items.sort((left, right) =>
zoneRank(left.zone) - zoneRank(right.zone) || left.zone.label.localeCompare(right.zone.label)
);
const gap = 42;
const start = Math.min(...items.map((item) => item.container.x - item.container.width / 2));
const maxRowWidth = cy
? Math.max(720, (cy.width() || 960) / Math.max(cy.zoom() || 1, 0.2) - 96)
: Infinity;
let cursor = start;
let top = Math.min(...items.map((item) => item.container.y - item.container.height / 2));
let rowHeight = 0;
items.forEach(({container}) => {
if (cursor > start && cursor + container.width > start + maxRowWidth) {
cursor = start;
top += rowHeight + gap;
rowHeight = 0;
}
container.x = cursor + container.width / 2;
container.y = top + container.height / 2;
cursor += container.width + gap;
rowHeight = Math.max(rowHeight, container.height);
});
};
const syncZoneContainers = (zones, options = {}) => {
let created = false;
zones.forEach((zone) => {
if ((zone.nodes || []).length > 0 && !zoneContainerState.has(zone.id)) created = true;
ensureZoneContainer(zone);
});
if (options.packNew && created) packZoneContainers(zones);
};
const zoneContainerBounds = (container) => ({
left: container.x - container.width / 2,
top: container.y - container.height / 2,
width: container.width,
height: container.height,
});
const zoneRenderedBoundsFromContainer = (container) => {
const bounds = zoneContainerBounds(container);
const zoom = cy ? cy.zoom() || 1 : 1;
const pan = cy ? cy.pan() || {x: 0, y: 0} : {x: 0, y: 0};
return {
left: bounds.left * zoom + pan.x,
top: bounds.top * zoom + pan.y,
width: bounds.width * zoom,
height: bounds.height * zoom,
};
};
const layoutZoneNodesInGrid = (nodes, container) => {
const padding = Number(container.padding) || 60;
const count = Math.max(1, nodes.length);
const columns = Math.max(1, Math.ceil(Math.sqrt(count * 1.35)));
const rows = Math.max(1, Math.ceil(count / columns));
const innerWidth = Math.max(1, container.width - padding * 2);
const innerHeight = Math.max(1, container.height - padding * 2);
const cellWidth = innerWidth / columns;
const cellHeight = innerHeight / rows;
const left = container.x - container.width / 2 + padding;
const top = container.y - container.height / 2 + padding;
nodes.forEach((node, index) => {
const column = index % columns;
const row = Math.floor(index / columns);
node.position({
x: left + cellWidth * (column + 0.5),
y: top + cellHeight * (row + 0.5),
});
});
};
const layoutZoneNodesInCircle = (nodes, container) => {
if (nodes.length === 1) {
nodes[0].position({x: container.x, y: container.y});
return;
}
const padding = Number(container.padding) || 60;
const radius = Math.max(20, Math.min(container.width, container.height) / 2 - padding);
nodes.forEach((node, index) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / nodes.length;
node.position({
x: container.x + Math.cos(angle) * radius,
y: container.y + Math.sin(angle) * radius,
});
});
};
const layoutZoneSubgraph = (zone, container) => {
const nodes = (zone.nodes || [])
.slice()
.sort((left, right) => zoneNodeSortKey(left).localeCompare(zoneNodeSortKey(right)));
if (!nodes.length || !container) return;
const algorithm = String(zone.layout?.algorithm || "compact-grid");
if (algorithm === "circle") {
layoutZoneNodesInCircle(nodes, container);
return;
}
layoutZoneNodesInGrid(nodes, container);
};
const applyZoneContainerLayout = () => {
if (!cy || zoneDragState) return;
const zones = collectZoneSummaries();
syncZoneContainers(zones, {packNew: true});
zones.forEach((zone) => {
if (!(zone.nodes || []).length) return;
layoutZoneSubgraph(zone, ensureZoneContainer(zone));
});
zoneSummaries = zones;
updateSelectionAnchor();
scheduleZoneOverlayUpdate();
};
const fitVisibleGraph = () => {
if (!cy) return;
const visible = cy.elements().filter((element) => element.style("display") !== "none");
if (visible.length) cy.fit(visible, 72);
};
const serializedZoneContainers = () => {
const entries = Array.from(zoneContainerState.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([id, container]) => [id, {
x: Math.round(container.x * 100) / 100,
y: Math.round(container.y * 100) / 100,
width: Math.round(container.width * 100) / 100,
height: Math.round(container.height * 100) / 100,
userPlaced: container.userPlaced === true,
}]);
return Object.fromEntries(entries);
};
const normalizedZoneContainers = (containers) => {
const normalized = new Map();
if (!containers || typeof containers !== "object") return normalized;
Object.entries(containers).forEach(([id, value]) => {
if (!value || typeof value !== "object") return;
const x = Number(value.x);
const y = Number(value.y);
const width = Number(value.width);
const height = Number(value.height);
if (![x, y, width, height].every(Number.isFinite)) return;
normalized.set(id, {
id,
x,
y,
width: Math.max(1, width),
height: Math.max(1, height),
padding: Number(value.padding) || 60,
userPlaced: value.userPlaced === true,
});
});
return normalized;
};
const collectZoneSummaries = () => {
if (!cy) return new Map();
const visibleElements = cy.elements()
@@ -1439,7 +1670,7 @@ def graph_explorer_page() -> str:
const boundaryEdges = [];
let internalEdgeCount = 0;
(zone.elements || [])
.filter((element) => element.isEdge && element.isEdge())
.filter(isZoneConnectivityEdge)
.forEach((edge) => {
const source = edge.data("source");
const target = edge.data("target");
@@ -1574,11 +1805,13 @@ def graph_explorer_page() -> str:
return;
}
zoneSummaries = collectZoneSummaries();
syncZoneContainers(zoneSummaries);
const boundaries = Array.from(zoneSummaries.values())
.filter((zone) => zone.nodes.length > 0)
.sort((left, right) => zoneRank(left) - zoneRank(right) || left.label.localeCompare(right.label))
.map((zone, index) => {
const bounds = zoneBoundsForNodes(zone.nodes);
const container = ensureZoneContainer(zone);
const bounds = container ? zoneRenderedBoundsFromContainer(container) : zoneBoundsForNodes(zone.nodes);
if (!bounds) return "";
const style = zoneStyle(zone);
const selectedClass = selectedZoneId === zone.id ? " selected" : "";
@@ -1600,6 +1833,8 @@ def graph_explorer_page() -> str:
const startZoneDrag = (event, zoneId) => {
const zone = zoneSummaries.get(zoneId);
if (!cy || !zone || !(zone.nodes || []).length) return;
const container = ensureZoneContainer(zone);
if (!container) return;
event.preventDefault();
event.stopPropagation();
selected = null;
@@ -1614,6 +1849,7 @@ def graph_explorer_page() -> str:
startY: event.clientY,
moved: false,
handle,
container: {...container},
nodes: zone.nodes.map((node) => ({
node,
position: {...node.position()},
@@ -1636,6 +1872,12 @@ def graph_explorer_page() -> str:
if (!node || !node.length) return;
node.position({x: position.x + dx, y: position.y + dy});
});
const container = zoneContainerState.get(zoneDragState.zoneId);
if (container && zoneDragState.container) {
container.x = zoneDragState.container.x + dx;
container.y = zoneDragState.container.y + dy;
container.userPlaced = true;
}
updateSelectionAnchor();
scheduleZoneOverlayUpdate();
};
@@ -1652,6 +1894,8 @@ def graph_explorer_page() -> str:
const zone = zoneSummaries.get(draggedZoneId);
if (zone) renderZoneDetails(zone);
updateProfileSummary(`Moved zone "${zone?.label || draggedZoneId}".`);
updateProfileControls();
updateUrlState();
window.setTimeout(() => { suppressZoneClick = false; }, 0);
}
};
@@ -1798,6 +2042,7 @@ def graph_explorer_page() -> str:
boundaries: visible,
labels: true,
},
containers: serializedZoneContainers(),
};
};
@@ -1894,6 +2139,9 @@ def graph_explorer_page() -> str:
const presentation = nested.presentation && typeof nested.presentation === "object"
? nested.presentation
: {};
const containers = nested.containers && typeof nested.containers === "object"
? nested.containers
: state.zoneContainers;
const grouping = nested.grouping || state.zoneGrouping || "deploymentEnvironment";
const definitionSet = zoneDefinitionSets[nested.definitionSet || state.zoneDefinitionSet]
? nested.definitionSet || state.zoneDefinitionSet
@@ -1911,6 +2159,7 @@ def graph_explorer_page() -> str:
boundaries: "boundaries" in presentation ? presentation.boundaries !== false : visible,
labels: "labels" in presentation ? presentation.labels !== false : true,
},
containers: normalizedZoneContainers(containers),
};
};
@@ -1929,6 +2178,7 @@ def graph_explorer_page() -> str:
activeZoneDefinitionSet = zone.definitionSet;
if (zoneBoundaryToggle) zoneBoundaryToggle.checked = zone.visible;
if (zoneGroupSelect) zoneGroupSelect.value = zone.grouping;
zoneContainerState = zone.containers;
}
if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") {
manualOverrides = {...state.manualOverrides};
@@ -2478,6 +2728,8 @@ def graph_explorer_page() -> str:
}
: {name, padding: 48, animate: false};
layoutElements.layout(options).run();
applyZoneContainerLayout();
fitVisibleGraph();
updateSelectionAnchor();
scheduleZoneOverlayUpdate();
};
@@ -2614,7 +2866,12 @@ def graph_explorer_page() -> str:
renderRules();
cy.on("tap", "node, edge", (event) => showDetails(event.target));
cy.on("tap", (event) => { if (event.target === cy) showDetails(null); });
cy.on("pan zoom resize render layoutstop", () => {
cy.on("pan zoom resize render", () => {
updateSelectionAnchor();
scheduleZoneOverlayUpdate();
});
cy.on("layoutstop", () => {
applyZoneContainerLayout();
updateSelectionAnchor();
scheduleZoneOverlayUpdate();
});

View File

@@ -401,6 +401,8 @@ def resolve_zones(
internal_edge_ids_by_zone_id: dict[str, list[str]] = defaultdict(list)
boundary_edge_ids_by_zone_id: dict[str, list[str]] = defaultdict(list)
for edge in edge_records:
if _is_context_only_edge(edge):
continue
source_zone_id = assignments.get(edge.source).zone_id if edge.source in assignments else None
target_zone_id = assignments.get(edge.target).zone_id if edge.target in assignments else None
if source_zone_id and source_zone_id == target_zone_id:
@@ -484,6 +486,8 @@ def _attraction_candidates(
nodes_by_id = {node.id: node for node in node_records}
adjacency: dict[str, list[_EdgeRecord]] = defaultdict(list)
for edge in edge_records:
if _is_context_only_edge(edge):
continue
adjacency[edge.source].append(edge)
adjacency[edge.target].append(edge)
@@ -619,6 +623,14 @@ def _edge_matches_attraction_rule(edge: _EdgeRecord, rule: ZoneAttractionRule) -
return _rule_matches(edge.data, rule.edge_filter, empty_matches=True)
def _is_context_only_edge(edge: _EdgeRecord) -> bool:
return _trueish(edge.data.get("displayOnly", edge.data.get("display_only"))) or edge.edge_type == "declares"
def _trueish(value: Any) -> bool:
return value is True or str(value).lower() == "true"
def _neighbor_for_direction(node_id: str, edge: _EdgeRecord, direction: str) -> str:
if direction in {"out", "both"} and edge.source == node_id:
return edge.target

View File

@@ -422,11 +422,20 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES" in page
assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" in page
assert "zone.diagnostics" in page
assert "isZoneContextOnlyEdge" in page
assert "isZoneConnectivityEdge" in page
assert "currentZoneViewState" in page
assert "normalizeZoneViewState" in page
assert "zoneDefinitionSet" in page
assert "zoneDefinitionSets" in page
assert "fabric-default" in page
assert "zoneContainerState" in page
assert "ensureZoneContainer" in page
assert "packZoneContainers" in page
assert "applyZoneContainerLayout" in page
assert "layoutZoneSubgraph" in page
assert "serializedZoneContainers" in page
assert "normalizedZoneContainers" in page
assert "collapsedZoneSnapshots" in page
assert "zoneDragState" in page
assert "startZoneDrag" in page

View File

@@ -96,6 +96,43 @@ def test_resolver_assigns_seed_nodes_and_boundary_edges() -> None:
}
def test_resolver_ignores_context_only_edges_for_boundaries_and_attraction() -> None:
resolution = resolve_zones(
nodes=[
_node("repo", kind="Repository"),
_node("svc.prod", deploymentEnvironment="prod"),
_node("svc.context", kind="service"),
],
edges=[
_edge("edge.repo-prod", "repo", "svc.prod", "declares", displayOnly=True),
_edge("edge.prod-context", "svc.prod", "svc.context", "declares", displayOnly=True),
],
zone_definitions=[
{
"id": "prod",
"label": "prod",
"membership": {"field": "deploymentEnvironment", "op": "equals", "value": "prod"},
"attraction": {
"rules": [
{
"edge_type": "*",
"direction": "both",
"depth": 1,
}
]
},
},
],
)
assert resolution.zone_by_id("prod").boundary_edge_ids == ()
assert resolution.zone_by_id("prod").internal_edge_ids == ()
assert "svc.context" not in resolution.node_assignments
assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" not in {
diagnostic.code for diagnostic in resolution.diagnostics
}
def test_resolver_attracts_nodes_by_edge_type_direction_and_depth() -> None:
resolution = resolve_zones(
nodes=[

View File

@@ -0,0 +1,129 @@
---
id: RAIL-FAB-WP-0024
type: workplan
title: "Stabilize zone containers and layout zone subgraphs"
domain: railiance
repo: railiance-fabric
status: finished
owner: codex
topic_slug: railiance-fabric
created: "2026-05-25"
updated: "2026-05-25"
---
# Stabilize Zone Containers And Layout Zone Subgraphs
## Context
RAIL-FAB-WP-0022 made zones first-class visualization entities and
RAIL-FAB-WP-0023 added plain labels plus view-only zone dragging. The current
prototype still derives a zone rectangle directly from the current positions of
its member nodes. When the operator changes the layout algorithm or reruns
layout, the zone surface moves with the global graph layout instead of staying
where the operator placed it.
The current resolver also treats every edge attached to a zoned node as zone
context. That includes display-only repository declaration edges. Those edges
help explain where graph declarations came from, but they should not be read as
direct deployment or operational connections across a zone boundary.
This workplan moves the graph explorer toward the intended model: a zone is a
stable drawing surface, and the assigned subgraph is laid out inside that
surface as a separate view concern.
## Task 1: Separate Context Edges From Zone Boundary Connectivity
```task
id: RAIL-FAB-WP-0024-T01
status: done
priority: high
```
Exclude display-only/context edges, especially repository `declares` edges,
from zone boundary diagnostics and collapsed-zone boundary edges.
Expected result: zone detail may still mention contextual evidence when useful,
but display-only declaration edges do not make it look as if every zoned node is
directly connected to an unzoned repository.
Result: Display-only edges and repository `declares` edges are now treated as
context-only in both the shared zone resolver and the browser view. They do not
create boundary diagnostics, attraction paths, or collapsed-zone boundary
edges.
## Task 2: Persist Stable Zone Container Positions
```task
id: RAIL-FAB-WP-0024-T02
status: done
priority: high
```
Introduce a view-level zone container state keyed by stable zone id.
The implementation should:
- remember zone container center and size after layout and drag;
- keep user-moved zone positions stable when the global layout algorithm
changes;
- keep saved/copied view state compatible with the nested `zone` object;
- avoid mutating the Fabric graph payload.
Expected result: switching between layout algorithms keeps zone papers in the
same operator-chosen positions.
Result: The graph explorer now stores stable zone containers in view state.
Containers remember graph-coordinate position and size, survive global layout
reruns, can be saved/copied through the nested `zone` state, and remain separate
from Fabric graph payload data.
## Task 3: Layout Zone Subgraphs Inside Containers
```task
id: RAIL-FAB-WP-0024-T03
status: done
priority: high
```
Add a per-zone layout pass that positions assigned visible nodes inside their
zone container after the global layout places the surrounding graph.
The first implementation may use a deterministic compact layout rather than a
full nested Cytoscape layout, as long as it:
- only moves visible nodes assigned to the zone;
- keeps each node inside the zone's drawing surface;
- treats unzoned nodes as part of the base canvas;
- keeps edge routing intact through Cytoscape's normal renderer.
Expected result: the visible subgraph inside each zone is arranged by the zone
view model, not merely enclosed after the global layout.
Result: After global layout, each visible zone projects its assigned nodes into
a local compact layout inside its stable container. New, not-yet-moved zones are
packed into readable rows so the default deployment-environment papers start as
separate drawing surfaces.
## Task 4: Verify And Document Zone Layout Semantics
```task
id: RAIL-FAB-WP-0024-T04
status: done
priority: medium
```
Update the graph explorer contract and run focused validation.
Verification should cover:
- static UI assertions for context-edge filtering and zone container helpers;
- JavaScript syntax validation;
- focused graph explorer and zone resolver tests;
- a visual smoke check of the graph explorer.
Expected result: documentation and tests describe the new semantics, and the
running UI shows stable zone containers with locally arranged member nodes.
Result: Updated the graph explorer contract and zone entity documentation.
Focused tests, generated JavaScript syntax validation, full tests, CLI
validation, and a headless Edge visual smoke check all passed.