feat: resolve graph explorer zones from definitions

This commit is contained in:
2026-05-25 00:00:28 +02:00
parent d060b1c896
commit f1cd74dc7b
4 changed files with 252 additions and 53 deletions

View File

@@ -156,7 +156,9 @@ reach it here?" without mutating the underlying Fabric responsibility boundary.
Zone boundary overlays are a visual layer over the graph canvas. They should be Zone boundary overlays are a visual layer over the graph canvas. They should be
computed from visible node positions after layout/filtering rather than modeled computed from visible node positions after layout/filtering rather than modeled
as graph parent nodes. The default boundary grouping is `deploymentEnvironment`: as graph parent nodes. The overlay should be backed by explicit zone definitions
and a resolver so visible nodes are assigned to at most one rendered zone. The
default boundary grouping is `deploymentEnvironment`:
| Overlay label | Matching nodes | | Overlay label | Matching nodes |
|---------------|----------------| |---------------|----------------|

View File

@@ -988,10 +988,230 @@ def graph_explorer_page() -> str:
.split("") .split("")
.reduce((total, character) => (total * 31 + character.charCodeAt(0)) >>> 0, 0); .reduce((total, character) => (total * 31 + character.charCodeAt(0)) >>> 0, 0);
const normalizeDeploymentZoneValue = (value) => {
const raw = String(value || "").trim();
const normalized = raw.toLowerCase();
if (!normalized || normalized === "all") return "";
if (normalized === "dev") return "dev-tegwick";
if (normalized === "test" || normalized === "staging") return "test";
if (normalized === "prod") return "prod";
return raw;
};
const defaultZoneDefinitions = {
deploymentEnvironment: [
{
id: "deploymentEnvironment:dev-tegwick",
label: "dev-tegwick",
field: "deploymentEnvironment",
value: "dev-tegwick",
rank: 0,
membership: {
field: "deploymentEnvironment",
op: "equals",
value: "dev-tegwick",
normalize: "deploymentEnvironment",
},
presentation: {height: 10, color: "#0f766e", fill: "rgba(15, 118, 110, .07)"},
},
{
id: "deploymentEnvironment:test",
label: "test",
field: "deploymentEnvironment",
value: "test",
rank: 1,
membership: {
field: "deploymentEnvironment",
op: "equals",
value: "test",
normalize: "deploymentEnvironment",
},
presentation: {height: 20, color: "#2563eb", fill: "rgba(37, 99, 235, .07)"},
},
{
id: "deploymentEnvironment:prod",
label: "prod",
field: "deploymentEnvironment",
value: "prod",
rank: 2,
membership: {
field: "deploymentEnvironment",
op: "equals",
value: "prod",
normalize: "deploymentEnvironment",
},
presentation: {height: 30, color: "#be123c", fill: "rgba(190, 18, 60, .07)"},
},
],
};
const zoneElementData = (element) => {
if (!element) return {};
if (typeof element.data === "function") return element.data();
return element.data && typeof element.data === "object" ? element.data : element;
};
const zoneFieldValue = (data, field) => {
if (!field) return "";
let current = data || {};
String(field).split(".").forEach((part) => {
current = current && typeof current === "object" ? current[part] : undefined;
});
return current;
};
const zoneRuleValue = (data, rule) => {
const value = zoneFieldValue(data, rule.field);
if (rule.normalize === "deploymentEnvironment") return normalizeDeploymentZoneValue(value);
return hasValue(value) ? String(value).trim() : "";
};
const zoneRuleMatches = (data, rule, emptyMatches = false) => {
if (!rule || !Object.keys(rule).length) return emptyMatches;
if (Array.isArray(rule.all)) {
return rule.all.every((child) => zoneRuleMatches(data, child));
}
if (Array.isArray(rule.any)) {
return rule.any.some((child) => zoneRuleMatches(data, child));
}
if (Array.isArray(rule.rules)) {
return rule.rules.every((child) => zoneRuleMatches(data, child));
}
const actual = zoneRuleValue(data, rule);
if (rule.op === "exists") return hasValue(actual);
if (rule.op === "in") {
const expected = Array.isArray(rule.value) ? rule.value : [rule.value];
return expected.map((value) => String(value)).includes(String(actual));
}
return String(actual) === String(rule.value ?? "");
};
const zoneDescriptorFromDefinition = (definition) => ({
field: definition.field,
id: definition.id,
label: definition.label || definition.id,
value: definition.value || definition.label || definition.id,
rank: definition.rank,
presentation: definition.presentation || {},
membership: definition.membership || {},
diagnostics: [],
});
const accessZoneDefinition = (value) => {
const label = String(value || "").trim();
if (!label) return null;
return {
id: `accessZone:${label}`,
label,
field: "accessZone",
value: label,
rank: 10 + hashString(label) % 20,
membership: {field: "accessZone", op: "equals", value: label},
presentation: {},
};
};
const zoneDefinitionsForData = (data, grouping = activeZoneGrouping) => {
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
if (grouping === "accessZone") {
const definition = accessZoneDefinition(zoneFieldValue(data, "accessZone"));
return definition ? [definition] : [];
}
return [];
};
const zoneDefinitionsForElements = (elements, grouping = activeZoneGrouping) => {
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
if (grouping === "accessZone") {
const values = new Set();
elements.forEach((element) => {
const value = String(zoneFieldValue(zoneElementData(element), "accessZone") || "").trim();
if (value) values.add(value);
});
return Array.from(values)
.sort((left, right) => left.localeCompare(right))
.map(accessZoneDefinition)
.filter(Boolean);
}
return [];
};
const chooseZoneCandidate = (candidates) => candidates
.slice()
.sort((left, right) => {
const leftHeight = Number(left.definition.presentation?.height) || 0;
const rightHeight = Number(right.definition.presentation?.height) || 0;
return rightHeight - leftHeight || left.index - right.index || left.definition.id.localeCompare(right.definition.id);
})[0];
const resolveZoneInstances = (elements, definitions) => {
const zones = new Map();
definitions.forEach((definition, index) => {
zones.set(definition.id, {
...zoneDescriptorFromDefinition(definition),
definition,
definitionIndex: index,
nodes: [],
elements: [],
nodeIds: new Set(),
elementIds: new Set(),
diagnostics: [],
});
});
const assignments = new Map();
const addElementToZone = (zoneId, element) => {
const zone = zones.get(zoneId);
if (!zone) return;
const elementId = element.id ? element.id() : zoneElementData(element).id;
if (elementId && zone.elementIds.has(elementId)) return;
if (elementId) zone.elementIds.add(elementId);
zone.elements.push(element);
};
elements.filter((element) => element.isNode && element.isNode()).forEach((node) => {
const data = zoneElementData(node);
const candidates = definitions
.map((definition, index) => ({definition, index}))
.filter((candidate) => zoneRuleMatches(data, candidate.definition.membership));
if (!candidates.length) return;
if (candidates.length > 1) {
const diagnostic = {
severity: "warning",
code: "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES",
message: `${elementLabel(node)} matched multiple zone definitions.`,
};
candidates.forEach((candidate) => zones.get(candidate.definition.id)?.diagnostics.push(diagnostic));
}
const chosen = chooseZoneCandidate(candidates).definition;
const zone = zones.get(chosen.id);
if (!zone) return;
assignments.set(node.id(), chosen.id);
zone.nodeIds.add(node.id());
zone.nodes.push(node);
addElementToZone(chosen.id, node);
});
elements.filter((element) => element.isEdge && element.isEdge()).forEach((edge) => {
const data = zoneElementData(edge);
const zoneIds = new Set(
definitions
.filter((definition) => zoneRuleMatches(data, definition.membership))
.map((definition) => definition.id)
);
const sourceZoneId = assignments.get(edge.data("source"));
const targetZoneId = assignments.get(edge.data("target"));
if (sourceZoneId) zoneIds.add(sourceZoneId);
if (targetZoneId) zoneIds.add(targetZoneId);
zoneIds.forEach((zoneId) => addElementToZone(zoneId, edge));
});
return zones;
};
const zoneStyle = (zone) => const zoneStyle = (zone) =>
zonePalette[zone.id] || fallbackZonePalette[hashString(zone.id) % fallbackZonePalette.length]; zone.presentation?.color
? {color: zone.presentation.color, fill: zone.presentation.fill || zonePalette[zone.id]?.fill || "rgba(37, 99, 235, .07)"}
: zonePalette[zone.id] || fallbackZonePalette[hashString(zone.id) % fallbackZonePalette.length];
const zoneRank = (zone) => { const zoneRank = (zone) => {
if (Number.isFinite(zone.rank)) return zone.rank;
if (zone.id === "deploymentEnvironment:dev-tegwick") return 0; if (zone.id === "deploymentEnvironment:dev-tegwick") return 0;
if (zone.id === "deploymentEnvironment:test") return 1; if (zone.id === "deploymentEnvironment:test") return 1;
if (zone.id === "deploymentEnvironment:prod") return 2; if (zone.id === "deploymentEnvironment:prod") return 2;
@@ -1011,36 +1231,9 @@ def graph_explorer_page() -> str:
}; };
const zoneForData = (data, grouping = activeZoneGrouping) => { const zoneForData = (data, grouping = activeZoneGrouping) => {
if (grouping === "deploymentEnvironment") { const definition = zoneDefinitionsForData(data, grouping)
const value = String(data.deploymentEnvironment || "").trim(); .find((candidate) => zoneRuleMatches(data, candidate.membership));
if (!value) return null; return definition ? zoneDescriptorFromDefinition(definition) : null;
const normalized = value.toLowerCase();
if (normalized === "all") return null;
const label = normalized === "dev"
? "dev-tegwick"
: normalized === "test" || normalized === "staging"
? "test"
: normalized === "prod"
? normalized
: value;
return {
field: "deploymentEnvironment",
id: `deploymentEnvironment:${label}`,
label,
value,
};
}
if (grouping === "accessZone") {
const value = String(data.accessZone || "").trim();
if (!value) return null;
return {
field: "accessZone",
id: `accessZone:${value}`,
label: value,
value,
};
}
return null;
}; };
const renderedNodeBox = (node) => { const renderedNodeBox = (node) => {
@@ -1091,30 +1284,22 @@ def graph_explorer_page() -> str:
}; };
const collectZoneSummaries = () => { const collectZoneSummaries = () => {
const groups = new Map(); if (!cy) return new Map();
if (!cy) return groups; const visibleElements = cy.elements()
cy.elements().filter((element) => element.style("display") !== "none").forEach((element) => { .filter((element) => element.style("display") !== "none")
const zone = zoneForData(element.data()); .toArray();
if (!zone) return; return resolveZoneInstances(
if (!groups.has(zone.id)) { visibleElements,
groups.set(zone.id, {...zone, nodes: [], elements: []}); zoneDefinitionsForElements(visibleElements, activeZoneGrouping)
} );
const group = groups.get(zone.id);
group.elements.push(element);
if (element.isNode()) group.nodes.push(element);
});
return groups;
}; };
const zoneCountsForField = (elements, field) => { const zoneCountsForField = (elements, field) => {
if (field !== "deploymentEnvironment" && field !== "accessZone") return groupCounts(elements, field); if (field !== "deploymentEnvironment" && field !== "accessZone") return groupCounts(elements, field);
const groups = new Map(); const zones = resolveZoneInstances(elements, zoneDefinitionsForElements(elements, field));
elements.forEach((element) => { return Array.from(zones.values())
const zone = zoneForData(element.data(), field); .filter((zone) => zone.elements.length > 0)
if (!zone) return; .map((zone) => [zone.label, zone.elements.length])
groups.set(zone.label, (groups.get(zone.label) || 0) + 1);
});
return Array.from(groups.entries())
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])); .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
}; };

