feat: stabilize graph zone containers

This commit is contained in:
2026-05-25 02:08:45 +02:00
parent 0f7b7d1fed
commit 558e0dc157
7 changed files with 496 additions and 13 deletions

View File

@@ -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
.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,
@@ -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();
});

View File

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