generated from coulomb/repo-seed
feat: prototype graph zone collapse
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
</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 = "";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user