generated from coulomb/repo-seed
Compare commits
5 Commits
1ad432270b
...
558e0dc157
| Author | SHA1 | Date | |
|---|---|---|---|
| 558e0dc157 | |||
| 0f7b7d1fed | |||
| 9b612447ca | |||
| a7a22a673f | |||
| 296ac051a7 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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("&", "&").replaceAll("<", "<")
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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`.
|
||||
|
||||
110
workplans/RAIL-FAB-WP-0023-zone-drag-interaction.md
Normal file
110
workplans/RAIL-FAB-WP-0023-zone-drag-interaction.md
Normal 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.
|
||||
129
workplans/RAIL-FAB-WP-0024-stable-zone-containers-and-layout.md
Normal file
129
workplans/RAIL-FAB-WP-0024-stable-zone-containers-and-layout.md
Normal 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.
|
||||
Reference in New Issue
Block a user