feat: persist graph explorer zone state

This commit is contained in:
2026-05-25 00:27:10 +02:00
parent 1ad432270b
commit 296ac051a7
4 changed files with 100 additions and 18 deletions

View File

@@ -188,6 +188,13 @@ multiple zone definitions, and edges crossing zone boundaries. Attraction
diagnostics such as multiple attraction candidates or depth-limit stops belong diagnostics such as multiple attraction candidates or depth-limit stops belong
to the same resolver diagnostic channel when attraction rules are enabled. 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 Compatibility
Repo-scoping can adapt without a rewrite because its current graph payload Repo-scoping can adapt without a rewrite because its current graph payload

View File

@@ -606,6 +606,7 @@ def graph_explorer_page() -> str:
let activeMode = "full"; let activeMode = "full";
let activeLabelMode = "auto"; let activeLabelMode = "auto";
let activeZoneGrouping = "deploymentEnvironment"; let activeZoneGrouping = "deploymentEnvironment";
let activeZoneDefinitionSet = "fabric-default";
let profilePersistence = "none"; let profilePersistence = "none";
let profiles = []; let profiles = [];
let currentProfileId = ""; 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) => { const zoneElementData = (element) => {
if (!element) return {}; if (!element) return {};
if (typeof element.data === "function") return element.data(); if (typeof element.data === "function") return element.data();
@@ -1120,6 +1129,7 @@ def graph_explorer_page() -> str:
}; };
const zoneDefinitionsForData = (data, grouping = activeZoneGrouping) => { const zoneDefinitionsForData = (data, grouping = activeZoneGrouping) => {
if (!zoneDefinitionSets[activeZoneDefinitionSet]) activeZoneDefinitionSet = "fabric-default";
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment; if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
if (grouping === "accessZone") { if (grouping === "accessZone") {
const definition = accessZoneDefinition(zoneFieldValue(data, "accessZone")); const definition = accessZoneDefinition(zoneFieldValue(data, "accessZone"));
@@ -1129,6 +1139,7 @@ def graph_explorer_page() -> str:
}; };
const zoneDefinitionsForElements = (elements, grouping = activeZoneGrouping) => { const zoneDefinitionsForElements = (elements, grouping = activeZoneGrouping) => {
if (!zoneDefinitionSets[activeZoneDefinitionSet]) activeZoneDefinitionSet = "fabric-default";
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment; if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
if (grouping === "accessZone") { if (grouping === "accessZone") {
const values = new Set(); const values = new Set();
@@ -1557,20 +1568,42 @@ def graph_explorer_page() -> str:
}); });
}; };
const currentViewState = () => ({ const currentZoneViewState = () => {
search: searchInput.value, const visible = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true;
mode: modeSelect.value || "full", const grouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment";
layout: layoutSelect.value || "cose", const definitionSet = zoneDefinitionSets[activeZoneDefinitionSet]
labelMode: labelSelect.value || "auto", ? activeZoneDefinitionSet
nodeTypes: Array.from(selectedNodeTypes()), : "fabric-default";
edgeTypes: Array.from(selectedEdgeTypes()), return {
review: reviewFilter.value, visible,
unresolved: unresolvedFilter.value, grouping,
zoneBoundaries: zoneBoundaryToggle ? zoneBoundaryToggle.checked : true, definitionSet,
zoneGrouping: zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment", presentation: {
rules: filterRules.map((rule) => ({...rule})), boundaries: visible,
manualOverrides: {...manualOverrides}, 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 encodeStateBlob = (state) => {
const bytes = new TextEncoder().encode(JSON.stringify(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("unresolved")) state.unresolved = params.get("unresolved") || "";
if (params.has("zoneBoundaries")) state.zoneBoundaries = params.get("zoneBoundaries") !== "0"; if (params.has("zoneBoundaries")) state.zoneBoundaries = params.get("zoneBoundaries") !== "0";
if (params.has("zoneGrouping")) state.zoneGrouping = params.get("zoneGrouping") || ""; 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("profile")) state.profile = params.get("profile") || "";
if (params.has("state")) { if (params.has("state")) {
try { try {
@@ -1622,6 +1656,7 @@ def graph_explorer_page() -> str:
if (state.unresolved) params.set("unresolved", state.unresolved); if (state.unresolved) params.set("unresolved", state.unresolved);
if (state.zoneBoundaries === false) params.set("zoneBoundaries", "0"); if (state.zoneBoundaries === false) params.set("zoneBoundaries", "0");
if (state.zoneGrouping && state.zoneGrouping !== "deploymentEnvironment") params.set("zoneGrouping", state.zoneGrouping); 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 (currentProfileId) params.set("profile", currentProfileId);
if (includeStateBlob || hasManualOverrides() || filterRules.length > 0) { if (includeStateBlob || hasManualOverrides() || filterRules.length > 0) {
params.set("state", encodeStateBlob(state)); params.set("state", encodeStateBlob(state));
@@ -1638,6 +1673,31 @@ def graph_explorer_page() -> str:
const optionExists = (select, value) => const optionExists = (select, value) =>
Array.from(select.options).some((option) => option.value === 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 = {}) => { const applyViewState = (state, options = {}) => {
if ("search" in state) searchInput.value = state.search || ""; if ("search" in state) searchInput.value = state.search || "";
if ("mode" in state) modeSelect.value = optionExists(modeSelect, state.mode) ? state.mode : "full"; 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 ("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 ("review" in state) reviewFilter.value = optionExists(reviewFilter, state.review) ? state.review : "";
if ("unresolved" in state) unresolvedFilter.value = optionExists(unresolvedFilter, state.unresolved) ? state.unresolved : ""; if ("unresolved" in state) unresolvedFilter.value = optionExists(unresolvedFilter, state.unresolved) ? state.unresolved : "";
if ("zoneBoundaries" in state && zoneBoundaryToggle) zoneBoundaryToggle.checked = state.zoneBoundaries !== false; if ("zone" in state || "zoneBoundaries" in state || "zoneGrouping" in state || "zoneDefinitionSet" in state) {
if ("zoneGrouping" in state && zoneGroupSelect) { const zone = normalizeZoneViewState(state);
zoneGroupSelect.value = optionExists(zoneGroupSelect, state.zoneGrouping) ? state.zoneGrouping : "deploymentEnvironment"; 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") { if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") {
manualOverrides = {...state.manualOverrides}; manualOverrides = {...state.manualOverrides};
@@ -2492,6 +2554,7 @@ def graph_explorer_page() -> str:
zoneBoundaryToggle.checked = true; zoneBoundaryToggle.checked = true;
zoneGroupSelect.value = "deploymentEnvironment"; zoneGroupSelect.value = "deploymentEnvironment";
activeZoneGrouping = "deploymentEnvironment"; activeZoneGrouping = "deploymentEnvironment";
activeZoneDefinitionSet = "fabric-default";
selectedZoneId = ""; selectedZoneId = "";
setCheckedValues(nodeTypeFilter); setCheckedValues(nodeTypeFilter);
setCheckedValues(edgeTypeFilter); setCheckedValues(edgeTypeFilter);

View File

@@ -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_NODE_SEEDED_BY_MULTIPLE_ZONES" in page
assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" in page assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" in page
assert "zone.diagnostics" 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 'normalize: "deploymentEnvironment"' in page
assert "zoneBoundsForNodes" in page assert "zoneBoundsForNodes" in page
assert "renderZoneOverlay" in page assert "renderZoneOverlay" in page

View File

@@ -156,7 +156,7 @@ resolver path.
```task ```task
id: RAIL-FAB-WP-0022-T05 id: RAIL-FAB-WP-0022-T05
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "765caa50-f372-4ab4-adb4-87660e684c54" 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 Expected result: saved views restore the same zone mode and default definition
set after reload. 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 6: Prototype Collapse-To-Zone-Node
```task ```task