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
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

View File

@@ -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);

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_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

View File

@@ -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