Compare commits

...

5 Commits

Author SHA1 Message Date
558e0dc157 feat: stabilize graph zone containers 2026-05-25 02:08:45 +02:00
0f7b7d1fed feat: add draggable graph zones 2026-05-25 01:09:05 +02:00
9b612447ca docs: finish zone entity workplan 2026-05-25 00:43:34 +02:00
a7a22a673f feat: prototype graph zone collapse 2026-05-25 00:40:13 +02:00
296ac051a7 feat: persist graph explorer zone state 2026-05-25 00:27:10 +02:00
9 changed files with 1068 additions and 36 deletions

View File

@@ -169,6 +169,65 @@ introduce a two-phase layout:
This will likely require an internal view model that separates fabric graph data
from rendered graph coordinates.
### Per-Zone Layout Preparation
The current graph explorer should not immediately run independent Cytoscape
layouts inside each zone rectangle. Cytoscape layouts operate on collections in
one coordinate space, while the current zone overlay is a view layer drawn over
the already-laid-out graph. Running nested layouts directly against visible
nodes would make pan/zoom, filters, collapse state, and edge routing fragile.
The safer path is a two-phase view layout:
1. Resolve zones from the current graph and view filters.
2. Place zone containers and unzoned nodes in the global canvas.
3. For each zone, compute local coordinates for its assigned nodes using the
zone's configured layout algorithm.
4. Project local zone coordinates into global graph coordinates.
5. Route internal edges inside the zone and boundary edges through the zone
perimeter or collapsed zone node.
The implementation needs these pieces before per-zone layouts become safe:
- a resolved zone view model that survives filtering and saved profiles;
- a stable assignment invariant so a visible node belongs to no more than one
zone;
- a zone container model with size, position, padding, and height;
- a local coordinate projection layer from zone space to Cytoscape space;
- explicit boundary-edge routing rules;
- collapse state that can replace a zone subgraph with a representative node;
- diagnostics when a configured zone layout cannot be applied.
The present implementation already establishes the first, second, and sixth
pieces. A follow-up should introduce a zone container placement phase before
attempting per-zone node layout. That follow-up can keep Cytoscape as the final
renderer while moving layout decisions into a Fabric-owned view model.
### Stable Zone Containers
The first container implementation keeps zone surfaces as view state keyed by
stable zone id. When a zone first appears, the global graph layout supplies its
initial center. Once created, the container owns the zone surface position while
the global layout continues to arrange the base canvas and unzoned nodes.
Dragging a zone moves the container and its currently assigned visible member
nodes together. Rerunning layout or switching the layout algorithm should keep
the container in its stored graph coordinates and then project the zone's
visible subgraph back into that container.
Container state belongs in saved or copied graph view state, not in the Fabric
payload. It is an operator workspace preference, similar to manual visibility
overrides.
### Context Edges
Display-only context edges are not zone connectivity. Repository `declares`
edges, for example, show which repository declared a node, but they should not
create boundary diagnostics, attraction paths, or collapsed-zone boundary
edges. A host can still show them as explanatory evidence in details, but a
zone boundary should only react to canonical or host-promoted graph
relationships.
## Layer Height And Overlap
Zone presentation includes a height. Height is a visual stacking concept, not a

View File

@@ -173,6 +173,11 @@ When access-zone grouping is selected, boundaries use `accessZone` values such
as `private-dev`, `collaborator-test`, `production-public`, or
`production-admin`.
Zone labels should be rendered as plain text in the upper-left corner of the
zone rectangle, without badge frames or white backgrounds. The label may still
act as the zone's focus/drag handle as long as it visually reads as text drawn
on the zone surface.
Useful warnings for the graph explorer include:
- control surfaces in user-facing access zones;
@@ -187,6 +192,39 @@ initial diagnostic set includes empty zone seed sets, visible nodes matched by
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.
Display-only context edges, such as repository `declares` edges, are evidence
for where declarations came from. They must not count as zone boundary
connectivity, attraction paths, or collapsed-zone boundary edges unless a host
explicitly promotes them to canonical graph relationships.
Saved graph profiles should persist zone view state as an explicit nested
`zone` object. The initial fields are `visible`, `grouping`, `definitionSet`,
`presentation`, and `containers`. 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, presentation preferences, and operator-placed zone
surfaces can be restored without another state migration.
Zone containers are view state, not fabric data. A container stores a stable
zone surface position and size in graph coordinates. Global graph layout may
place unzoned nodes and provide an initial center for new zones, but existing
zone containers should keep their operator-chosen positions when the layout
algorithm changes. After the global layout pass, each zone may project its
assigned visible nodes into local coordinates inside its container. The first
local layout may be a deterministic compact layout; later engines can replace
that with per-zone Cytoscape or engine-owned algorithms.
Zone collapse is a view-only operation. A collapsed zone should hide its visible
member nodes, replace them with a synthetic zone node, and draw synthetic
boundary edges from that zone node to visible external neighbors. Internal edges
are summarized on the zone node rather than rendered. Expanding the zone removes
the synthetic elements and restores the original graph elements without
changing the underlying payload.
Zone dragging is also view-only. Dragging a zone handle should translate the
currently assigned visible member nodes by the same delta and then recompute the
overlay bounds from the new node positions. The operation updates Cytoscape view
coordinates only; it does not change Fabric graph data.
## Repo-Scoping Compatibility

View File

