From 296ac051a7af36f3e7814dc7a8087e24db98fab8 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 25 May 2026 00:27:10 +0200 Subject: [PATCH] feat: persist graph explorer zone state --- docs/graph-explorer-contract.md | 7 ++ railiance_fabric/graph_explorer_ui.py | 97 +++++++++++++++---- tests/test_graph_explorer.py | 5 + ...P-0022-zone-entity-visualization-engine.md | 9 +- 4 files changed, 100 insertions(+), 18 deletions(-) diff --git a/docs/graph-explorer-contract.md b/docs/graph-explorer-contract.md index d6057a6..90f860e 100644 --- a/docs/graph-explorer-contract.md +++ b/docs/graph-explorer-contract.md @@ -188,6 +188,13 @@ multiple zone definitions, and edges crossing zone boundaries. Attraction diagnostics such as multiple attraction candidates or depth-limit stops belong to the same resolver diagnostic channel when attraction rules are enabled. +Saved graph profiles should persist zone view state as an explicit nested +`zone` object. The initial fields are `visible`, `grouping`, `definitionSet`, +and `presentation`. URL parameters may continue to expose compatibility aliases +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. + ## 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 af651f4..7e4dc85 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -606,6 +606,7 @@ def graph_explorer_page() -> str: let activeMode = "full"; let activeLabelMode = "auto"; let activeZoneGrouping = "deploymentEnvironment"; + let activeZoneDefinitionSet = "fabric-default"; let profilePersistence = "none"; let profiles = []; let currentProfileId = ""; @@ -1045,6 +1046,14 @@ def graph_explorer_page() -> str: ], }; + const zoneDefinitionSets = { + "fabric-default": { + id: "fabric-default", + label: "Fabric defaults", + groupings: ["deploymentEnvironment", "accessZone"], + }, + }; + const zoneElementData = (element) => { if (!element) return {}; if (typeof element.data === "function") return element.data(); @@ -1120,6 +1129,7 @@ def graph_explorer_page() -> str: }; const zoneDefinitionsForData = (data, grouping = activeZoneGrouping) => { + if (!zoneDefinitionSets[activeZoneDefinitionSet]) activeZoneDefinitionSet = "fabric-default"; if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment; if (grouping === "accessZone") { const definition = accessZoneDefinition(zoneFieldValue(data, "accessZone")); @@ -1129,6 +1139,7 @@ def graph_explorer_page() -> str: }; const zoneDefinitionsForElements = (elements, grouping = activeZoneGrouping) => { + if (!zoneDefinitionSets[activeZoneDefinitionSet]) activeZoneDefinitionSet = "fabric-default"; if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment; if (grouping === "accessZone") { const values = new Set(); @@ -1557,20 +1568,42 @@ def graph_explorer_page() -> str: }); }; - const currentViewState = () => ({ - search: searchInput.value, - mode: modeSelect.value || "full", - layout: layoutSelect.value || "cose", - labelMode: labelSelect.value || "auto", - nodeTypes: Array.from(selectedNodeTypes()), - edgeTypes: Array.from(selectedEdgeTypes()), - review: reviewFilter.value, - unresolved: unresolvedFilter.value, - zoneBoundaries: zoneBoundaryToggle ? zoneBoundaryToggle.checked : true, - zoneGrouping: zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment", - rules: filterRules.map((rule) => ({...rule})), - manualOverrides: {...manualOverrides}, - }); + const currentZoneViewState = () => { + const visible = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true; + const grouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment"; + const definitionSet = zoneDefinitionSets[activeZoneDefinitionSet] + ? activeZoneDefinitionSet + : "fabric-default"; + return { + visible, + grouping, + definitionSet, + presentation: { + boundaries: visible, + labels: true, + }, + }; + }; + + const currentViewState = () => { + const zone = currentZoneViewState(); + return { + search: searchInput.value, + mode: modeSelect.value || "full", + layout: layoutSelect.value || "cose", + labelMode: labelSelect.value || "auto", + nodeTypes: Array.from(selectedNodeTypes()), + edgeTypes: Array.from(selectedEdgeTypes()), + review: reviewFilter.value, + unresolved: unresolvedFilter.value, + zone, + zoneBoundaries: zone.visible, + zoneGrouping: zone.grouping, + zoneDefinitionSet: zone.definitionSet, + rules: filterRules.map((rule) => ({...rule})), + manualOverrides: {...manualOverrides}, + }; + }; const encodeStateBlob = (state) => { const bytes = new TextEncoder().encode(JSON.stringify(state)); @@ -1598,6 +1631,7 @@ def graph_explorer_page() -> str: if (params.has("unresolved")) state.unresolved = params.get("unresolved") || ""; if (params.has("zoneBoundaries")) state.zoneBoundaries = params.get("zoneBoundaries") !== "0"; if (params.has("zoneGrouping")) state.zoneGrouping = params.get("zoneGrouping") || ""; + if (params.has("zoneDefinitionSet")) state.zoneDefinitionSet = params.get("zoneDefinitionSet") || ""; if (params.has("profile")) state.profile = params.get("profile") || ""; if (params.has("state")) { try { @@ -1622,6 +1656,7 @@ def graph_explorer_page() -> str: if (state.unresolved) params.set("unresolved", state.unresolved); if (state.zoneBoundaries === false) params.set("zoneBoundaries", "0"); if (state.zoneGrouping && state.zoneGrouping !== "deploymentEnvironment") params.set("zoneGrouping", state.zoneGrouping); + if (state.zoneDefinitionSet && state.zoneDefinitionSet !== "fabric-default") params.set("zoneDefinitionSet", state.zoneDefinitionSet); if (currentProfileId) params.set("profile", currentProfileId); if (includeStateBlob || hasManualOverrides() || filterRules.length > 0) { params.set("state", encodeStateBlob(state)); @@ -1638,6 +1673,31 @@ def graph_explorer_page() -> str: const optionExists = (select, value) => Array.from(select.options).some((option) => option.value === value); + const normalizeZoneViewState = (state) => { + const nested = state.zone && typeof state.zone === "object" ? state.zone : {}; + const presentation = nested.presentation && typeof nested.presentation === "object" + ? nested.presentation + : {}; + const grouping = nested.grouping || state.zoneGrouping || "deploymentEnvironment"; + const definitionSet = zoneDefinitionSets[nested.definitionSet || state.zoneDefinitionSet] + ? nested.definitionSet || state.zoneDefinitionSet + : "fabric-default"; + const visible = "visible" in nested + ? nested.visible !== false + : "zoneBoundaries" in state + ? state.zoneBoundaries !== false + : true; + return { + visible, + grouping: zoneGroupSelect && optionExists(zoneGroupSelect, grouping) ? grouping : "deploymentEnvironment", + definitionSet, + presentation: { + boundaries: "boundaries" in presentation ? presentation.boundaries !== false : visible, + labels: "labels" in presentation ? presentation.labels !== false : true, + }, + }; + }; + const applyViewState = (state, options = {}) => { if ("search" in state) searchInput.value = state.search || ""; if ("mode" in state) modeSelect.value = optionExists(modeSelect, state.mode) ? state.mode : "full"; @@ -1648,9 +1708,11 @@ def graph_explorer_page() -> str: if ("edgeTypes" in state) setCheckedValues(edgeTypeFilter, (state.edgeTypes || []).filter((value) => allEdgeTypes.includes(value))); if ("review" in state) reviewFilter.value = optionExists(reviewFilter, state.review) ? state.review : ""; if ("unresolved" in state) unresolvedFilter.value = optionExists(unresolvedFilter, state.unresolved) ? state.unresolved : ""; - if ("zoneBoundaries" in state && zoneBoundaryToggle) zoneBoundaryToggle.checked = state.zoneBoundaries !== false; - if ("zoneGrouping" in state && zoneGroupSelect) { - zoneGroupSelect.value = optionExists(zoneGroupSelect, state.zoneGrouping) ? state.zoneGrouping : "deploymentEnvironment"; + if ("zone" in state || "zoneBoundaries" in state || "zoneGrouping" in state || "zoneDefinitionSet" in state) { + const zone = normalizeZoneViewState(state); + activeZoneDefinitionSet = zone.definitionSet; + if (zoneBoundaryToggle) zoneBoundaryToggle.checked = zone.visible; + if (zoneGroupSelect) zoneGroupSelect.value = zone.grouping; } if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") { manualOverrides = {...state.manualOverrides}; @@ -2492,6 +2554,7 @@ def graph_explorer_page() -> str: zoneBoundaryToggle.checked = true; zoneGroupSelect.value = "deploymentEnvironment"; activeZoneGrouping = "deploymentEnvironment"; + activeZoneDefinitionSet = "fabric-default"; selectedZoneId = ""; setCheckedValues(nodeTypeFilter); setCheckedValues(edgeTypeFilter); diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index fcd521d..878924e 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -422,6 +422,11 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None: assert "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES" in page assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" in page assert "zone.diagnostics" in page + assert "currentZoneViewState" in page + assert "normalizeZoneViewState" in page + assert "zoneDefinitionSet" in page + assert "zoneDefinitionSets" in page + assert "fabric-default" 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 c131404..0c5629a 100644 --- a/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md +++ b/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md @@ -156,7 +156,7 @@ resolver path. ```task id: RAIL-FAB-WP-0022-T05 -status: todo +status: done priority: medium state_hub_task_id: "765caa50-f372-4ab4-adb4-87660e684c54" ``` @@ -173,6 +173,13 @@ At minimum, preserve: Expected result: saved views restore the same zone mode and default definition set after reload. +Result: Added explicit nested zone state to graph explorer profiles with +`visible`, `grouping`, `definitionSet`, and `presentation` fields while keeping +legacy URL aliases for `zoneBoundaries`, `zoneGrouping`, and +`zoneDefinitionSet`. Saved and copied views now preserve the active zone +definition set and presentation state, and profile restore normalizes unknown +future definition sets back to the Fabric default. + ## Task 6: Prototype Collapse-To-Zone-Node ```task