generated from coulomb/repo-seed
feat: stabilize graph zone containers
This commit is contained in:
@@ -203,6 +203,31 @@ 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
|
||||
|
||||
@@ -192,13 +192,27 @@ 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`,
|
||||
and `presentation`. 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 and
|
||||
presentation preferences can be restored without another state migration.
|
||||
`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
|
||||
|
||||
@@ -619,6 +619,7 @@ 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();
|
||||
@@ -1102,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: [],
|
||||
@@ -1225,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
|
||||
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,
|
||||
@@ -1243,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;
|
||||
@@ -1327,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()
|
||||
@@ -1439,7 +1670,7 @@ def graph_explorer_page() -> str:
|
||||
const boundaryEdges = [];
|
||||
let internalEdgeCount = 0;
|
||||
(zone.elements || [])
|
||||
.filter((element) => element.isEdge && element.isEdge())
|
||||
.filter(isZoneConnectivityEdge)
|
||||
.forEach((edge) => {
|
||||
const source = edge.data("source");
|
||||
const target = edge.data("target");
|
||||
@@ -1574,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" : "";
|
||||
@@ -1600,6 +1833,8 @@ 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;
|
||||
@@ -1614,6 +1849,7 @@ def graph_explorer_page() -> str:
|
||||
startY: event.clientY,
|
||||
moved: false,
|
||||
handle,
|
||||
container: {...container},
|
||||
nodes: zone.nodes.map((node) => ({
|
||||
node,
|
||||
position: {...node.position()},
|
||||
@@ -1636,6 +1872,12 @@ def graph_explorer_page() -> str:
|
||||
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();
|
||||
};
|
||||
@@ -1652,6 +1894,8 @@ def graph_explorer_page() -> str:
|
||||
const zone = zoneSummaries.get(draggedZoneId);
|
||||
if (zone) renderZoneDetails(zone);
|
||||
updateProfileSummary(`Moved zone "${zone?.label || draggedZoneId}".`);
|
||||
updateProfileControls();
|
||||
updateUrlState();
|
||||
window.setTimeout(() => { suppressZoneClick = false; }, 0);
|
||||
}
|
||||
};
|
||||
@@ -1798,6 +2042,7 @@ def graph_explorer_page() -> str:
|
||||
boundaries: visible,
|
||||
labels: true,
|
||||
},
|
||||
containers: serializedZoneContainers(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1894,6 +2139,9 @@ def graph_explorer_page() -> str:
|
||||
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
|
||||
@@ -1911,6 +2159,7 @@ def graph_explorer_page() -> str:
|
||||
boundaries: "boundaries" in presentation ? presentation.boundaries !== false : visible,
|
||||
labels: "labels" in presentation ? presentation.labels !== false : true,
|
||||
},
|
||||
containers: normalizedZoneContainers(containers),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1929,6 +2178,7 @@ def graph_explorer_page() -> str:
|
||||
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};
|
||||
@@ -2478,6 +2728,8 @@ def graph_explorer_page() -> str:
|
||||
}
|
||||
: {name, padding: 48, animate: false};
|
||||
layoutElements.layout(options).run();
|
||||
applyZoneContainerLayout();
|
||||
fitVisibleGraph();
|
||||
updateSelectionAnchor();
|
||||
scheduleZoneOverlayUpdate();
|
||||
};
|
||||
@@ -2614,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();
|
||||
});
|
||||
|
||||
@@ -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,11 +422,20 @@ 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
|
||||
|
||||
@@ -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=[
|
||||
|
||||
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