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

@@ -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 = "";