diff --git a/docs/graph-explorer-contract.md b/docs/graph-explorer-contract.md index 90f860e..90d9af1 100644 --- a/docs/graph-explorer-contract.md +++ b/docs/graph-explorer-contract.md @@ -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 diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index 7e4dc85..cf6e3d9 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -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: `).join(""); orientationActions.innerHTML = ` + @@ -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( + `` + ); + } 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("")} @@ -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 = ""; diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index 878924e..5fc7f26 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -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 diff --git a/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md b/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md index 0c5629a..c94fac8 100644 --- a/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md +++ b/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md @@ -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