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