diff --git a/docs/graph-explorer-contract.md b/docs/graph-explorer-contract.md index cc48247..960bee5 100644 --- a/docs/graph-explorer-contract.md +++ b/docs/graph-explorer-contract.md @@ -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 | |---------------|----------------| diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index 7f5db02..e032c22 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -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])); }; diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index f4cac01..71a68a1 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -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 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 c0c8bf5..9c7f2b7 100644 --- a/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md +++ b/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md @@ -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