View File

@@ -415,6 +415,9 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert "ruleRemovalSignature" in page assert "ruleRemovalSignature" in page
assert "zoneModeFields" in page assert "zoneModeFields" in page
assert "zoneForData" in page assert "zoneForData" in page
assert "defaultZoneDefinitions" in page
assert "resolveZoneInstances" in page
assert 'normalize: "deploymentEnvironment"' in page
assert "zoneBoundsForNodes" in page assert "zoneBoundsForNodes" in page
assert "renderZoneOverlay" in page assert "renderZoneOverlay" in page
assert "renderZoneDetails" in page assert "renderZoneDetails" in page

View File

@@ -95,7 +95,7 @@ summaries, and conflict diagnostics. Covered the core behavior in
```task ```task
id: RAIL-FAB-WP-0022-T03 id: RAIL-FAB-WP-0022-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "c89d8d53-72a4-4590-a0e5-67b012c3550c" state_hub_task_id: "c89d8d53-72a4-4590-a0e5-67b012c3550c"
``` ```
@@ -114,6 +114,15 @@ The current operator-facing behavior should remain available:
Expected result: existing graph explorer tests continue to pass, with new tests Expected result: existing graph explorer tests continue to pass, with new tests
showing that the UI obtains zone rectangles from resolved zone instances. showing that the UI obtains zone rectangles from resolved zone instances.
Result: Refactored the graph explorer overlay to use explicit default zone
definitions and a client-side `resolveZoneInstances()` path. Deployment
environment zones now resolve through declarative membership rules with
`deploymentEnvironment` normalization for `dev` -> `dev-tegwick`,
`test`/`staging` -> `test`, `prod` -> `prod`, and ignored `all` values. Access
zone overlays are generated as dynamic definitions from visible graph evidence.
The overlay keeps the single-zone visible-node assignment behavior while
preserving edge evidence in zone details.
## Task 4: Add Zone Diagnostics To The Explorer ## Task 4: Add Zone Diagnostics To The Explorer
```task ```task