generated from coulomb/repo-seed
feat: stabilize graph zone containers
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user