feat: render graph explorer zone boundaries

This commit is contained in:
2026-05-24 17:02:12 +02:00
parent ea81533172
commit 5a2d987b6a
4 changed files with 437 additions and 10 deletions

View File

@@ -154,6 +154,23 @@ The graph explorer should support zone-oriented modes for Fabric payloads:
Zone modes are diagnostic views. They answer "where does this run or who can Zone modes are diagnostic views. They answer "where does this run or who can
reach it here?" without mutating the underlying Fabric responsibility boundary. 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`:
| Overlay label | Matching nodes |
|---------------|----------------|
| `dev-tegwick` | visible nodes with `deploymentEnvironment: dev` in the private local development overlay |
| `test` | visible nodes with `deploymentEnvironment: test` or legacy `staging` |
| `prod` | visible nodes with `deploymentEnvironment: prod` |
Nodes without deployment overlay data are not enclosed. Edge-only deployment
overlay evidence may contribute to zone summaries and warnings, but it should
not create a boundary unless at least one visible node belongs to the same zone.
When access-zone grouping is selected, boundaries use `accessZone` values such
as `private-dev`, `collaborator-test`, `production-public`, or
`production-admin`.
Useful warnings for the graph explorer include: Useful warnings for the graph explorer include:
- control surfaces in user-facing access zones; - control surfaces in user-facing access zones;

View File

