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 profiles should prefer the nested object so future zone definition sets and
presentation preferences can be restored without another state migration. 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 Compatibility
Repo-scoping can adapt without a rewrite because its current graph payload 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 selectedZoneId = "";
let zoneOverlayFrame = 0; let zoneOverlayFrame = 0;
let zoneSummaries = new Map(); let zoneSummaries = new Map();
let collapsedZoneSnapshots = new Map();
const zoneCollapsePrefix = "zone-collapse:";
const escapeHtml = (value) => String(value ?? "") const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&amp;").replaceAll("<", "&lt;") .replaceAll("&", "&amp;").replaceAll("<", "&lt;")
@@ -1346,6 +1348,7 @@ def graph_explorer_page() -> str:
selectedZoneId = zone.id; selectedZoneId = zone.id;
const visibleElements = zone.elements || []; const visibleElements = zone.elements || [];
const visibleNodes = zone.nodes || []; const visibleNodes = zone.nodes || [];
const isCollapsed = collapsedZoneSnapshots.has(zone.id);
detailTitle.textContent = zone.label; detailTitle.textContent = zone.label;
detailSummary.textContent = `${visibleNodes.length} visible node${visibleNodes.length === 1 ? "" : "s"} in ${ruleAttributeLabels[zone.field] || humanize(zone.field)}`; detailSummary.textContent = `${visibleNodes.length} visible node${visibleNodes.length === 1 ? "" : "s"} in ${ruleAttributeLabels[zone.field] || humanize(zone.field)}`;
detailPills.innerHTML = [zone.field, zone.value, activeZoneGrouping] detailPills.innerHTML = [zone.field, zone.value, activeZoneGrouping]
@@ -1397,6 +1400,7 @@ def graph_explorer_page() -> str:
</li> </li>
`).join(""); `).join("");
orientationActions.innerHTML = ` 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="focus">Focus Context</button>
<button type="button" data-orientation-action="highlight">Highlight Context</button> <button type="button" data-orientation-action="highlight">Highlight Context</button>
<button type="button" data-orientation-action="hide-other">Hide Other</button> <button type="button" data-orientation-action="hide-other">Hide Other</button>
@@ -1407,6 +1411,155 @@ def graph_explorer_page() -> str:
updateSelectionAnchor(); 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 = () => { const renderZoneOverlay = () => {
if (!zoneOverlay || !cy) return; if (!zoneOverlay || !cy) return;
const enabled = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true; const enabled = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true;
@@ -1727,6 +1880,7 @@ def graph_explorer_page() -> str:
activeLabelMode = labelSelect.value || "auto"; activeLabelMode = labelSelect.value || "auto";
activeZoneGrouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment"; activeZoneGrouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment";
selectedZoneId = ""; selectedZoneId = "";
collapsedZoneSnapshots = new Map();
focusSet = null; focusSet = null;
syncFilterSummaries(); syncFilterSummaries();
applyFilters(); applyFilters();
@@ -1894,6 +2048,7 @@ def graph_explorer_page() -> str:
const applyFilters = (options = {}) => { const applyFilters = (options = {}) => {
if (!cy) return; if (!cy) return;
removeZoneCollapseElements();
const previousRemoved = options.redrawOnRemove ? ruleRemovalSignature() : ""; const previousRemoved = options.redrawOnRemove ? ruleRemovalSignature() : "";
syncFilterSummaries(); syncFilterSummaries();
const hiddenNodes = new Set(); const hiddenNodes = new Set();
@@ -1913,6 +2068,7 @@ def graph_explorer_page() -> str:
if (state === "remove") removedNodes.add(node.id()); if (state === "remove") removedNodes.add(node.id());
if (state === "hide") hiddenNodes.add(node.id()); if (state === "hide") hiddenNodes.add(node.id());
}); });
applyZoneCollapseVisibility(hiddenNodes);
cy.edges().forEach((edge) => { cy.edges().forEach((edge) => {
let state = matchesFilters(edge) ? "show" : "hide"; let state = matchesFilters(edge) ? "show" : "hide";
const ruleAction = ruleActionFor(edge); const ruleAction = ruleActionFor(edge);
@@ -1931,6 +2087,8 @@ def graph_explorer_page() -> str:
edge.toggleClass("rule-highlight", state === "highlight"); edge.toggleClass("rule-highlight", state === "highlight");
edge.style("display", state === "hide" || state === "remove" ? "none" : "element"); edge.style("display", state === "hide" || state === "remove" ? "none" : "element");
}); });
addZoneCollapseElements();
updateLabelVisibility();
const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none").length; const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none").length;
const visibleEdges = cy.edges().filter((edge) => edge.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; const removed = cy.elements().filter((element) => element.data("displayState") === "remove").length;
@@ -1977,6 +2135,10 @@ def graph_explorer_page() -> str:
["mapping", data.mappingFit], ["mapping", data.mappingFit],
["display only", data.displayOnly === true ? "yes" : ""], ["display only", data.displayOnly === true ? "yes" : ""],
["strength", data.strength], ["strength", data.strength],
["collapsed zone", data.collapsedZoneLabel],
["contained nodes", data.containedNodeCount],
["internal edges", data.containedInternalEdgeCount],
["boundary edges", data.boundaryEdgeCount],
["deployment environment", data.deploymentEnvironment], ["deployment environment", data.deploymentEnvironment],
["deployment scenario", data.deploymentScenario], ["deployment scenario", data.deploymentScenario],
["routing authority", data.routingAuthority], ["routing authority", data.routingAuthority],
@@ -2001,9 +2163,24 @@ def graph_explorer_page() -> str:
const data = element.data(); const data = element.data();
const contextIds = new Set([element.id()]); const contextIds = new Set([element.id()]);
const rows = []; const rows = [];
const extraOrientationActions = [];
let title = "Graph context"; let title = "Graph context";
let profileName = `Fabric context: ${elementLabel(element)}`; 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"; title = "Onboarding gap";
profileName = `Onboarding gap: ${data.repo || elementLabel(element)}`; profileName = `Onboarding gap: ${data.repo || elementLabel(element)}`;
rows.push( rows.push(
@@ -2160,6 +2337,7 @@ def graph_explorer_page() -> str:
`) `)
.join(""); .join("");
orientationActions.innerHTML = ` orientationActions.innerHTML = `
${extraOrientationActions.join("")}
<button type="button" data-orientation-action="focus">Focus Context</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="highlight">Highlight Context</button>
<button type="button" data-orientation-action="hide-other">Hide Other</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[layer = 'binding']", style: {"shape": "rhomboid"}},
{selector: "node[unresolved = true]", style: {"border-color": "#b45309", "border-style": "dashed", "border-width": 3}}, {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[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: { {selector: "edge", style: {
"curve-style": "bezier", "curve-style": "bezier",
"line-color": "#98a2b3", "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 = 'strong']", style: {"line-color": "#344054", "target-arrow-color": "#344054"}},
{selector: "edge[strength = 'weak']", style: {"line-style": "dotted"}}, {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: { {selector: "node.rule-highlight", style: {
"border-color": "#2563eb", "border-color": "#2563eb",
"border-width": 3, "border-width": 3,
@@ -2446,6 +2639,7 @@ def graph_explorer_page() -> str:
zoneGroupSelect.addEventListener("input", () => { zoneGroupSelect.addEventListener("input", () => {
activeZoneGrouping = zoneGroupSelect.value || "deploymentEnvironment"; activeZoneGrouping = zoneGroupSelect.value || "deploymentEnvironment";
selectedZoneId = ""; selectedZoneId = "";
collapsedZoneSnapshots = new Map();
currentProfileId = ""; currentProfileId = "";
profileSelect.value = ""; profileSelect.value = "";
renderZoneOverlay(); renderZoneOverlay();
@@ -2541,6 +2735,15 @@ def graph_explorer_page() -> str:
orientationActions.addEventListener("click", (event) => { orientationActions.addEventListener("click", (event) => {
const button = event.target.closest("[data-orientation-action]"); const button = event.target.closest("[data-orientation-action]");
if (!button) return; 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); applyOrientationContext(button.dataset.orientationAction);
}); });
document.querySelector("[data-action='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48)); 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"; activeZoneGrouping = "deploymentEnvironment";
activeZoneDefinitionSet = "fabric-default"; activeZoneDefinitionSet = "fabric-default";
selectedZoneId = ""; selectedZoneId = "";
collapsedZoneSnapshots = new Map();
setCheckedValues(nodeTypeFilter); setCheckedValues(nodeTypeFilter);
setCheckedValues(edgeTypeFilter); setCheckedValues(edgeTypeFilter);
reviewFilter.value = ""; reviewFilter.value = "";

View File

@@ -427,6 +427,12 @@ 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 "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 'normalize: "deploymentEnvironment"' in page
assert "zoneBoundsForNodes" in page assert "zoneBoundsForNodes" in page
assert "renderZoneOverlay" in page assert "renderZoneOverlay" in page

View File

@@ -184,7 +184,7 @@ future definition sets back to the Fabric default.
```task ```task
id: RAIL-FAB-WP-0022-T06 id: RAIL-FAB-WP-0022-T06
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "7f3676cb-3d2e-417c-a385-f95545bcd738" 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 Expected result: collapse behavior works for one zone at a time and is covered
by focused tests. Multi-zone hierarchy can remain future work. 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 7: Prepare For Per-Zone Layout
```task ```task