@@ -65,17 +65,20 @@ def graph_explorer_page() -> str:
z-index: 1;
min-height: 26px;
max-width: calc(100% - 20px);
border-color: var(--zone-color, #2563eb);
border: 0;
color: var(--zone-color, #2563eb);
background: rgba(255, 255, 255, .96);
box-shadow: 0 8px 18px rgba(23, 32, 51, .1);
background: transparent;
box-shadow: none;
cursor: grab;
font-size: 12px;
font-weight: 700;
overflow: hidden;
padding: 3px 8px;
padding: 0;
pointer-events: auto;
text-overflow: ellipsis;
text-shadow: 0 1px 0 rgba(255, 255, 255, .78), 0 0 8px rgba(255, 255, 255, .72);
}
.zone-label.dragging { cursor: grabbing; }
.zone-label:focus-visible {
outline: 2px solid var(--zone-color, #2563eb);
outline-offset: 2px;
@@ -606,6 +609,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 = "";
@@ -615,6 +619,11 @@ def graph_explorer_page() -> str:
let selectedZoneId = "";
let zoneOverlayFrame = 0;
let zoneSummaries = new Map();
let zoneContainerState = new Map();
let zoneDragState = null;
let suppressZoneClick = false;
let collapsedZoneSnapshots = new Map();
const zoneCollapsePrefix = "zone-collapse:";
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
@@ -1045,6 +1054,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();
@@ -1086,12 +1103,24 @@ def graph_explorer_page() -> str:
return String(actual) === String(rule.value ?? "");
};
const isTrueish = (value) => value === true || String(value).toLowerCase() === "true";
const isZoneContextOnlyEdge = (element) => {
const data = zoneElementData(element);
const edgeType = String(data.edgeType || data.dependencyType || "").trim();
return isTrueish(data.displayOnly) || edgeType === "declares";
};
const isZoneConnectivityEdge = (element) =>
element && element.isEdge && element.isEdge() && !isZoneContextOnlyEdge(element);
const zoneDescriptorFromDefinition = (definition) => ({
field: definition.field,
id: definition.id,
label: definition.label || definition.id,
value: definition.value || definition.label || definition.id,
rank: definition.rank,
layout: definition.layout || {},
presentation: definition.presentation || {},
membership: definition.membership || {},
diagnostics: [],
@@ -1120,6 +1149,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 +1159,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();
@@ -1207,15 +1238,18 @@ def graph_explorer_page() -> str:
}
});
elements.filter((element) => element.isEdge && element.isEdge()).forEach((edge) => {
const isConnectivity = isZoneConnectivityEdge(edge);
const data = zoneElementData(edge);
const zoneIds = new Set(
definitions
.filter((definition) => zoneRuleMatches(data, definition.membership))
.map((definition) => definition.id)
isConnectivity
? 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 || targetZoneId) && sourceZoneId !== targetZoneId) {
if (isConnectivity && (sourceZoneId || targetZoneId) && sourceZoneId !== targetZoneId) {
const diagnostic = {
severity: "info",
code: zoneDiagnosticCodes.edgeCrossesZoneBoundary,
@@ -1225,8 +1259,8 @@ def graph_explorer_page() -> str:
.filter(Boolean)
.forEach((zoneId) => zones.get(zoneId)?.diagnostics.push(diagnostic));
}
if (sourceZoneId) zoneIds.add(sourceZoneId);
if (targetZoneId) zoneIds.add(targetZoneId);
if (isConnectivity && sourceZoneId) zoneIds.add(sourceZoneId);
if (isConnectivity && targetZoneId) zoneIds.add(targetZoneId);
zoneIds.forEach((zoneId) => addElementToZone(zoneId, edge));
});
return zones;
@@ -1253,8 +1287,7 @@ def graph_explorer_page() -> str:
};
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)));
return 8;
};
const zoneForData = (data, grouping = activeZoneGrouping) => {
@@ -1310,6 +1343,221 @@ def graph_explorer_page() -> str:
return {left, top, width, height};
};
const zoneNodeSortKey = (node) =>
String(node.data("stableKey") || node.data("label") || node.id());
const zoneCenterForNodes = (nodes) => {
const positions = nodes
.map((node) => node.position())
.filter((position) => Number.isFinite(position.x) && Number.isFinite(position.y));
if (!positions.length) return null;
return {
x: positions.reduce((total, position) => total + position.x, 0) / positions.length,
y: positions.reduce((total, position) => total + position.y, 0) / positions.length,
};
};
const zonePreferredSize = (zone) => {
const count = Math.max(1, (zone.nodes || []).length);
const columns = Math.max(1, Math.ceil(Math.sqrt(count * 1.35)));
const rows = Math.max(1, Math.ceil(count / columns));
const cellWidth = Number(zone.layout?.options?.cellWidth) || 118;
const cellHeight = Number(zone.layout?.options?.cellHeight) || 94;
const padding = Number(zone.layout?.options?.padding) || 60;
return {
width: Math.max(180, columns * cellWidth + padding * 2),
height: Math.max(128, rows * cellHeight + padding * 2),
padding,
};
};
const ensureZoneContainer = (zone) => {
if (!zone || !(zone.nodes || []).length) return null;
const preferred = zonePreferredSize(zone);
const existing = zoneContainerState.get(zone.id);
if (existing) {
existing.width = preferred.width;
existing.height = preferred.height;
existing.padding = preferred.padding;
return existing;
}
const center = zoneCenterForNodes(zone.nodes) || {x: 0, y: 0};
const container = {
id: zone.id,
x: center.x,
y: center.y,
width: preferred.width,
height: preferred.height,
padding: preferred.padding,
userPlaced: false,
};
zoneContainerState.set(zone.id, container);
return container;
};
const packZoneContainers = (zones) => {
const items = Array.from(zones.values())
.filter((zone) => (zone.nodes || []).length > 0)
.map((zone) => ({zone, container: ensureZoneContainer(zone)}))
.filter((item) => item.container);
if (items.length < 2 || items.some((item) => item.container.userPlaced)) return;
items.sort((left, right) =>
zoneRank(left.zone) - zoneRank(right.zone) || left.zone.label.localeCompare(right.zone.label)
);
const gap = 42;
const start = Math.min(...items.map((item) => item.container.x - item.container.width / 2));
const maxRowWidth = cy
? Math.max(720, (cy.width() || 960) / Math.max(cy.zoom() || 1, 0.2) - 96)
: Infinity;
let cursor = start;
let top = Math.min(...items.map((item) => item.container.y - item.container.height / 2));
let rowHeight = 0;
items.forEach(({container}) => {
if (cursor > start && cursor + container.width > start + maxRowWidth) {
cursor = start;
top += rowHeight + gap;
rowHeight = 0;
}
container.x = cursor + container.width / 2;
container.y = top + container.height / 2;
cursor += container.width + gap;
rowHeight = Math.max(rowHeight, container.height);
});
};
const syncZoneContainers = (zones, options = {}) => {
let created = false;
zones.forEach((zone) => {
if ((zone.nodes || []).length > 0 && !zoneContainerState.has(zone.id)) created = true;
ensureZoneContainer(zone);
});
if (options.packNew && created) packZoneContainers(zones);
};
const zoneContainerBounds = (container) => ({
left: container.x - container.width / 2,
top: container.y - container.height / 2,
width: container.width,
height: container.height,
});
const zoneRenderedBoundsFromContainer = (container) => {
const bounds = zoneContainerBounds(container);
const zoom = cy ? cy.zoom() || 1 : 1;
const pan = cy ? cy.pan() || {x: 0, y: 0} : {x: 0, y: 0};
return {
left: bounds.left * zoom + pan.x,
top: bounds.top * zoom + pan.y,
width: bounds.width * zoom,
height: bounds.height * zoom,
};
};
const layoutZoneNodesInGrid = (nodes, container) => {
const padding = Number(container.padding) || 60;
const count = Math.max(1, nodes.length);
const columns = Math.max(1, Math.ceil(Math.sqrt(count * 1.35)));
const rows = Math.max(1, Math.ceil(count / columns));
const innerWidth = Math.max(1, container.width - padding * 2);
const innerHeight = Math.max(1, container.height - padding * 2);
const cellWidth = innerWidth / columns;
const cellHeight = innerHeight / rows;
const left = container.x - container.width / 2 + padding;
const top = container.y - container.height / 2 + padding;
nodes.forEach((node, index) => {
const column = index % columns;
const row = Math.floor(index / columns);
node.position({
x: left + cellWidth * (column + 0.5),
y: top + cellHeight * (row + 0.5),
});
});
};
const layoutZoneNodesInCircle = (nodes, container) => {
if (nodes.length === 1) {
nodes[0].position({x: container.x, y: container.y});
return;
}
const padding = Number(container.padding) || 60;
const radius = Math.max(20, Math.min(container.width, container.height) / 2 - padding);
nodes.forEach((node, index) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / nodes.length;
node.position({
x: container.x + Math.cos(angle) * radius,
y: container.y + Math.sin(angle) * radius,
});
});
};
const layoutZoneSubgraph = (zone, container) => {
const nodes = (zone.nodes || [])
.slice()
.sort((left, right) => zoneNodeSortKey(left).localeCompare(zoneNodeSortKey(right)));
if (!nodes.length || !container) return;
const algorithm = String(zone.layout?.algorithm || "compact-grid");
if (algorithm === "circle") {
layoutZoneNodesInCircle(nodes, container);
return;
}
layoutZoneNodesInGrid(nodes, container);
};
const applyZoneContainerLayout = () => {
if (!cy || zoneDragState) return;
const zones = collectZoneSummaries();
syncZoneContainers(zones, {packNew: true});
zones.forEach((zone) => {
if (!(zone.nodes || []).length) return;
layoutZoneSubgraph(zone, ensureZoneContainer(zone));
});
zoneSummaries = zones;
updateSelectionAnchor();
scheduleZoneOverlayUpdate();
};
const fitVisibleGraph = () => {
if (!cy) return;
const visible = cy.elements().filter((element) => element.style("display") !== "none");
if (visible.length) cy.fit(visible, 72);
};
const serializedZoneContainers = () => {
const entries = Array.from(zoneContainerState.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([id, container]) => [id, {
x: Math.round(container.x * 100) / 100,
y: Math.round(container.y * 100) / 100,
width: Math.round(container.width * 100) / 100,
height: Math.round(container.height * 100) / 100,
userPlaced: container.userPlaced === true,
}]);
return Object.fromEntries(entries);
};
const normalizedZoneContainers = (containers) => {
const normalized = new Map();
if (!containers || typeof containers !== "object") return normalized;
Object.entries(containers).forEach(([id, value]) => {
if (!value || typeof value !== "object") return;
const x = Number(value.x);
const y = Number(value.y);
const width = Number(value.width);
const height = Number(value.height);
if (![x, y, width, height].every(Number.isFinite)) return;
normalized.set(id, {
id,
x,
y,
width: Math.max(1, width),
height: Math.max(1, height),
padding: Number(value.padding) || 60,
userPlaced: value.userPlaced === true,
});
});
return normalized;
};
const collectZoneSummaries = () => {
if (!cy) return new Map();
const visibleElements = cy.elements()
@@ -1335,6 +1583,7 @@ def graph_explorer_page() -> str:
selectedZoneId = zone.id;
const visibleElements = zone.elements || [];
const visibleNodes = zone.nodes || [];
const isCollapsed = collapsedZoneSnapshots.has(zone.id);
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]
@@ -1386,6 +1635,7 @@ def graph_explorer_page() -> str:
</li>
`).join("");
orientationActions.innerHTML = `
<button type="button" data-orientation-action="${isCollapsed ? "expand-zone" : "collapse-zone"}" data-zone-id="${escapeHtml(zone.id)}">${isCollapsed ? "Expand Zone" : "Collapse Zone"}</button>
<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>
@@ -1396,6 +1646,155 @@ def graph_explorer_page() -> str:
updateSelectionAnchor();
};
const zoneCollapseNodeId = (zoneId) => `${zoneCollapsePrefix}node:${zoneId}`;
const isZoneCollapseElement = (element) => String(element.id()).startsWith(zoneCollapsePrefix);
const removeZoneCollapseElements = () => {
if (!cy) return;
if (selected && isZoneCollapseElement(selected)) selected = null;
cy.elements().filter(isZoneCollapseElement).remove();
};
const zoneCollapsePosition = (nodes) => {
const positions = nodes.map((node) => node.position());
if (!positions.length) return {x: 0, y: 0};
return {
x: positions.reduce((total, position) => total + position.x, 0) / positions.length,
y: positions.reduce((total, position) => total + position.y, 0) / positions.length,
};
};
const zoneCollapseSnapshot = (zone) => {
const nodeIds = new Set((zone.nodes || []).map((node) => node.id()));
const boundaryEdges = [];
let internalEdgeCount = 0;
(zone.elements || [])
.filter(isZoneConnectivityEdge)
.forEach((edge) => {
const source = edge.data("source");
const target = edge.data("target");
const sourceInside = nodeIds.has(source);
const targetInside = nodeIds.has(target);
if (sourceInside && targetInside) {
internalEdgeCount += 1;
} else if (sourceInside || targetInside) {
boundaryEdges.push({
id: edge.id(),
source,
target,
edgeType: edge.data("edgeType") || "zone_boundary",
sourceInside,
targetInside,
});
}
});
return {
id: zone.id,
label: zone.label,
field: zone.field,
value: zone.value,
nodeIds: Array.from(nodeIds),
boundaryEdges,
internalEdgeCount,
position: zoneCollapsePosition(zone.nodes || []),
};
};
const addZoneCollapseElements = () => {
if (!cy || !collapsedZoneSnapshots.size) return;
collapsedZoneSnapshots.forEach((snapshot) => {
const zoneNodeId = zoneCollapseNodeId(snapshot.id);
if (!cy.getElementById(zoneNodeId).length) {
cy.add({
group: "nodes",
data: {
id: zoneNodeId,
stableKey: zoneNodeId,
kind: "Zone",
layer: "zone",
label: snapshot.label,
name: `${snapshot.label} zone`,
description: `Collapsed zone with ${snapshot.nodeIds.length} hidden node${snapshot.nodeIds.length === 1 ? "" : "s"}.`,
displayState: "show",
visualSize: 72,
zoneCollapse: true,
collapsedZoneId: snapshot.id,
collapsedZoneLabel: snapshot.label,
containedNodeCount: snapshot.nodeIds.length,
containedInternalEdgeCount: snapshot.internalEdgeCount,
boundaryEdgeCount: snapshot.boundaryEdges.length,
},
classes: "zone-collapse-node",
position: snapshot.position,
});
}
snapshot.boundaryEdges.forEach((edge, index) => {
const source = edge.sourceInside ? zoneNodeId : edge.source;
const target = edge.targetInside ? zoneNodeId : edge.target;
if (source === target || !cy.getElementById(source).length || !cy.getElementById(target).length) return;
const edgeId = `${zoneCollapsePrefix}edge:${snapshot.id}:${index}:${edge.id}`;
if (cy.getElementById(edgeId).length) return;
cy.add({
group: "edges",
data: {
id: edgeId,
stableKey: edgeId,
kind: "edge",
layer: "zone",
label: edge.edgeType,
source,
target,
edgeType: edge.edgeType,
strength: "medium",
edgeWidth: 3,
displayState: "show",
zoneCollapse: true,
collapsedZoneId: snapshot.id,
originalEdgeId: edge.id,
},
classes: "zone-collapse-edge",
});
});
});
};
const applyZoneCollapseVisibility = (hiddenNodes) => {
if (!cy || !collapsedZoneSnapshots.size) return;
collapsedZoneSnapshots.forEach((snapshot) => {
snapshot.nodeIds.forEach((nodeId) => {
const node = cy.getElementById(nodeId);
if (!node.length) return;
node.data("displayState", "hide");
node.style("display", "none");
hiddenNodes.add(nodeId);
});
});
};
const collapseZone = (zone) => {
if (!cy || !zone || !(zone.nodes || []).length) return;
collapsedZoneSnapshots.set(zone.id, zoneCollapseSnapshot(zone));
selected = null;
selectedZoneId = "";
applyFilters({redrawOnRemove: true});
runLayout();
updateProfileSummary(`Collapsed zone "${zone.label}".`);
updateUrlState();
};
const expandZone = (zoneId) => {
if (!cy || !zoneId) return;
const snapshot = collapsedZoneSnapshots.get(zoneId);
collapsedZoneSnapshots.delete(zoneId);
selected = null;
selectedZoneId = "";
applyFilters({redrawOnRemove: true});
runLayout();
updateProfileSummary(snapshot ? `Expanded zone "${snapshot.label}".` : "");
updateUrlState();
};
const renderZoneOverlay = () => {
if (!zoneOverlay || !cy) return;
const enabled = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true;
@@ -1406,11 +1805,13 @@ def graph_explorer_page() -> str:
return;
}
zoneSummaries = collectZoneSummaries();
syncZoneContainers(zoneSummaries);
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);
const container = ensureZoneContainer(zone);
const bounds = container ? zoneRenderedBoundsFromContainer(container) : zoneBoundsForNodes(zone.nodes);
if (!bounds) return "";
const style = zoneStyle(zone);
const selectedClass = selectedZoneId === zone.id ? " selected" : "";
@@ -1429,6 +1830,76 @@ def graph_explorer_page() -> str:
}
};
const startZoneDrag = (event, zoneId) => {
const zone = zoneSummaries.get(zoneId);
if (!cy || !zone || !(zone.nodes || []).length) return;
const container = ensureZoneContainer(zone);
if (!container) return;
event.preventDefault();
event.stopPropagation();
selected = null;
selectedZoneId = zone.id;
const handle = event.target.closest(".zone-label");
handle?.classList.add("dragging");
handle?.setPointerCapture?.(event.pointerId);
zoneDragState = {
zoneId: zone.id,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
moved: false,
handle,
container: {...container},
nodes: zone.nodes.map((node) => ({
node,
position: {...node.position()},
})),
};
renderZoneDetails(zone);
};
const moveZoneDrag = (event) => {
if (!zoneDragState || event.pointerId !== zoneDragState.pointerId || !cy) return;
event.preventDefault();
const zoom = cy.zoom() || 1;
const dx = (event.clientX - zoneDragState.startX) / zoom;
const dy = (event.clientY - zoneDragState.startY) / zoom;
if (Math.abs(event.clientX - zoneDragState.startX) + Math.abs(event.clientY - zoneDragState.startY) > 3) {
zoneDragState.moved = true;
suppressZoneClick = true;
}
zoneDragState.nodes.forEach(({node, position}) => {
if (!node || !node.length) return;
node.position({x: position.x + dx, y: position.y + dy});
});
const container = zoneContainerState.get(zoneDragState.zoneId);
if (container && zoneDragState.container) {
container.x = zoneDragState.container.x + dx;
container.y = zoneDragState.container.y + dy;
container.userPlaced = true;
}
updateSelectionAnchor();
scheduleZoneOverlayUpdate();
};
const finishZoneDrag = (event) => {
if (!zoneDragState || event.pointerId !== zoneDragState.pointerId) return;
zoneDragState.handle?.classList.remove("dragging");
zoneDragState.handle?.releasePointerCapture?.(event.pointerId);
const draggedZoneId = zoneDragState.zoneId;
const moved = zoneDragState.moved;
zoneDragState = null;
renderZoneOverlay();
if (moved) {
const zone = zoneSummaries.get(draggedZoneId);
if (zone) renderZoneDetails(zone);
updateProfileSummary(`Moved zone "${zone?.label || draggedZoneId}".`);
updateProfileControls();
updateUrlState();
window.setTimeout(() => { suppressZoneClick = false; }, 0);
}
};
const scheduleZoneOverlayUpdate = () => {
if (!zoneOverlay || zoneOverlayFrame) return;
zoneOverlayFrame = window.requestAnimationFrame(() => {
@@ -1557,20 +2028,43 @@ 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,
},
containers: serializedZoneContainers(),
};
};
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 +2092,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 +2117,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 +2134,35 @@ 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 containers = nested.containers && typeof nested.containers === "object"
? nested.containers
: state.zoneContainers;
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,
},
containers: normalizedZoneContainers(containers),
};
};
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 +2173,12 @@ 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;
zoneContainerState = zone.containers;
}
if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") {
manualOverrides = {...state.manualOverrides};
@@ -1665,6 +2193,7 @@ def graph_explorer_page() -> str:
activeLabelMode = labelSelect.value || "auto";
activeZoneGrouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment";
selectedZoneId = "";
collapsedZoneSnapshots = new Map();
focusSet = null;
syncFilterSummaries();
applyFilters();
@@ -1832,6 +2361,7 @@ def graph_explorer_page() -> str:
const applyFilters = (options = {}) => {
if (!cy) return;
removeZoneCollapseElements();
const previousRemoved = options.redrawOnRemove ? ruleRemovalSignature() : "";
syncFilterSummaries();
const hiddenNodes = new Set();
@@ -1851,6 +2381,7 @@ def graph_explorer_page() -> str:
if (state === "remove") removedNodes.add(node.id());
if (state === "hide") hiddenNodes.add(node.id());
});
applyZoneCollapseVisibility(hiddenNodes);
cy.edges().forEach((edge) => {
let state = matchesFilters(edge) ? "show" : "hide";
const ruleAction = ruleActionFor(edge);
@@ -1869,6 +2400,8 @@ def graph_explorer_page() -> str:
edge.toggleClass("rule-highlight", state === "highlight");
edge.style("display", state === "hide" || state === "remove" ? "none" : "element");
});
addZoneCollapseElements();
updateLabelVisibility();
const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none").length;
const visibleEdges = cy.edges().filter((edge) => edge.style("display") !== "none").length;
const removed = cy.elements().filter((element) => element.data("displayState") === "remove").length;
@@ -1915,6 +2448,10 @@ def graph_explorer_page() -> str:
["mapping", data.mappingFit],
["display only", data.displayOnly === true ? "yes" : ""],
["strength", data.strength],
["collapsed zone", data.collapsedZoneLabel],
["contained nodes", data.containedNodeCount],
["internal edges", data.containedInternalEdgeCount],
["boundary edges", data.boundaryEdgeCount],
["deployment environment", data.deploymentEnvironment],
["deployment scenario", data.deploymentScenario],
["routing authority", data.routingAuthority],
@@ -1939,9 +2476,24 @@ def graph_explorer_page() -> str:
const data = element.data();
const contextIds = new Set([element.id()]);
const rows = [];
const extraOrientationActions = [];
let title = "Graph context";
let profileName = `Fabric context: ${elementLabel(element)}`;
if (data.kind === "Repository" && data.lifecycle === "registered-only") {
if (data.kind === "Zone" && data.zoneCollapse === true) {
title = "Collapsed zone";
profileName = `Zone: ${data.collapsedZoneLabel || elementLabel(element)}`;
const neighborhood = element.neighborhood();
neighborhood.forEach((item) => addContextElement(contextIds, item));
rows.push(
{label: "zone", value: data.collapsedZoneLabel || data.id, state: "good"},
{label: "contained nodes", value: String(data.containedNodeCount || 0)},
{label: "internal edges", value: String(data.containedInternalEdgeCount || 0)},
{label: "boundary edges", value: String(data.boundaryEdgeCount || 0)}
);
extraOrientationActions.push(
`<button type="button" data-orientation-action="expand-zone" data-zone-id="${escapeHtml(data.collapsedZoneId)}">Expand Zone</button>`
);
} else if (data.kind === "Repository" && data.lifecycle === "registered-only") {
title = "Onboarding gap";
profileName = `Onboarding gap: ${data.repo || elementLabel(element)}`;
rows.push(
@@ -2098,6 +2650,7 @@ def graph_explorer_page() -> str:
`)
.join("");
orientationActions.innerHTML = `
${extraOrientationActions.join("")}
<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>
@@ -2175,6 +2728,8 @@ def graph_explorer_page() -> str:
}
: {name, padding: 48, animate: false};
layoutElements.layout(options).run();
applyZoneContainerLayout();
fitVisibleGraph();
updateSelectionAnchor();
scheduleZoneOverlayUpdate();
};
@@ -2258,6 +2813,20 @@ def graph_explorer_page() -> str:
{selector: "node[layer = 'binding']", style: {"shape": "rhomboid"}},
{selector: "node[unresolved = true]", style: {"border-color": "#b45309", "border-style": "dashed", "border-width": 3}},
{selector: "node[lifecycle = 'registered-only']", style: {"border-color": "#be123c", "border-style": "dashed", "border-width": 3}},
{selector: "node[zoneCollapse = true]", style: {
"shape": "round-rectangle",
"background-color": "#334155",
"border-color": "#0f766e",
"border-width": 3,
"border-style": "double",
"color": "#172033",
"font-weight": 700,
"text-background-color": "#ffffff",
"text-background-opacity": .86,
"text-background-padding": 3,
"width": "data(visualSize)",
"height": "data(visualSize)"
}},
{selector: "edge", style: {
"curve-style": "bezier",
"line-color": "#98a2b3",
@@ -2267,6 +2836,7 @@ def graph_explorer_page() -> str:
}},
{selector: "edge[strength = 'strong']", style: {"line-color": "#344054", "target-arrow-color": "#344054"}},
{selector: "edge[strength = 'weak']", style: {"line-style": "dotted"}},
{selector: "edge[zoneCollapse = true]", style: {"line-style": "dashed", "line-color": "#0f766e", "target-arrow-color": "#0f766e"}},
{selector: "node.rule-highlight", style: {
"border-color": "#2563eb",
"border-width": 3,
@@ -2296,7 +2866,12 @@ def graph_explorer_page() -> str:
renderRules();
cy.on("tap", "node, edge", (event) => showDetails(event.target));
cy.on("tap", (event) => { if (event.target === cy) showDetails(null); });
cy.on("pan zoom resize render layoutstop", () => {
cy.on("pan zoom resize render", () => {
updateSelectionAnchor();
scheduleZoneOverlayUpdate();
});
cy.on("layoutstop", () => {
applyZoneContainerLayout();
updateSelectionAnchor();
scheduleZoneOverlayUpdate();
});
@@ -2384,6 +2959,7 @@ def graph_explorer_page() -> str:
zoneGroupSelect.addEventListener("input", () => {
activeZoneGrouping = zoneGroupSelect.value || "deploymentEnvironment";
selectedZoneId = "";
collapsedZoneSnapshots = new Map();
currentProfileId = "";
profileSelect.value = "";
renderZoneOverlay();
@@ -2393,6 +2969,10 @@ def graph_explorer_page() -> str:
updateUrlState();
});
zoneOverlay.addEventListener("click", (event) => {
if (suppressZoneClick) {
suppressZoneClick = false;
return;
}
const button = event.target.closest("[data-zone-id]");
if (!button) return;
const zone = zoneSummaries.get(button.dataset.zoneId);
@@ -2401,6 +2981,14 @@ def graph_explorer_page() -> str:
selectedZoneId = zone.id;
renderZoneOverlay();
});
zoneOverlay.addEventListener("pointerdown", (event) => {
const handle = event.target.closest(".zone-label[data-zone-id]");
if (!handle || event.button !== 0) return;
startZoneDrag(event, handle.dataset.zoneId);
});
window.addEventListener("pointermove", moveZoneDrag);
window.addEventListener("pointerup", finishZoneDrag);
window.addEventListener("pointercancel", finishZoneDrag);
ruleTarget.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value}));
ruleType.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value, type: ruleType.value}));
ruleAttribute.addEventListener("input", () => refreshRuleBuilder({
@@ -2479,6 +3067,15 @@ def graph_explorer_page() -> str:
orientationActions.addEventListener("click", (event) => {
const button = event.target.closest("[data-orientation-action]");
if (!button) return;
if (button.dataset.orientationAction === "collapse-zone") {
const zone = zoneSummaries.get(button.dataset.zoneId || selectedZoneId);
if (zone) collapseZone(zone);
return;
}
if (button.dataset.orientationAction === "expand-zone") {
expandZone(button.dataset.zoneId || selected?.data("collapsedZoneId") || selectedZoneId);
return;
}
applyOrientationContext(button.dataset.orientationAction);
});
document.querySelector("[data-action='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48));
@@ -2492,7 +3089,9 @@ def graph_explorer_page() -> str:
zoneBoundaryToggle.checked = true;
zoneGroupSelect.value = "deploymentEnvironment";
activeZoneGrouping = "deploymentEnvironment";
activeZoneDefinitionSet = "fabric-default";
selectedZoneId = "";
collapsedZoneSnapshots = new Map();
setCheckedValues(nodeTypeFilter);
setCheckedValues(edgeTypeFilter);
reviewFilter.value = "";

View File

@@ -401,6 +401,8 @@ def resolve_zones(
internal_edge_ids_by_zone_id: dict[str, list[str]] = defaultdict(list)
boundary_edge_ids_by_zone_id: dict[str, list[str]] = defaultdict(list)
for edge in edge_records:
if _is_context_only_edge(edge):
continue
source_zone_id = assignments.get(edge.source).zone_id if edge.source in assignments else None
target_zone_id = assignments.get(edge.target).zone_id if edge.target in assignments else None
if source_zone_id and source_zone_id == target_zone_id:
@@ -484,6 +486,8 @@ def _attraction_candidates(
nodes_by_id = {node.id: node for node in node_records}
adjacency: dict[str, list[_EdgeRecord]] = defaultdict(list)
for edge in edge_records:
if _is_context_only_edge(edge):
continue
adjacency[edge.source].append(edge)
adjacency[edge.target].append(edge)
@@ -619,6 +623,14 @@ def _edge_matches_attraction_rule(edge: _EdgeRecord, rule: ZoneAttractionRule) -
return _rule_matches(edge.data, rule.edge_filter, empty_matches=True)
def _is_context_only_edge(edge: _EdgeRecord) -> bool:
return _trueish(edge.data.get("displayOnly", edge.data.get("display_only"))) or edge.edge_type == "declares"
def _trueish(value: Any) -> bool:
return value is True or str(value).lower() == "true"
def _neighbor_for_direction(node_id: str, edge: _EdgeRecord, direction: str) -> str:
if direction in {"out", "both"} and edge.source == node_id:
return edge.target

View File

@@ -422,6 +422,34 @@ 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 "isZoneContextOnlyEdge" in page
assert "isZoneConnectivityEdge" in page
assert "currentZoneViewState" in page
assert "normalizeZoneViewState" in page
assert "zoneDefinitionSet" in page
assert "zoneDefinitionSets" in page
assert "fabric-default" in page
assert "zoneContainerState" in page
assert "ensureZoneContainer" in page
assert "packZoneContainers" in page
assert "applyZoneContainerLayout" in page
assert "layoutZoneSubgraph" in page
assert "serializedZoneContainers" in page
assert "normalizedZoneContainers" in page
assert "collapsedZoneSnapshots" in page
assert "zoneDragState" in page
assert "startZoneDrag" in page
assert "moveZoneDrag" in page
assert "finishZoneDrag" in page
assert "Moved zone" in page
assert "cursor: grab" in page
assert "background: transparent" in page
assert "box-shadow: none" in page
assert "collapseZone" in page
assert "expandZone" in page
assert "zoneCollapseNodeId" in page
assert "Collapse Zone" in page
assert "Expand Zone" in page
assert 'normalize: "deploymentEnvironment"' in page
assert "zoneBoundsForNodes" in page
assert "renderZoneOverlay" in page

View File

@@ -96,6 +96,43 @@ def test_resolver_assigns_seed_nodes_and_boundary_edges() -> None:
}
def test_resolver_ignores_context_only_edges_for_boundaries_and_attraction() -> None:
resolution = resolve_zones(
nodes=[
_node("repo", kind="Repository"),
_node("svc.prod", deploymentEnvironment="prod"),
_node("svc.context", kind="service"),
],
edges=[
_edge("edge.repo-prod", "repo", "svc.prod", "declares", displayOnly=True),
_edge("edge.prod-context", "svc.prod", "svc.context", "declares", displayOnly=True),
],
zone_definitions=[
{
"id": "prod",
"label": "prod",
"membership": {"field": "deploymentEnvironment", "op": "equals", "value": "prod"},
"attraction": {
"rules": [
{
"edge_type": "*",
"direction": "both",
"depth": 1,
}
]
},
},
],
)
assert resolution.zone_by_id("prod").boundary_edge_ids == ()
assert resolution.zone_by_id("prod").internal_edge_ids == ()
assert "svc.context" not in resolution.node_assignments
assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" not in {
diagnostic.code for diagnostic in resolution.diagnostics
}
def test_resolver_attracts_nodes_by_edge_type_direction_and_depth() -> None:
resolution = resolve_zones(
nodes=[

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Promote graph zones to first-class visualization entities"
domain: railiance
repo: railiance-fabric
status: active
status: finished
owner: codex
topic_slug: railiance-fabric
created: "2026-05-24"
@@ -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,11 +173,18 @@ 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
id: RAIL-FAB-WP-0022-T06
status: todo
status: done
priority: medium
state_hub_task_id: "7f3676cb-3d2e-417c-a385-f95545bcd738"
```
@@ -196,11 +203,18 @@ The prototype should:
Expected result: collapse behavior works for one zone at a time and is covered
by focused tests. Multi-zone hierarchy can remain future work.
Result: Added a view-only zone collapse prototype to the graph explorer. Zone
detail panels now offer `Collapse Zone`; collapsed zones hide member nodes,
render a synthetic zone node with node/internal-edge/boundary-edge summaries,
draw synthetic boundary edges to visible external neighbors, and expose
`Expand Zone` from the collapsed zone node. Expanding removes synthetic elements
and restores the original graph view without changing the underlying payload.
## Task 7: Prepare For Per-Zone Layout
```task
id: RAIL-FAB-WP-0022-T07
status: todo
status: done
priority: low
state_hub_task_id: "4b6f0b7e-a066-490d-8160-ba23b03cf820"
```
@@ -215,6 +229,12 @@ a follow-up workplan for two-phase layout.
Expected result: the codebase has a documented path toward per-zone layouts
without destabilizing the current graph explorer.
Result: Documented the per-zone layout preparation path in
`docs/ZoneEntityVisualization.md`. The recommended direction is a two-phase
layout: resolve zones and containers first, then compute local zone coordinates
and project them into Cytoscape space. The note identifies the prerequisites
already established by WP-0022 and avoids a premature nested-layout rewrite.
## Acceptance Criteria
- Zone entity behavior is documented in `docs/ZoneEntityVisualization.md`.

View File

@@ -0,0 +1,110 @@
---
id: RAIL-FAB-WP-0023
type: workplan
title: "Improve zone labels and dragging"
domain: railiance
repo: railiance-fabric
status: active
owner: codex
topic_slug: railiance-fabric
created: "2026-05-25"
updated: "2026-05-25"
---
# Improve Zone Labels And Dragging
## Context
RAIL-FAB-WP-0021 introduced zone boundary rectangles and RAIL-FAB-WP-0022
promoted zones to first-class graph explorer view entities. The current zone
labels still look like small framed buttons with a white background. That makes
the overlay read more like controls than like labels printed on a drawing
surface.
The next interaction step is to let the operator grab a zone and move it around,
dragging all currently attached visible nodes with it. This makes zones behave
more like the intended "piece of paper" view entity while keeping the underlying
Fabric graph unchanged.
## Task 1: Simplify Zone Labels
```task
id: RAIL-FAB-WP-0023-T01
status: done
priority: high
```
Change zone labels from framed button badges to plain text in the upper-left
corner of the zone rectangle.
The label should:
- have no frame;
- have no white background;
- remain readable against the zone fill;
- still be keyboard-focusable/clickable enough to open zone details;
- avoid adding visual clutter to dense graph views.
Expected result: graph explorer zone labels look like text drawn on the zone
surface rather than separate UI controls.
Result: Updated graph explorer zone labels to render as transparent, unframed
text in the upper-left corner of each zone rectangle. Labels remain
keyboard-focusable and clickable, and the visual smoke screenshot confirms the
labels read as text on the zone surface.
## Task 2: Drag Zones With Member Nodes
```task
id: RAIL-FAB-WP-0023-T02
status: done
priority: high
```
Add a zone drag interaction that moves all currently attached visible member
nodes with the zone.
The interaction should:
- start from the zone label or zone boundary affordance;
- move assigned visible nodes by the drag delta;
- keep Cytoscape node positions and overlay bounds in sync;
- update zone details and labels during/after the drag;
- avoid mutating the underlying Fabric payload;
- not interfere with normal node dragging more than necessary.
Expected result: grabbing a zone lets the operator reposition the visible zone
subgraph as a unit.
Result: Added a view-only zone drag interaction using the plain zone label as
the grab handle. Dragging translates the currently assigned visible zone member
nodes by the pointer delta, recomputes overlay bounds, refreshes zone details,
and leaves the Fabric graph payload unchanged.
## Task 3: Verify Interaction Behavior
```task
id: RAIL-FAB-WP-0023-T03
status: blocked
priority: medium
```
Verify the visual and interaction behavior with focused tests and a browser
smoke check.
The verification should cover:
- static UI test assertions for the new label/drag helpers;
- JavaScript syntax validation;
- graph explorer focused tests;
- a visual smoke screenshot showing plain labels;
- manual or scripted confirmation that zone dragging moves member nodes.
Expected result: the UI renders clean labels and the zone drag interaction works
without breaking existing graph explorer behavior.
Result: Static UI assertions, JavaScript syntax validation, focused graph tests,
full test suite, Fabric CLI validation, and a headless Edge screenshot pass.
Automated drag smoke verification is blocked because Edge's remote debugging
endpoint was not reachable from WSL in this session. Manual verification is
needed by grabbing a zone label in the running graph explorer.

View File

@@ -0,0 +1,129 @@
---
id: RAIL-FAB-WP-0024
type: workplan
title: "Stabilize zone containers and layout zone subgraphs"
domain: railiance
repo: railiance-fabric
status: finished
owner: codex
topic_slug: railiance-fabric
created: "2026-05-25"
updated: "2026-05-25"
---
# Stabilize Zone Containers And Layout Zone Subgraphs
## Context
RAIL-FAB-WP-0022 made zones first-class visualization entities and
RAIL-FAB-WP-0023 added plain labels plus view-only zone dragging. The current
prototype still derives a zone rectangle directly from the current positions of
its member nodes. When the operator changes the layout algorithm or reruns
layout, the zone surface moves with the global graph layout instead of staying
where the operator placed it.
The current resolver also treats every edge attached to a zoned node as zone
context. That includes display-only repository declaration edges. Those edges
help explain where graph declarations came from, but they should not be read as
direct deployment or operational connections across a zone boundary.
This workplan moves the graph explorer toward the intended model: a zone is a
stable drawing surface, and the assigned subgraph is laid out inside that
surface as a separate view concern.
## Task 1: Separate Context Edges From Zone Boundary Connectivity
```task
id: RAIL-FAB-WP-0024-T01
status: done
priority: high
```
Exclude display-only/context edges, especially repository `declares` edges,
from zone boundary diagnostics and collapsed-zone boundary edges.
Expected result: zone detail may still mention contextual evidence when useful,
but display-only declaration edges do not make it look as if every zoned node is
directly connected to an unzoned repository.
Result: Display-only edges and repository `declares` edges are now treated as
context-only in both the shared zone resolver and the browser view. They do not
create boundary diagnostics, attraction paths, or collapsed-zone boundary
edges.
## Task 2: Persist Stable Zone Container Positions
```task
id: RAIL-FAB-WP-0024-T02
status: done
priority: high
```
Introduce a view-level zone container state keyed by stable zone id.
The implementation should:
- remember zone container center and size after layout and drag;
- keep user-moved zone positions stable when the global layout algorithm
changes;
- keep saved/copied view state compatible with the nested `zone` object;
- avoid mutating the Fabric graph payload.
Expected result: switching between layout algorithms keeps zone papers in the
same operator-chosen positions.
Result: The graph explorer now stores stable zone containers in view state.
Containers remember graph-coordinate position and size, survive global layout
reruns, can be saved/copied through the nested `zone` state, and remain separate
from Fabric graph payload data.
## Task 3: Layout Zone Subgraphs Inside Containers
```task
id: RAIL-FAB-WP-0024-T03
status: done
priority: high
```
Add a per-zone layout pass that positions assigned visible nodes inside their
zone container after the global layout places the surrounding graph.
The first implementation may use a deterministic compact layout rather than a
full nested Cytoscape layout, as long as it:
- only moves visible nodes assigned to the zone;
- keeps each node inside the zone's drawing surface;
- treats unzoned nodes as part of the base canvas;
- keeps edge routing intact through Cytoscape's normal renderer.
Expected result: the visible subgraph inside each zone is arranged by the zone
view model, not merely enclosed after the global layout.
Result: After global layout, each visible zone projects its assigned nodes into
a local compact layout inside its stable container. New, not-yet-moved zones are
packed into readable rows so the default deployment-environment papers start as
separate drawing surfaces.
## Task 4: Verify And Document Zone Layout Semantics
```task
id: RAIL-FAB-WP-0024-T04
status: done
priority: medium
```
Update the graph explorer contract and run focused validation.
Verification should cover:
- static UI assertions for context-edge filtering and zone container helpers;
- JavaScript syntax validation;
- focused graph explorer and zone resolver tests;
- a visual smoke check of the graph explorer.
Expected result: documentation and tests describe the new semantics, and the
running UI shows stable zone containers with locally arranged member nodes.
Result: Updated the graph explorer contract and zone entity documentation.
Focused tests, generated JavaScript syntax validation, full tests, CLI
validation, and a headless Edge visual smoke check all passed.