@@ -36,7 +36,50 @@ def graph_explorer_page() -> str:
} }
.main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; min-height: 0; } .main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; min-height: 0; }
.canvas-wrap { position: relative; min-width: 0; min-height: 0; } .canvas-wrap { position: relative; min-width: 0; min-height: 0; }
#graph-canvas { position: absolute; inset: 0; } #graph-canvas { position: absolute; inset: 0; z-index: 0; }
.zone-overlay {
position: absolute;
inset: 0;
z-index: 2;
overflow: hidden;
pointer-events: none;
}
.zone-boundary {
position: absolute;
border: 2px solid var(--zone-color, #2563eb);
border-radius: 8px;
background: var(--zone-fill, rgba(37, 99, 235, .06));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .74), 0 10px 32px rgba(23, 32, 51, .08);
min-height: 56px;
min-width: 90px;
pointer-events: none;
}
.zone-boundary.selected {
border-width: 3px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .82), 0 14px 36px rgba(23, 32, 51, .14);
}
.zone-label {
position: absolute;
top: 8px;
left: 10px;
z-index: 1;
min-height: 26px;
max-width: calc(100% - 20px);
border-color: var(--zone-color, #2563eb);
color: var(--zone-color, #2563eb);
background: rgba(255, 255, 255, .96);
box-shadow: 0 8px 18px rgba(23, 32, 51, .1);
font-size: 12px;
font-weight: 700;
overflow: hidden;
padding: 3px 8px;
pointer-events: auto;
text-overflow: ellipsis;
}
.zone-label:focus-visible {
outline: 2px solid var(--zone-color, #2563eb);
outline-offset: 2px;
}
.side { .side {
border-left: 1px solid var(--line); border-left: 1px solid var(--line);
background: var(--panel); background: var(--panel);
@@ -181,6 +224,14 @@ def graph_explorer_page() -> str:
min-width: 84px; min-width: 84px;
padding: 4px 6px; padding: 4px 6px;
} }
.map-control-field input[type="checkbox"] {
width: 18px;
height: 18px;
min-height: 0;
margin: 5px 0;
padding: 0;
accent-color: var(--accent);
}
.selection-anchor { .selection-anchor {
position: absolute; position: absolute;
z-index: 5; z-index: 5;
@@ -390,7 +441,17 @@ def graph_explorer_page() -> str:
<main class="main"> <main class="main">
<div class="canvas-wrap"> <div class="canvas-wrap">
<div id="graph-canvas" role="img" aria-label="Interactive Fabric graph" aria-live="polite"><p class="canvas-notice">Loading graph...</p></div> <div id="graph-canvas" role="img" aria-label="Interactive Fabric graph" aria-live="polite"><p class="canvas-notice">Loading graph...</p></div>
<div id="zone-overlay" class="zone-overlay" aria-label="Deployment zone boundaries"></div>
<div class="map-controls" aria-label="Map navigation controls"> <div class="map-controls" aria-label="Map navigation controls">
<label class="map-control-field"><span class="field-label">Zones <button type="button" class="help-tip" aria-label="Zone boundary help" data-help-title="Zones" data-help="Zone boundaries draw labeled rectangles around visible nodes that share deployment overlay fields. They are visual annotations only.">?</button></span>
<input id="zone-boundary-toggle" type="checkbox" aria-label="Show zone boundaries" checked>
</label>
<label class="map-control-field"><span class="field-label">Zone By <button type="button" class="help-tip" aria-label="Zone grouping help" data-help-title="Zone By" data-help="Environment groups nodes as dev-tegwick, test, or prod. Access Zone groups by intended reachability such as private-dev or production-public.">?</button></span>
<select id="zone-group-select" title="Choose zone boundary grouping">
<option value="deploymentEnvironment">Environment</option>
<option value="accessZone">Access Zone</option>
</select>
</label>
<label class="map-control-field"><span class="field-label">Labels <button type="button" class="help-tip" aria-label="Labels help" data-help-title="Labels" data-help="Label density changes only text visibility. Auto hides low-priority labels when the map is dense; Key keeps repositories, services, deployments, servers, and issue markers visible.">?</button></span> <label class="map-control-field"><span class="field-label">Labels <button type="button" class="help-tip" aria-label="Labels help" data-help-title="Labels" data-help="Label density changes only text visibility. Auto hides low-priority labels when the map is dense; Key keeps repositories, services, deployments, servers, and issue markers visible.">?</button></span>
<select id="label-select" title="Control node label density"> <select id="label-select" title="Control node label density">
<option value="auto">Auto</option> <option value="auto">Auto</option>
@@ -495,6 +556,7 @@ def graph_explorer_page() -> str:
const manifestUrl = "/exports/graph-explorer/manifest"; const manifestUrl = "/exports/graph-explorer/manifest";
const graphUrl = "/exports/graph-explorer"; const graphUrl = "/exports/graph-explorer";
const canvas = document.getElementById("graph-canvas"); const canvas = document.getElementById("graph-canvas");
const zoneOverlay = document.getElementById("zone-overlay");
const popup = document.getElementById("popup"); const popup = document.getElementById("popup");
const helpPopup = document.getElementById("help-popup"); const helpPopup = document.getElementById("help-popup");
const selectionAnchor = document.getElementById("selection-anchor"); const selectionAnchor = document.getElementById("selection-anchor");
@@ -503,6 +565,8 @@ def graph_explorer_page() -> str:
const modeSelect = document.getElementById("mode-select"); const modeSelect = document.getElementById("mode-select");
const layoutSelect = document.getElementById("layout-select"); const layoutSelect = document.getElementById("layout-select");
const labelSelect = document.getElementById("label-select"); const labelSelect = document.getElementById("label-select");
const zoneBoundaryToggle = document.getElementById("zone-boundary-toggle");
const zoneGroupSelect = document.getElementById("zone-group-select");
const nodeTypeFilter = document.getElementById("node-type-filter"); const nodeTypeFilter = document.getElementById("node-type-filter");
const nodeTypeSummary = document.getElementById("node-type-summary"); const nodeTypeSummary = document.getElementById("node-type-summary");
const edgeTypeFilter = document.getElementById("edge-type-filter"); const edgeTypeFilter = document.getElementById("edge-type-filter");
@@ -541,12 +605,16 @@ def graph_explorer_page() -> str:
let allEdgeTypes = []; let allEdgeTypes = [];
let activeMode = "full"; let activeMode = "full";
let activeLabelMode = "auto"; let activeLabelMode = "auto";
let activeZoneGrouping = "deploymentEnvironment";
let profilePersistence = "none"; let profilePersistence = "none";
let profiles = []; let profiles = [];
let currentProfileId = ""; let currentProfileId = "";
let filterRules = []; let filterRules = [];
let editingRuleId = ""; let editingRuleId = "";
let orientationContext = null; let orientationContext = null;
let selectedZoneId = "";
let zoneOverlayFrame = 0;
let zoneSummaries = new Map();
const escapeHtml = (value) => String(value ?? "") const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&amp;").replaceAll("<", "&lt;") .replaceAll("&", "&amp;").replaceAll("<", "&lt;")
@@ -903,6 +971,255 @@ def graph_explorer_page() -> str:
return warnings; return warnings;
}; };
const zonePalette = {
"deploymentEnvironment:dev-tegwick": {color: "#0f766e", fill: "rgba(15, 118, 110, .07)"},
"deploymentEnvironment:test": {color: "#2563eb", fill: "rgba(37, 99, 235, .07)"},
"deploymentEnvironment:prod": {color: "#be123c", fill: "rgba(190, 18, 60, .07)"},
};
const fallbackZonePalette = [
{color: "#7c3aed", fill: "rgba(124, 58, 237, .07)"},
{color: "#b45309", fill: "rgba(180, 83, 9, .08)"},
{color: "#0891b2", fill: "rgba(8, 145, 178, .08)"},
{color: "#4f46e5", fill: "rgba(79, 70, 229, .07)"},
];
const hashString = (value) => String(value || "")
.split("")
.reduce((total, character) => (total * 31 + character.charCodeAt(0)) >>> 0, 0);
const zoneStyle = (zone) =>
zonePalette[zone.id] || fallbackZonePalette[hashString(zone.id) % fallbackZonePalette.length];
const zoneRank = (zone) => {
if (zone.id === "deploymentEnvironment:dev-tegwick") return 0;
if (zone.id === "deploymentEnvironment:test") return 1;
if (zone.id === "deploymentEnvironment:prod") return 2;
return 10 + hashString(zone.id) % 20;
};
const zoneLabelRank = (zone, index) => {
if (zone.id === "deploymentEnvironment:test") return 0;
if (zone.id === "deploymentEnvironment:prod") return 1;
if (zone.id === "deploymentEnvironment:dev-tegwick") return 2;
return index % 4;
};
const zoneLabelTop = (zone, bounds, index) => {
const rank = zone.field === "deploymentEnvironment" ? zoneLabelRank(zone, index) : index % 4;
return Math.min(8 + rank * 28, Math.max(8, Math.round(bounds.height - 32)));
};
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 renderedNodeBox = (node) => {
try {
const box = node.renderedBoundingBox({includeLabels: true, includeOverlays: false});
if ([box.x1, box.y1, box.x2, box.y2].every(Number.isFinite)) return box;
} catch {
// Fall back to the rendered center point below.
}
const position = node.renderedPosition();
const radius = (Number(node.data("visualSize")) || 44) / 2;
return {
x1: position.x - radius,
y1: position.y - radius,
x2: position.x + radius,
y2: position.y + radius,
};
};
const zoneBoundsForNodes = (nodes) => {
if (!nodes.length) return null;
const bounds = nodes.reduce((box, node) => {
const nodeBox = renderedNodeBox(node);
return {
x1: Math.min(box.x1, nodeBox.x1),
y1: Math.min(box.y1, nodeBox.y1),
x2: Math.max(box.x2, nodeBox.x2),
y2: Math.max(box.y2, nodeBox.y2),
};
}, {x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity});
if (![bounds.x1, bounds.y1, bounds.x2, bounds.y2].every(Number.isFinite)) return null;
const padding = 34;
let left = bounds.x1 - padding;
let top = bounds.y1 - padding;
let width = bounds.x2 - bounds.x1 + padding * 2;
let height = bounds.y2 - bounds.y1 + padding * 2;
const minWidth = 132;
const minHeight = 84;
if (width < minWidth) {
left -= (minWidth - width) / 2;
width = minWidth;
}
if (height < minHeight) {
top -= (minHeight - height) / 2;
height = minHeight;
}
return {left, top, width, height};
};
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;
};
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())
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
};
const renderZoneDetails = (zone) => {
selected = null;
selectedZoneId = zone.id;
const visibleElements = zone.elements || [];
const visibleNodes = zone.nodes || [];
detailTitle.textContent = zone.label;
detailSummary.textContent = `${visibleNodes.length} visible node${visibleNodes.length === 1 ? "" : "s"} in ${ruleAttributeLabels[zone.field] || humanize(zone.field)}`;
detailPills.innerHTML = [zone.field, zone.value, activeZoneGrouping]
.map((value) => value ? `<span class="pill">${escapeHtml(value)}</span>` : "")
.join("");
const valuesFor = (field) => groupCounts(visibleElements, field)
.map(([value, count]) => `${value} (${count})`)
.join(", ");
const warnings = visibleElements
.flatMap((element) => zoneWarningsForData(element.data()).map((warning) =>
`${elementLabel(element)}: ${warning}`
));
const rows = [
["visible nodes", String(visibleNodes.length)],
["deployment environments", valuesFor("deploymentEnvironment")],
["deployment scenarios", valuesFor("deploymentScenario")],
["access zones", valuesFor("accessZone")],
["routing authorities", valuesFor("routingAuthority")],
["policy authorities", valuesFor("policyAuthority")],
...warnings.slice(0, 8).map((warning) => ["warning", warning]),
];
if (warnings.length > 8) rows.push(["warning", `${warnings.length - 8} additional route warnings`]);
detailList.innerHTML = rows
.filter(([, value]) => value)
.map(([key, value]) => `<li class="${key === "warning" ? "orientation-warning" : ""}"><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
.join("");
const contextIds = new Set(visibleNodes.map((node) => node.id()));
visibleElements.filter((element) => element.isEdge()).forEach((edge) => addContextEdge(contextIds, edge));
orientationContext = {
ids: Array.from(contextIds),
profileName: `Zone: ${zone.label}`,
};
orientationTitle.textContent = `Zone context (${orientationContext.ids.length} items)`;
orientationList.innerHTML = [
{label: "grouping", value: ruleAttributeLabels[zone.field] || humanize(zone.field), state: "good"},
{label: "zone", value: zone.label},
{label: "warnings", value: warnings.length ? String(warnings.length) : "none", state: warnings.length ? "warning" : "good"},
].map((row) => `
<li class="orientation-item ${orientationStateClass(row.state)}">
<span class="orientation-label">${escapeHtml(row.label)}</span>
<span class="orientation-value">${escapeHtml(row.value)}</span>
</li>
`).join("");
orientationActions.innerHTML = `
<button type="button" data-orientation-action="focus">Focus Context</button>
<button type="button" data-orientation-action="highlight">Highlight Context</button>
<button type="button" data-orientation-action="hide-other">Hide Other</button>
<button type="button" data-orientation-action="remove-other">Remove Other</button>
<button type="button" data-orientation-action="name-view">Name View</button>
`;
updateLabelVisibility();
updateSelectionAnchor();
};
const renderZoneOverlay = () => {
if (!zoneOverlay || !cy) return;
const enabled = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true;
if (!enabled) {
zoneSummaries = new Map();
selectedZoneId = "";
zoneOverlay.innerHTML = "";
return;
}
zoneSummaries = collectZoneSummaries();
const boundaries = Array.from(zoneSummaries.values())
.filter((zone) => zone.nodes.length > 0)
.sort((left, right) => zoneRank(left) - zoneRank(right) || left.label.localeCompare(right.label))
.map((zone, index) => {
const bounds = zoneBoundsForNodes(zone.nodes);
if (!bounds) return "";
const style = zoneStyle(zone);
const selectedClass = selectedZoneId === zone.id ? " selected" : "";
const labelTop = zoneLabelTop(zone, bounds, index);
return `
<div class="zone-boundary${selectedClass}" data-zone-id="${escapeHtml(zone.id)}" style="--zone-color:${style.color};--zone-fill:${style.fill};left:${Math.round(bounds.left)}px;top:${Math.round(bounds.top)}px;width:${Math.round(bounds.width)}px;height:${Math.round(bounds.height)}px">
<button type="button" class="zone-label" data-zone-id="${escapeHtml(zone.id)}" style="--zone-color:${style.color};top:${labelTop}px" title="${escapeHtml(zone.label)}">${escapeHtml(zone.label)}</button>
</div>
`;
});
zoneOverlay.innerHTML = boundaries.join("");
if (selectedZoneId && zoneSummaries.has(selectedZoneId)) {
renderZoneDetails(zoneSummaries.get(selectedZoneId));
} else if (selectedZoneId) {
selectedZoneId = "";
}
};
const scheduleZoneOverlayUpdate = () => {
if (!zoneOverlay || zoneOverlayFrame) return;
zoneOverlayFrame = window.requestAnimationFrame(() => {
zoneOverlayFrame = 0;
renderZoneOverlay();
});
};
const groupCounts = (elements, field) => { const groupCounts = (elements, field) => {
const counts = new Map(); const counts = new Map();
elements.forEach((element) => { elements.forEach((element) => {
@@ -927,7 +1244,7 @@ def graph_explorer_page() -> str:
const rows = [{label: "visible", value: `${visibleNodes} nodes / ${visibleEdges} edges`}]; const rows = [{label: "visible", value: `${visibleNodes} nodes / ${visibleEdges} edges`}];
fields.forEach((field) => { fields.forEach((field) => {
const counts = groupCounts(visibleElements, field); const counts = zoneCountsForField(visibleElements, field);
rows.push({ rows.push({
label: ruleAttributeLabels[field] || humanize(field), label: ruleAttributeLabels[field] || humanize(field),
value: counts.length value: counts.length
@@ -1032,6 +1349,8 @@ def graph_explorer_page() -> str:
edgeTypes: Array.from(selectedEdgeTypes()), edgeTypes: Array.from(selectedEdgeTypes()),
review: reviewFilter.value, review: reviewFilter.value,
unresolved: unresolvedFilter.value, unresolved: unresolvedFilter.value,
zoneBoundaries: zoneBoundaryToggle ? zoneBoundaryToggle.checked : true,
zoneGrouping: zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment",
rules: filterRules.map((rule) => ({...rule})), rules: filterRules.map((rule) => ({...rule})),
manualOverrides: {...manualOverrides}, manualOverrides: {...manualOverrides},
}); });
@@ -1060,6 +1379,8 @@ def graph_explorer_page() -> str:
if (params.has("layer")) state.nodeTypes = parseListParam(params.get("layer")); if (params.has("layer")) state.nodeTypes = parseListParam(params.get("layer"));
if (params.has("review")) state.review = params.get("review") || ""; if (params.has("review")) state.review = params.get("review") || "";
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("zoneGrouping")) state.zoneGrouping = params.get("zoneGrouping") || "";
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 {
@@ -1082,6 +1403,8 @@ def graph_explorer_page() -> str:
if (state.edgeTypes.length !== allEdgeTypes.length) params.set("edgeTypes", state.edgeTypes.join(",")); if (state.edgeTypes.length !== allEdgeTypes.length) params.set("edgeTypes", state.edgeTypes.join(","));
if (state.review) params.set("review", state.review); if (state.review) params.set("review", state.review);
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.zoneGrouping && state.zoneGrouping !== "deploymentEnvironment") params.set("zoneGrouping", state.zoneGrouping);
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));
@@ -1108,6 +1431,10 @@ 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 ("zoneGrouping" in state && zoneGroupSelect) {
zoneGroupSelect.value = optionExists(zoneGroupSelect, state.zoneGrouping) ? state.zoneGrouping : "deploymentEnvironment";
}
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};
} }
@@ -1119,6 +1446,8 @@ def graph_explorer_page() -> str:
} }
activeMode = modeSelect.value || "full"; activeMode = modeSelect.value || "full";
activeLabelMode = labelSelect.value || "auto"; activeLabelMode = labelSelect.value || "auto";
activeZoneGrouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment";
selectedZoneId = "";
focusSet = null; focusSet = null;
syncFilterSummaries(); syncFilterSummaries();
applyFilters(); applyFilters();
@@ -1154,6 +1483,10 @@ def graph_explorer_page() -> str:
if (selectedEdgeTypes().size !== allEdgeTypes.length) parts.push("edge filter"); if (selectedEdgeTypes().size !== allEdgeTypes.length) parts.push("edge filter");
if (reviewFilter.value) parts.push(`${reviewFilter.value} review`); if (reviewFilter.value) parts.push(`${reviewFilter.value} review`);
if (unresolvedFilter.value) parts.push("unresolved only"); if (unresolvedFilter.value) parts.push("unresolved only");
if (zoneBoundaryToggle && !zoneBoundaryToggle.checked) parts.push("zones off");
if (zoneGroupSelect && (zoneGroupSelect.value || "deploymentEnvironment") !== "deploymentEnvironment") {
parts.push("access zone boundaries");
}
if (filterRules.length) parts.push(`${filterRules.length} rule${filterRules.length === 1 ? "" : "s"}`); if (filterRules.length) parts.push(`${filterRules.length} rule${filterRules.length === 1 ? "" : "s"}`);
const overrideCount = Object.keys(manualOverrides).length; const overrideCount = Object.keys(manualOverrides).length;
if (overrideCount) parts.push(`${overrideCount} override${overrideCount === 1 ? "" : "s"}`); if (overrideCount) parts.push(`${overrideCount} override${overrideCount === 1 ? "" : "s"}`);
@@ -1327,12 +1660,15 @@ def graph_explorer_page() -> str:
hiddenSummary.textContent = removed ? `Hidden ${hidden} / Removed ${removed}` : `Hidden ${hidden}`; hiddenSummary.textContent = removed ? `Hidden ${hidden} / Removed ${removed}` : `Hidden ${hidden}`;
updateLabelVisibility(); updateLabelVisibility();
updateSelectionAnchor(); updateSelectionAnchor();
scheduleZoneOverlayUpdate();
if (!selected) renderMapOverview(); if (!selected) renderMapOverview();
if (options.redrawOnRemove && previousRemoved !== ruleRemovalSignature()) runLayout(); if (options.redrawOnRemove && previousRemoved !== ruleRemovalSignature()) runLayout();
}; };
const showDetails = (element) => { const showDetails = (element) => {
selected = element || null; selected = element || null;
selectedZoneId = "";
scheduleZoneOverlayUpdate();
if (!element) { if (!element) {
orientationContext = null; orientationContext = null;
renderMapOverview(); renderMapOverview();
@@ -1623,6 +1959,7 @@ def graph_explorer_page() -> str:
: {name, padding: 48, animate: false}; : {name, padding: 48, animate: false};
layoutElements.layout(options).run(); layoutElements.layout(options).run();
updateSelectionAnchor(); updateSelectionAnchor();
scheduleZoneOverlayUpdate();
}; };
const renderLegend = (layers) => { const renderLegend = (layers) => {
@@ -1742,8 +2079,14 @@ def graph_explorer_page() -> str:
renderRules(); renderRules();
cy.on("tap", "node, edge", (event) => showDetails(event.target)); cy.on("tap", "node, edge", (event) => showDetails(event.target));
cy.on("tap", (event) => { if (event.target === cy) showDetails(null); }); cy.on("tap", (event) => { if (event.target === cy) showDetails(null); });
cy.on("pan zoom resize render layoutstop", updateSelectionAnchor); cy.on("pan zoom resize render layoutstop", () => {
cy.on("position", "node", updateSelectionAnchor); updateSelectionAnchor();
scheduleZoneOverlayUpdate();
});
cy.on("position", "node", () => {
updateSelectionAnchor();
scheduleZoneOverlayUpdate();
});
cy.on("mouseover", "node, edge", (event) => { cy.on("mouseover", "node, edge", (event) => {
event.target.addClass("hover"); event.target.addClass("hover");
const data = event.target.data(); const data = event.target.data();
@@ -1811,6 +2154,36 @@ def graph_explorer_page() -> str:
updateProfileSummary(); updateProfileSummary();
updateUrlState(); updateUrlState();
}); });
zoneBoundaryToggle.addEventListener("input", () => {
selectedZoneId = "";
currentProfileId = "";
profileSelect.value = "";
renderZoneOverlay();
if (!selected) renderMapOverview();
updateProfileControls();
updateProfileSummary();
updateUrlState();
});
zoneGroupSelect.addEventListener("input", () => {
activeZoneGrouping = zoneGroupSelect.value || "deploymentEnvironment";
selectedZoneId = "";
currentProfileId = "";
profileSelect.value = "";
renderZoneOverlay();
if (!selected) renderMapOverview();
updateProfileControls();
updateProfileSummary();
updateUrlState();
});
zoneOverlay.addEventListener("click", (event) => {
const button = event.target.closest("[data-zone-id]");
if (!button) return;
const zone = zoneSummaries.get(button.dataset.zoneId);
if (!zone) return;
event.stopPropagation();
selectedZoneId = zone.id;
renderZoneOverlay();
});
ruleTarget.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value})); ruleTarget.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value}));
ruleType.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value, type: ruleType.value})); ruleType.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value, type: ruleType.value}));
ruleAttribute.addEventListener("input", () => refreshRuleBuilder({ ruleAttribute.addEventListener("input", () => refreshRuleBuilder({
@@ -1899,6 +2272,10 @@ def graph_explorer_page() -> str:
activeMode = "full"; activeMode = "full";
labelSelect.value = "auto"; labelSelect.value = "auto";
activeLabelMode = "auto"; activeLabelMode = "auto";
zoneBoundaryToggle.checked = true;
zoneGroupSelect.value = "deploymentEnvironment";
activeZoneGrouping = "deploymentEnvironment";
selectedZoneId = "";
setCheckedValues(nodeTypeFilter); setCheckedValues(nodeTypeFilter);
setCheckedValues(edgeTypeFilter); setCheckedValues(edgeTypeFilter);
reviewFilter.value = ""; reviewFilter.value = "";

View File

@@ -399,6 +399,9 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert 'id="mode-select"' in page assert 'id="mode-select"' in page
assert 'id="layout-select"' in page assert 'id="layout-select"' in page
assert 'id="label-select"' in page assert 'id="label-select"' in page
assert 'id="zone-overlay"' in page
assert 'id="zone-boundary-toggle"' in page
assert 'id="zone-group-select"' in page
assert 'id="node-type-filter"' in page assert 'id="node-type-filter"' in page
assert 'id="edge-type-filter"' in page assert 'id="edge-type-filter"' in page
assert 'id="rule-panel"' in page assert 'id="rule-panel"' in page
@@ -411,6 +414,14 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert "ruleActionFor" in page assert "ruleActionFor" in page
assert "ruleRemovalSignature" in page assert "ruleRemovalSignature" in page
assert "zoneModeFields" in page assert "zoneModeFields" in page
assert "zoneForData" in page
assert "zoneBoundsForNodes" in page
assert "renderZoneOverlay" in page
assert "renderZoneDetails" in page
assert "dev-tegwick" in page
assert 'normalized === "staging"' in page
assert "zoneBoundaries" in page
assert "zoneGrouping" in page
assert "renderMapOverview" in page assert "renderMapOverview" in page
assert "route without policy authority" in page assert "route without policy authority" in page
assert "deploymentEnvironment" in page assert "deploymentEnvironment" in page

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Zone Boundary Overlays" title: "Zone Boundary Overlays"
domain: railiance domain: railiance
repo: railiance-fabric repo: railiance-fabric
status: ready status: finished
owner: codex owner: codex
topic_slug: railiance topic_slug: railiance
created: "2026-05-24" created: "2026-05-24"
@@ -58,7 +58,7 @@ parent nodes unless testing shows the overlay layer is too fragile.
```task ```task
id: RAIL-FAB-WP-0021-T01 id: RAIL-FAB-WP-0021-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "91777c22-663c-443a-b682-38bfb7a864bf" state_hub_task_id: "91777c22-663c-443a-b682-38bfb7a864bf"
``` ```
@@ -78,11 +78,16 @@ Expected grouping rules:
Done when the grouping and label rules are documented in the graph explorer Done when the grouping and label rules are documented in the graph explorer
contract or operations docs and have focused tests. contract or operations docs and have focused tests.
Result: documented environment/access-zone boundary rules in
`docs/graph-explorer-contract.md`; graph explorer maps `dev` to `dev-tegwick`,
legacy `staging` to `test`, `prod` to `prod`, and ignores catch-all `all`
environment values for rectangle boundaries.
## T02 - Render Rectangle Boundary Overlays ## T02 - Render Rectangle Boundary Overlays
```task ```task
id: RAIL-FAB-WP-0021-T02 id: RAIL-FAB-WP-0021-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "98ef5ecc-fa4d-443c-a4fe-fa896984d6c4" state_hub_task_id: "98ef5ecc-fa4d-443c-a4fe-fa896984d6c4"
``` ```
@@ -103,11 +108,15 @@ Implementation expectations:
Done when the UI shows stable labeled rectangles for `dev-tegwick`, `test`, and Done when the UI shows stable labeled rectangles for `dev-tegwick`, `test`, and
`prod` where matching nodes are visible. `prod` where matching nodes are visible.
Result: added a canvas overlay layer that computes padded rendered-node bounds,
draws labeled rectangles, updates after layout/pan/zoom/filter changes, and
hides empty zones.
## T03 - Add Zone Overlay Controls ## T03 - Add Zone Overlay Controls
```task ```task
id: RAIL-FAB-WP-0021-T03 id: RAIL-FAB-WP-0021-T03
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "30d48d5c-bd53-434b-9b8b-ed1a495cee4e" state_hub_task_id: "30d48d5c-bd53-434b-9b8b-ed1a495cee4e"
``` ```
@@ -125,11 +134,14 @@ Expected controls:
Done when operators can turn the overlay off for dense graph inspection and Done when operators can turn the overlay off for dense graph inspection and
restore it through shared/saved view state. restore it through shared/saved view state.
Result: added map controls for zone visibility and grouping by deployment
environment or access zone, with URL/profile state preservation.
## T04 - Surface Zone Boundary Details ## T04 - Surface Zone Boundary Details
```task ```task
id: RAIL-FAB-WP-0021-T04 id: RAIL-FAB-WP-0021-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "c002aa54-a243-4912-81ae-0d282910ebc6" state_hub_task_id: "c002aa54-a243-4912-81ae-0d282910ebc6"
``` ```
@@ -148,11 +160,16 @@ Expected behavior:
Done when a zone label provides the same core answers as the WP-20 map overview, Done when a zone label provides the same core answers as the WP-20 map overview,
but scoped to that zone. but scoped to that zone.
Result: zone labels are clickable and show scoped counts for visible nodes,
environments, scenarios, access zones, routing authorities, policy authorities,
and route-without-policy-authority warnings; existing focus/highlight/hide
orientation actions work for zone contexts.
## T05 - Verify Responsive And Dense Graph Behavior ## T05 - Verify Responsive And Dense Graph Behavior
```task ```task
id: RAIL-FAB-WP-0021-T05 id: RAIL-FAB-WP-0021-T05
status: todo status: done
priority: high priority: high
state_hub_task_id: "03f265ad-f335-4caf-a882-35d8d30c75fc" state_hub_task_id: "03f265ad-f335-4caf-a882-35d8d30c75fc"
``` ```
@@ -171,3 +188,8 @@ Checks should cover:
Done when automated tests cover the computed grouping behavior and a browser Done when automated tests cover the computed grouping behavior and a browser
smoke check confirms the canvas overlay is visible and aligned. smoke check confirms the canvas overlay is visible and aligned.
Result: static UI tests cover the zone controls/helpers; JavaScript syntax was
checked with `node --check`; the full test suite and validator pass; a headless
Edge smoke screenshot confirmed visible labeled boundaries in deployment
environment mode.