diff --git a/docs/graph-explorer-contract.md b/docs/graph-explorer-contract.md
index 90f860e..90d9af1 100644
--- a/docs/graph-explorer-contract.md
+++ b/docs/graph-explorer-contract.md
@@ -195,6 +195,13 @@ 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.
+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
+boundary edges from that zone node to visible external neighbors. Internal edges
+are summarized on the zone node rather than rendered. Expanding the zone removes
+the synthetic elements and restores the original graph elements without
+changing the underlying payload.
+
## Repo-Scoping Compatibility
Repo-scoping can adapt without a rewrite because its current graph payload
diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py
index 7e4dc85..cf6e3d9 100644
--- a/railiance_fabric/graph_explorer_ui.py
+++ b/railiance_fabric/graph_explorer_ui.py
@@ -616,6 +616,8 @@ def graph_explorer_page() -> str:
let selectedZoneId = "";
let zoneOverlayFrame = 0;
let zoneSummaries = new Map();
+ let collapsedZoneSnapshots = new Map();
+ const zoneCollapsePrefix = "zone-collapse:";
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&").replaceAll("<", "<")
@@ -1346,6 +1348,7 @@ def graph_explorer_page() -> str:
selectedZoneId = zone.id;
const visibleElements = zone.elements || [];
const visibleNodes = zone.nodes || [];
+ const isCollapsed = collapsedZoneSnapshots.has(zone.id);
detailTitle.textContent = zone.label;
detailSummary.textContent = `${visibleNodes.length} visible node${visibleNodes.length === 1 ? "" : "s"} in ${ruleAttributeLabels[zone.field] || humanize(zone.field)}`;
detailPills.innerHTML = [zone.field, zone.value, activeZoneGrouping]
@@ -1397,6 +1400,7 @@ def graph_explorer_page() -> str:
`).join("");
orientationActions.innerHTML = `
+
@@ -1407,6 +1411,155 @@ def graph_explorer_page() -> str:
updateSelectionAnchor();
};
+ const zoneCollapseNodeId = (zoneId) => `${zoneCollapsePrefix}node:${zoneId}`;
+
+ const isZoneCollapseElement = (element) => String(element.id()).startsWith(zoneCollapsePrefix);
+
+ const removeZoneCollapseElements = () => {
+ if (!cy) return;
+ if (selected && isZoneCollapseElement(selected)) selected = null;
+ cy.elements().filter(isZoneCollapseElement).remove();
+ };
+
+ const zoneCollapsePosition = (nodes) => {
+ const positions = nodes.map((node) => node.position());
+ if (!positions.length) return {x: 0, y: 0};
+ return {
+ x: positions.reduce((total, position) => total + position.x, 0) / positions.length,
+ y: positions.reduce((total, position) => total + position.y, 0) / positions.length,
+ };
+ };
+
+ const zoneCollapseSnapshot = (zone) => {
+ const nodeIds = new Set((zone.nodes || []).map((node) => node.id()));
+ const boundaryEdges = [];
+ let internalEdgeCount = 0;
+ (zone.elements || [])
+ .filter((element) => element.isEdge && element.isEdge())
+ .forEach((edge) => {
+ const source = edge.data("source");
+ const target = edge.data("target");
+ const sourceInside = nodeIds.has(source);
+ const targetInside = nodeIds.has(target);
+ if (sourceInside && targetInside) {
+ internalEdgeCount += 1;
+ } else if (sourceInside || targetInside) {
+ boundaryEdges.push({
+ id: edge.id(),
+ source,
+ target,
+ edgeType: edge.data("edgeType") || "zone_boundary",
+ sourceInside,
+ targetInside,
+ });
+ }
+ });
+ return {
+ id: zone.id,
+ label: zone.label,
+ field: zone.field,
+ value: zone.value,
+ nodeIds: Array.from(nodeIds),
+ boundaryEdges,
+ internalEdgeCount,
+ position: zoneCollapsePosition(zone.nodes || []),
+ };
+ };
+
+ const addZoneCollapseElements = () => {
+ if (!cy || !collapsedZoneSnapshots.size) return;
+ collapsedZoneSnapshots.forEach((snapshot) => {
+ const zoneNodeId = zoneCollapseNodeId(snapshot.id);
+ if (!cy.getElementById(zoneNodeId).length) {
+ cy.add({
+ group: "nodes",
+ data: {
+ id: zoneNodeId,
+ stableKey: zoneNodeId,
+ kind: "Zone",
+ layer: "zone",
+ label: snapshot.label,
+ name: `${snapshot.label} zone`,
+ description: `Collapsed zone with ${snapshot.nodeIds.length} hidden node${snapshot.nodeIds.length === 1 ? "" : "s"}.`,
+ displayState: "show",
+ visualSize: 72,
+ zoneCollapse: true,
+ collapsedZoneId: snapshot.id,
+ collapsedZoneLabel: snapshot.label,
+ containedNodeCount: snapshot.nodeIds.length,
+ containedInternalEdgeCount: snapshot.internalEdgeCount,
+ boundaryEdgeCount: snapshot.boundaryEdges.length,
+ },
+ classes: "zone-collapse-node",
+ position: snapshot.position,
+ });
+ }
+ snapshot.boundaryEdges.forEach((edge, index) => {
+ const source = edge.sourceInside ? zoneNodeId : edge.source;
+ const target = edge.targetInside ? zoneNodeId : edge.target;
+ if (source === target || !cy.getElementById(source).length || !cy.getElementById(target).length) return;
+ const edgeId = `${zoneCollapsePrefix}edge:${snapshot.id}:${index}:${edge.id}`;
+ if (cy.getElementById(edgeId).length) return;
+ cy.add({
+ group: "edges",
+ data: {
+ id: edgeId,
+ stableKey: edgeId,
+ kind: "edge",
+ layer: "zone",
+ label: edge.edgeType,
+ source,
+ target,
+ edgeType: edge.edgeType,
+ strength: "medium",
+ edgeWidth: 3,
+ displayState: "show",
+ zoneCollapse: true,
+ collapsedZoneId: snapshot.id,
+ originalEdgeId: edge.id,
+ },
+ classes: "zone-collapse-edge",
+ });
+ });
+ });
+ };
+
+ const applyZoneCollapseVisibility = (hiddenNodes) => {
+ if (!cy || !collapsedZoneSnapshots.size) return;
+ collapsedZoneSnapshots.forEach((snapshot) => {
+ snapshot.nodeIds.forEach((nodeId) => {
+ const node = cy.getElementById(nodeId);
+ if (!node.length) return;
+ node.data("displayState", "hide");
+ node.style("display", "none");
+ hiddenNodes.add(nodeId);
+ });
+ });
+ };
+
+ const collapseZone = (zone) => {
+ if (!cy || !zone || !(zone.nodes || []).length) return;
+ collapsedZoneSnapshots.set(zone.id, zoneCollapseSnapshot(zone));
+ selected = null;
+ selectedZoneId = "";
+ applyFilters({redrawOnRemove: true});
+ runLayout();
+ updateProfileSummary(`Collapsed zone "${zone.label}".`);
+ updateUrlState();
+ };
+
+ const expandZone = (zoneId) => {
+ if (!cy || !zoneId) return;
+ const snapshot = collapsedZoneSnapshots.get(zoneId);
+ collapsedZoneSnapshots.delete(zoneId);
+ selected = null;
+ selectedZoneId = "";
+ applyFilters({redrawOnRemove: true});
+ runLayout();
+ updateProfileSummary(snapshot ? `Expanded zone "${snapshot.label}".` : "");
+ updateUrlState();
+ };
+
const renderZoneOverlay = () => {
if (!zoneOverlay || !cy) return;
const enabled = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true;
@@ -1727,6 +1880,7 @@ def graph_explorer_page() -> str:
activeLabelMode = labelSelect.value || "auto";
activeZoneGrouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment";
selectedZoneId = "";
+ collapsedZoneSnapshots = new Map();
focusSet = null;
syncFilterSummaries();
applyFilters();
@@ -1894,6 +2048,7 @@ def graph_explorer_page() -> str:
const applyFilters = (options = {}) => {
if (!cy) return;
+ removeZoneCollapseElements();
const previousRemoved = options.redrawOnRemove ? ruleRemovalSignature() : "";
syncFilterSummaries();
const hiddenNodes = new Set();
@@ -1913,6 +2068,7 @@ def graph_explorer_page() -> str:
if (state === "remove") removedNodes.add(node.id());
if (state === "hide") hiddenNodes.add(node.id());
});
+ applyZoneCollapseVisibility(hiddenNodes);
cy.edges().forEach((edge) => {
let state = matchesFilters(edge) ? "show" : "hide";
const ruleAction = ruleActionFor(edge);
@@ -1931,6 +2087,8 @@ def graph_explorer_page() -> str:
edge.toggleClass("rule-highlight", state === "highlight");
edge.style("display", state === "hide" || state === "remove" ? "none" : "element");
});
+ addZoneCollapseElements();
+ updateLabelVisibility();
const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none").length;
const visibleEdges = cy.edges().filter((edge) => edge.style("display") !== "none").length;
const removed = cy.elements().filter((element) => element.data("displayState") === "remove").length;
@@ -1977,6 +2135,10 @@ def graph_explorer_page() -> str:
["mapping", data.mappingFit],
["display only", data.displayOnly === true ? "yes" : ""],
["strength", data.strength],
+ ["collapsed zone", data.collapsedZoneLabel],
+ ["contained nodes", data.containedNodeCount],
+ ["internal edges", data.containedInternalEdgeCount],
+ ["boundary edges", data.boundaryEdgeCount],
["deployment environment", data.deploymentEnvironment],
["deployment scenario", data.deploymentScenario],
["routing authority", data.routingAuthority],
@@ -2001,9 +2163,24 @@ def graph_explorer_page() -> str:
const data = element.data();
const contextIds = new Set([element.id()]);
const rows = [];
+ const extraOrientationActions = [];
let title = "Graph context";
let profileName = `Fabric context: ${elementLabel(element)}`;
- if (data.kind === "Repository" && data.lifecycle === "registered-only") {
+ if (data.kind === "Zone" && data.zoneCollapse === true) {
+ title = "Collapsed zone";
+ profileName = `Zone: ${data.collapsedZoneLabel || elementLabel(element)}`;
+ const neighborhood = element.neighborhood();
+ neighborhood.forEach((item) => addContextElement(contextIds, item));
+ rows.push(
+ {label: "zone", value: data.collapsedZoneLabel || data.id, state: "good"},
+ {label: "contained nodes", value: String(data.containedNodeCount || 0)},
+ {label: "internal edges", value: String(data.containedInternalEdgeCount || 0)},
+ {label: "boundary edges", value: String(data.boundaryEdgeCount || 0)}
+ );
+ extraOrientationActions.push(
+ ``
+ );
+ } else if (data.kind === "Repository" && data.lifecycle === "registered-only") {
title = "Onboarding gap";
profileName = `Onboarding gap: ${data.repo || elementLabel(element)}`;
rows.push(
@@ -2160,6 +2337,7 @@ def graph_explorer_page() -> str:
`)
.join("");
orientationActions.innerHTML = `
+ ${extraOrientationActions.join("")}
@@ -2320,6 +2498,20 @@ def graph_explorer_page() -> str:
{selector: "node[layer = 'binding']", style: {"shape": "rhomboid"}},
{selector: "node[unresolved = true]", style: {"border-color": "#b45309", "border-style": "dashed", "border-width": 3}},
{selector: "node[lifecycle = 'registered-only']", style: {"border-color": "#be123c", "border-style": "dashed", "border-width": 3}},
+ {selector: "node[zoneCollapse = true]", style: {
+ "shape": "round-rectangle",
+ "background-color": "#334155",
+ "border-color": "#0f766e",
+ "border-width": 3,
+ "border-style": "double",
+ "color": "#172033",
+ "font-weight": 700,
+ "text-background-color": "#ffffff",
+ "text-background-opacity": .86,
+ "text-background-padding": 3,
+ "width": "data(visualSize)",
+ "height": "data(visualSize)"
+ }},
{selector: "edge", style: {
"curve-style": "bezier",
"line-color": "#98a2b3",
@@ -2329,6 +2521,7 @@ def graph_explorer_page() -> str:
}},
{selector: "edge[strength = 'strong']", style: {"line-color": "#344054", "target-arrow-color": "#344054"}},
{selector: "edge[strength = 'weak']", style: {"line-style": "dotted"}},
+ {selector: "edge[zoneCollapse = true]", style: {"line-style": "dashed", "line-color": "#0f766e", "target-arrow-color": "#0f766e"}},
{selector: "node.rule-highlight", style: {
"border-color": "#2563eb",
"border-width": 3,
@@ -2446,6 +2639,7 @@ def graph_explorer_page() -> str:
zoneGroupSelect.addEventListener("input", () => {
activeZoneGrouping = zoneGroupSelect.value || "deploymentEnvironment";
selectedZoneId = "";
+ collapsedZoneSnapshots = new Map();
currentProfileId = "";
profileSelect.value = "";
renderZoneOverlay();
@@ -2541,6 +2735,15 @@ def graph_explorer_page() -> str:
orientationActions.addEventListener("click", (event) => {
const button = event.target.closest("[data-orientation-action]");
if (!button) return;
+ if (button.dataset.orientationAction === "collapse-zone") {
+ const zone = zoneSummaries.get(button.dataset.zoneId || selectedZoneId);
+ if (zone) collapseZone(zone);
+ return;
+ }
+ if (button.dataset.orientationAction === "expand-zone") {
+ expandZone(button.dataset.zoneId || selected?.data("collapsedZoneId") || selectedZoneId);
+ return;
+ }
applyOrientationContext(button.dataset.orientationAction);
});
document.querySelector("[data-action='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48));
@@ -2556,6 +2759,7 @@ def graph_explorer_page() -> str:
activeZoneGrouping = "deploymentEnvironment";
activeZoneDefinitionSet = "fabric-default";
selectedZoneId = "";
+ collapsedZoneSnapshots = new Map();
setCheckedValues(nodeTypeFilter);
setCheckedValues(edgeTypeFilter);
reviewFilter.value = "";
diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py
index 878924e..5fc7f26 100644
--- a/tests/test_graph_explorer.py
+++ b/tests/test_graph_explorer.py
@@ -427,6 +427,12 @@ 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 "collapsedZoneSnapshots" in page
+ assert "collapseZone" in page
+ assert "expandZone" in page
+ assert "zoneCollapseNodeId" in page
+ assert "Collapse Zone" in page
+ assert "Expand Zone" in page
assert 'normalize: "deploymentEnvironment"' in page
assert "zoneBoundsForNodes" in page
assert "renderZoneOverlay" in page
diff --git a/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md b/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md
index 0c5629a..c94fac8 100644
--- a/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md
+++ b/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md
@@ -184,7 +184,7 @@ future definition sets back to the Fabric default.
```task
id: RAIL-FAB-WP-0022-T06
-status: todo
+status: done
priority: medium
state_hub_task_id: "7f3676cb-3d2e-417c-a385-f95545bcd738"
```
@@ -203,6 +203,13 @@ The prototype should:
Expected result: collapse behavior works for one zone at a time and is covered
by focused tests. Multi-zone hierarchy can remain future work.
+Result: Added a view-only zone collapse prototype to the graph explorer. Zone
+detail panels now offer `Collapse Zone`; collapsed zones hide member nodes,
+render a synthetic zone node with node/internal-edge/boundary-edge summaries,
+draw synthetic boundary edges to visible external neighbors, and expose
+`Expand Zone` from the collapsed zone node. Expanding removes synthetic elements
+and restores the original graph view without changing the underlying payload.
+
## Task 7: Prepare For Per-Zone Layout
```task