feat: prototype graph zone collapse

This commit is contained in:
2026-05-25 00:40:13 +02:00
parent 296ac051a7
commit a7a22a673f
4 changed files with 226 additions and 2 deletions

View File

@@ -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

View File

@@ -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("&", "&amp;").replaceAll("<", "&lt;")
@@ -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:
</li>
`).join("");
orientationActions.innerHTML = `
<button type="button" data-orientation-action="${isCollapsed ? "expand-zone" : "collapse-zone"}" data-zone-id="${escapeHtml(zone.id)}">${isCollapsed ? "Expand Zone" : "Collapse Zone"}</button>
<button type="button" data-orientation-action="focus">Focus Context</button>
<button type="button" data-orientation-action="highlight">Highlight Context</button>
<button type="button" data-orientation-action="hide-other">Hide Other</button>
@@ -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(
`<button type="button" data-orientation-action="expand-zone" data-zone-id="${escapeHtml(data.collapsedZoneId)}">Expand Zone</button>`
);
} 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("")}
<button type="button" data-orientation-action="focus">Focus Context</button>
<button type="button" data-orientation-action="highlight">Highlight Context</button>
<button type="button" data-orientation-action="hide-other">Hide Other</button>
@@ -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 = "";

View File

@@ -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

View File

@@ -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