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

View File

@@ -988,10 +988,230 @@ def graph_explorer_page() -> str:
.split("")
.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) =>
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) => {
if (Number.isFinite(zone.rank)) return zone.rank;
if (zone.id === "deploymentEnvironment:dev-tegwick") return 0;
if (zone.id === "deploymentEnvironment:test") return 1;
if (zone.id === "deploymentEnvironment:prod") return 2;
@@ -1011,36 +1231,9 @@ def graph_explorer_page() -> str:
};
const zoneForData = (data, grouping = activeZoneGrouping) => {
if (grouping === "deploymentEnvironment") {
const value = String(data.deploymentEnvironment || "").trim();
if (!value) return 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 definition = zoneDefinitionsForData(data, grouping)
.find((candidate) => zoneRuleMatches(data, candidate.membership));
return definition ? zoneDescriptorFromDefinition(definition) : null;
};
const renderedNodeBox = (node) => {
@@ -1091,30 +1284,22 @@ def graph_explorer_page() -> str:
};
const collectZoneSummaries = () => {
const groups = new Map();
if (!cy) return groups;
cy.elements().filter((element) => element.style("display") !== "none").forEach((element) => {
const zone = zoneForData(element.data());
if (!zone) return;
if (!groups.has(zone.id)) {
groups.set(zone.id, {...zone, nodes: [], elements: []});
}
const group = groups.get(zone.id);
group.elements.push(element);
if (element.isNode()) group.nodes.push(element);
});
return groups;
if (!cy) return new Map();
const visibleElements = cy.elements()
.filter((element) => element.style("display") !== "none")
.toArray();
return resolveZoneInstances(
visibleElements,
zoneDefinitionsForElements(visibleElements, activeZoneGrouping)
);
};
const zoneCountsForField = (elements, field) => {
if (field !== "deploymentEnvironment" && field !== "accessZone") return groupCounts(elements, field);
const groups = new Map();
elements.forEach((element) => {
const zone = zoneForData(element.data(), field);
if (!zone) return;
groups.set(zone.label, (groups.get(zone.label) || 0) + 1);
});
return Array.from(groups.entries())
const zones = resolveZoneInstances(elements, zoneDefinitionsForElements(elements, field));
return Array.from(zones.values())
.filter((zone) => zone.elements.length > 0)
.map((zone) => [zone.label, zone.elements.length])
.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 "zoneModeFields" in page
assert "zoneForData" in page
assert "defaultZoneDefinitions" in page
assert "resolveZoneInstances" in page
assert 'normalize: "deploymentEnvironment"' in page
assert "zoneBoundsForNodes" in page
assert "renderZoneOverlay" in page
assert "renderZoneDetails" in page

View File

@@ -95,7 +95,7 @@ summaries, and conflict diagnostics. Covered the core behavior in
```task
id: RAIL-FAB-WP-0022-T03
status: todo
status: done
priority: high
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
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