@ankhorage/zora 21.0.8 → 21.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.d.ts +4 -5
- package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.d.ts.map +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.js +171 -85
- package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.js.map +1 -1
- package/package.json +1 -1
- package/src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.test.ts +60 -25
- package/src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.ts +243 -93
- package/web-dist/components/graph-view/GraphView.js +189 -72
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
# @ankhorage/zora
|
|
5
5
|
|
|
6
|
-
         
|
|
7
7
|
|
|
8
8
|
Opinionated React Native and React Native Web UI kit built on @ankhorage/surface.
|
|
9
9
|
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { Core } from 'cytoscape';
|
|
2
2
|
/***
|
|
3
|
-
* Optimize settled layout spacing
|
|
4
|
-
* Compound containment
|
|
5
|
-
* @performance
|
|
6
|
-
*
|
|
7
|
-
* non-uniformly overlapping views retain their original positions.
|
|
3
|
+
* Optimize settled layout spacing around readable label bounds instead of full node clearance.
|
|
4
|
+
* Compound containment stays intentional while sibling groups retain non-overlapping boundaries.
|
|
5
|
+
* @performance Cytoscape geometry is measured once, then bounded pure numeric trials reuse the
|
|
6
|
+
* immutable snapshot. No layout reruns, zoom work, or intermediate frames are painted.
|
|
8
7
|
*/
|
|
9
8
|
export declare function compactGraphSpacing(cy: Core, spacingFactor: number): number;
|
|
10
9
|
//# sourceMappingURL=compactGraphSpacing.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compactGraphSpacing.d.ts","sourceRoot":"","sources":["../../../../../../src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"compactGraphSpacing.d.ts","sourceRoot":"","sources":["../../../../../../src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAgB,MAAM,WAAW,CAAC;AAQpD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,CAiC3E"}
|
|
@@ -1,68 +1,185 @@
|
|
|
1
|
+
import { findMinimumAcceptedNumber } from '@ankhorage/utility/algorithms';
|
|
2
|
+
const LABEL_OVERLAP_RATIO = 0.12;
|
|
3
|
+
const MAX_EXPANSION_ATTEMPTS = 8;
|
|
4
|
+
const MAX_NODE_COUNT = 2000;
|
|
5
|
+
const MAX_PAIR_CHECKS = 200000;
|
|
6
|
+
const SEARCH_ITERATIONS = 8;
|
|
1
7
|
/***
|
|
2
|
-
* Optimize settled layout spacing
|
|
3
|
-
* Compound containment
|
|
4
|
-
* @performance
|
|
5
|
-
*
|
|
6
|
-
* non-uniformly overlapping views retain their original positions.
|
|
8
|
+
* Optimize settled layout spacing around readable label bounds instead of full node clearance.
|
|
9
|
+
* Compound containment stays intentional while sibling groups retain non-overlapping boundaries.
|
|
10
|
+
* @performance Cytoscape geometry is measured once, then bounded pure numeric trials reuse the
|
|
11
|
+
* immutable snapshot. No layout reruns, zoom work, or intermediate frames are painted.
|
|
7
12
|
*/
|
|
8
13
|
export function compactGraphSpacing(cy, spacingFactor) {
|
|
9
14
|
const leaves = cy.nodes(':childless');
|
|
10
|
-
if (leaves.length < 2 || cy.nodes().length >
|
|
15
|
+
if (leaves.length < 2 || cy.nodes().length > MAX_NODE_COUNT || cy.nodes(':locked').length > 0)
|
|
11
16
|
return spacingFactor;
|
|
12
|
-
const ancestors = new Map(cy.nodes().map((node) => [node.id(), new Set(node.ancestors().map((parent) => parent.id()))]));
|
|
13
|
-
const positions = new Map(leaves.map((node) => [node.id(), { ...node.position() }]));
|
|
14
17
|
const bounds = leaves.boundingBox();
|
|
15
18
|
const center = { x: (bounds.x1 + bounds.x2) / 2, y: (bounds.y1 + bounds.y2) / 2 };
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
const leafGeometry = leaves.map(readLeafGeometry);
|
|
20
|
+
const groups = cy.nodes(':parent').map((node) => readGroupGeometry(node.id(), node.ancestors().map((parent) => parent.id()), node.boundingBox(), leafGeometry));
|
|
21
|
+
const accepts = (factor) => !hasUnsafeCollisions(resolveCollisionBoxes(leafGeometry, groups, center, factor));
|
|
22
|
+
const currentAccepted = accepts(1);
|
|
23
|
+
const minimum = currentAccepted ? Math.min(1, 0.1 / spacingFactor) : 1;
|
|
24
|
+
const maximum = currentAccepted ? 1 : findExpansionMaximum(accepts);
|
|
25
|
+
if (maximum === undefined)
|
|
26
|
+
return spacingFactor;
|
|
27
|
+
const factor = findMinimumAcceptedNumber({
|
|
28
|
+
minimum,
|
|
29
|
+
maximum,
|
|
30
|
+
iterations: SEARCH_ITERATIONS,
|
|
31
|
+
accepts,
|
|
32
|
+
});
|
|
33
|
+
if (factor === undefined)
|
|
34
|
+
return spacingFactor;
|
|
35
|
+
applyScale(cy, leafGeometry, center, factor);
|
|
36
|
+
return spacingFactor * factor;
|
|
19
37
|
}
|
|
20
|
-
/***
|
|
21
|
-
function
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return
|
|
38
|
+
/*** Read one leaf's immutable position, hierarchy and renderer-measured label geometry. */
|
|
39
|
+
function readLeafGeometry(node) {
|
|
40
|
+
const nodeBox = node.boundingBox({
|
|
41
|
+
includeLabels: true,
|
|
42
|
+
includeOverlays: false,
|
|
43
|
+
includeUnderlays: false,
|
|
44
|
+
});
|
|
45
|
+
const labelBox = node.boundingBox({
|
|
46
|
+
includeNodes: false,
|
|
47
|
+
includeEdges: false,
|
|
48
|
+
includeLabels: true,
|
|
49
|
+
includeOverlays: false,
|
|
50
|
+
includeUnderlays: false,
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
id: node.id(),
|
|
54
|
+
ancestors: new Set(node.ancestors().map((parent) => parent.id())),
|
|
55
|
+
position: { ...node.position() },
|
|
56
|
+
labelBox: isUsableBox(labelBox) ? labelBox : nodeBox,
|
|
57
|
+
nodeBox,
|
|
58
|
+
};
|
|
35
59
|
}
|
|
36
|
-
/***
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
/*** Capture one compound group's descendant geometry and constant renderer-owned outer insets. */
|
|
61
|
+
function readGroupGeometry(id, ancestorIds, parentBox, leaves) {
|
|
62
|
+
const descendants = leaves.filter((leaf) => leaf.ancestors.has(id));
|
|
63
|
+
const descendantUnion = unionBounds(descendants.map((leaf) => leaf.nodeBox)) ?? parentBox;
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
ancestors: new Set(ancestorIds),
|
|
67
|
+
descendants,
|
|
68
|
+
insets: {
|
|
69
|
+
left: Math.max(0, descendantUnion.x1 - parentBox.x1),
|
|
70
|
+
right: Math.max(0, parentBox.x2 - descendantUnion.x2),
|
|
71
|
+
top: Math.max(0, descendantUnion.y1 - parentBox.y1),
|
|
72
|
+
bottom: Math.max(0, parentBox.y2 - descendantUnion.y2),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/*** Resolve immutable label and compound boxes for one candidate uniform spacing factor. */
|
|
77
|
+
function resolveCollisionBoxes(leaves, groups, center, factor) {
|
|
78
|
+
const labels = leaves.map((leaf) => ({
|
|
79
|
+
id: leaf.id,
|
|
80
|
+
ancestors: leaf.ancestors,
|
|
81
|
+
kind: 'label',
|
|
82
|
+
box: shiftBounds(leaf.labelBox, leaf.position, center, factor),
|
|
83
|
+
}));
|
|
84
|
+
const compoundBoxes = groups.flatMap((group) => {
|
|
85
|
+
const descendants = group.descendants.map((leaf) => shiftBounds(leaf.nodeBox, leaf.position, center, factor));
|
|
86
|
+
const union = unionBounds(descendants);
|
|
87
|
+
if (!union)
|
|
88
|
+
return [];
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
id: group.id,
|
|
92
|
+
ancestors: group.ancestors,
|
|
93
|
+
kind: 'group',
|
|
94
|
+
box: expandBounds(union, group.insets),
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
});
|
|
98
|
+
return [...labels, ...compoundBoxes];
|
|
99
|
+
}
|
|
100
|
+
/*** Find the first bounded expansion factor that makes the pure geometry snapshot acceptable. */
|
|
101
|
+
function findExpansionMaximum(accepts) {
|
|
102
|
+
return Array.from({ length: MAX_EXPANSION_ATTEMPTS }, (_, index) => 2 ** (index + 1)).find(accepts);
|
|
103
|
+
}
|
|
104
|
+
/***
|
|
105
|
+
* Sweep resolved boxes by x extent and stop conservatively if the pair-check budget is exhausted.
|
|
106
|
+
* Local loop counters are intentionally mutable on this performance-critical boundary.
|
|
107
|
+
*/
|
|
108
|
+
function hasUnsafeCollisions(boxes) {
|
|
109
|
+
const sorted = [...boxes].sort((first, second) => first.box.x1 - second.box.x1);
|
|
110
|
+
const budget = { remaining: MAX_PAIR_CHECKS };
|
|
111
|
+
for (const [index, first] of sorted.entries()) {
|
|
112
|
+
for (let next = index + 1; next < sorted.length; next += 1) {
|
|
113
|
+
const second = sorted.at(next);
|
|
114
|
+
if (!second)
|
|
115
|
+
break;
|
|
116
|
+
if (second.box.x1 >= first.box.x2)
|
|
117
|
+
break;
|
|
118
|
+
budget.remaining -= 1;
|
|
119
|
+
if (budget.remaining < 0)
|
|
120
|
+
return true;
|
|
121
|
+
if (isIntentionalContainment(first, second))
|
|
122
|
+
continue;
|
|
123
|
+
if (isUnsafeOverlap(first, second))
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
60
126
|
}
|
|
61
|
-
|
|
62
|
-
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
/*** Ignore intentional compound containment while retaining sibling and unrelated separation. */
|
|
130
|
+
function isIntentionalContainment(first, second) {
|
|
131
|
+
return first.ancestors.has(second.id) || second.ancestors.has(first.id);
|
|
132
|
+
}
|
|
133
|
+
/*** Permit only a small bounded label-label overlap; every other intersection remains unsafe. */
|
|
134
|
+
function isUnsafeOverlap(first, second) {
|
|
135
|
+
const overlapWidth = Math.min(first.box.x2, second.box.x2) - Math.max(first.box.x1, second.box.x1);
|
|
136
|
+
const overlapHeight = Math.min(first.box.y2, second.box.y2) - Math.max(first.box.y1, second.box.y1);
|
|
137
|
+
if (overlapWidth <= 0 || overlapHeight <= 0)
|
|
138
|
+
return false;
|
|
139
|
+
if (first.kind !== 'label' || second.kind !== 'label')
|
|
140
|
+
return true;
|
|
141
|
+
const widthRatio = overlapWidth / Math.min(first.box.w, second.box.w);
|
|
142
|
+
const heightRatio = overlapHeight / Math.min(first.box.h, second.box.h);
|
|
143
|
+
return widthRatio > LABEL_OVERLAP_RATIO && heightRatio > LABEL_OVERLAP_RATIO;
|
|
63
144
|
}
|
|
64
|
-
/***
|
|
65
|
-
function
|
|
145
|
+
/*** Shift one fixed renderer box with its leaf center under uniform radial scaling. */
|
|
146
|
+
function shiftBounds(box, position, center, factor) {
|
|
147
|
+
const dx = (position.x - center.x) * (factor - 1);
|
|
148
|
+
const dy = (position.y - center.y) * (factor - 1);
|
|
149
|
+
return {
|
|
150
|
+
x1: box.x1 + dx,
|
|
151
|
+
y1: box.y1 + dy,
|
|
152
|
+
x2: box.x2 + dx,
|
|
153
|
+
y2: box.y2 + dy,
|
|
154
|
+
w: box.w,
|
|
155
|
+
h: box.h,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/*** Expand a descendant union by the compound renderer's measured outer insets. */
|
|
159
|
+
function expandBounds(box, insets) {
|
|
160
|
+
const x1 = box.x1 - insets.left;
|
|
161
|
+
const y1 = box.y1 - insets.top;
|
|
162
|
+
const x2 = box.x2 + insets.right;
|
|
163
|
+
const y2 = box.y2 + insets.bottom;
|
|
164
|
+
return { x1, y1, x2, y2, w: x2 - x1, h: y2 - y1 };
|
|
165
|
+
}
|
|
166
|
+
/*** Return the immutable union of measured boxes, or undefined for an empty collection. */
|
|
167
|
+
function unionBounds(boxes) {
|
|
168
|
+
if (boxes.length === 0)
|
|
169
|
+
return undefined;
|
|
170
|
+
const x1 = Math.min(...boxes.map((box) => box.x1));
|
|
171
|
+
const y1 = Math.min(...boxes.map((box) => box.y1));
|
|
172
|
+
const x2 = Math.max(...boxes.map((box) => box.x2));
|
|
173
|
+
const y2 = Math.max(...boxes.map((box) => box.y2));
|
|
174
|
+
return { x1, y1, x2, y2, w: x2 - x1, h: y2 - y1 };
|
|
175
|
+
}
|
|
176
|
+
/*** Return whether renderer measurement produced a non-empty label box. */
|
|
177
|
+
function isUsableBox(box) {
|
|
178
|
+
return box.w > 0 && box.h > 0;
|
|
179
|
+
}
|
|
180
|
+
/*** Apply the accepted factor once, preserving node dimensions, hierarchy, ordering and edge data. */
|
|
181
|
+
function applyScale(cy, leaves, center, factor) {
|
|
182
|
+
const positions = new Map(leaves.map((leaf) => [leaf.id, leaf.position]));
|
|
66
183
|
cy.batch(() => {
|
|
67
184
|
cy.nodes()
|
|
68
185
|
.not(':parent')
|
|
@@ -75,35 +192,4 @@ function applyScale(cy, positions, center, factor) {
|
|
|
75
192
|
});
|
|
76
193
|
});
|
|
77
194
|
}
|
|
78
|
-
/*** Sweep measured bounds, ignoring intentional containment and stopping conservatively at budget. */
|
|
79
|
-
function hasCollisions(cy, ancestors) {
|
|
80
|
-
const boxes = cy
|
|
81
|
-
.nodes()
|
|
82
|
-
.map((node) => ({
|
|
83
|
-
id: node.id(),
|
|
84
|
-
box: node.boundingBox({
|
|
85
|
-
includeLabels: true,
|
|
86
|
-
includeOverlays: false,
|
|
87
|
-
includeUnderlays: false,
|
|
88
|
-
}),
|
|
89
|
-
}))
|
|
90
|
-
.sort((a, b) => a.box.x1 - b.box.x1);
|
|
91
|
-
const budget = { remaining: 200000 };
|
|
92
|
-
for (const [index, first] of boxes.entries()) {
|
|
93
|
-
for (let next = index + 1; next < boxes.length; next += 1) {
|
|
94
|
-
const second = boxes.at(next);
|
|
95
|
-
if (!second)
|
|
96
|
-
break;
|
|
97
|
-
if (second.box.x1 >= first.box.x2 + 8)
|
|
98
|
-
break;
|
|
99
|
-
if (--budget.remaining < 0)
|
|
100
|
-
return true;
|
|
101
|
-
if (ancestors.get(first.id)?.has(second.id) || ancestors.get(second.id)?.has(first.id))
|
|
102
|
-
continue;
|
|
103
|
-
if (first.box.y1 < second.box.y2 + 8 && second.box.y1 < first.box.y2 + 8)
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return false;
|
|
108
|
-
}
|
|
109
195
|
//# sourceMappingURL=compactGraphSpacing.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compactGraphSpacing.js","sourceRoot":"","sources":["../../../../../../src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Core } from 'cytoscape';\n\n/***\n * Optimize settled layout spacing uniformly until measured node and label bounds clear.\n * Compound containment is intentional; sibling and unrelated compound intersections are not.\n * @performance At most eight synchronous trials and 200,000 pair checks per trial. No layout\n * algorithm reruns, no work on zoom, and no intermediate frame is painted. Large, locked, or\n * non-uniformly overlapping views retain their original positions.\n */\nexport function compactGraphSpacing(cy: Core, spacingFactor: number): number {\n const leaves = cy.nodes(':childless');\n if (leaves.length < 2 || cy.nodes().length > 2000 || leaves.filter(':locked').length > 0)\n return spacingFactor;\n const ancestors = new Map(\n cy.nodes().map((node) => [node.id(), new Set(node.ancestors().map((parent) => parent.id()))]),\n );\n const positions = new Map(leaves.map((node) => [node.id(), { ...node.position() }]));\n const bounds = leaves.boundingBox();\n const center = { x: (bounds.x1 + bounds.x2) / 2, y: (bounds.y1 + bounds.y2) / 2 };\n if (hasCollisions(cy, ancestors))\n return expandGraphSpacing(cy, ancestors, center, positions, spacingFactor);\n return compressGraphSpacing(cy, ancestors, center, positions, spacingFactor);\n}\n\n/*** Compress a collision-free settled layout to its smallest measured clearance. */\nfunction compressGraphSpacing(\n cy: Core,\n ancestors: ReadonlyMap<string, ReadonlySet<string>>,\n center: { x: number; y: number },\n positions: ReadonlyMap<string, { x: number; y: number }>,\n spacingFactor: number,\n): number {\n const search = { low: Math.min(1, 0.1 / spacingFactor), high: 1 };\n for (const attempt of Array.from({ length: 8 }, (_, index) => index)) {\n const factor = attempt === 0 ? search.low : (search.low + search.high) / 2;\n applyScale(cy, positions, center, factor);\n if (hasCollisions(cy, ancestors)) search.low = factor;\n else search.high = factor;\n if (search.high === search.low) break;\n }\n applyScale(cy, positions, center, search.high);\n return spacingFactor * search.high;\n}\n\n/*** Expand a uniformly cramped layout until measured node and label bounds clear. */\nfunction expandGraphSpacing(\n cy: Core,\n ancestors: ReadonlyMap<string, ReadonlySet<string>>,\n center: { x: number; y: number },\n positions: ReadonlyMap<string, { x: number; y: number }>,\n spacingFactor: number,\n): number {\n let low = 1;\n let high = 1;\n let attempts = 0;\n while (attempts < 8) {\n high *= 2;\n attempts += 1;\n applyScale(cy, positions, center, high);\n if (!hasCollisions(cy, ancestors)) break;\n low = high;\n }\n if (hasCollisions(cy, ancestors)) {\n applyScale(cy, positions, center, 1);\n return spacingFactor;\n }\n for (const _attempt of Array.from({ length: 8 - attempts }, (_, index) => index)) {\n const factor = (low + high) / 2;\n applyScale(cy, positions, center, factor);\n if (hasCollisions(cy, ancestors)) low = factor;\n else high = factor;\n }\n applyScale(cy, positions, center, high);\n return spacingFactor * high;\n}\n\n/*** Transform leaf centers, preserving ordering, dimensions, compound membership and edge data. */\nfunction applyScale(\n cy: Core,\n positions: ReadonlyMap<string, { x: number; y: number }>,\n center: { x: number; y: number },\n factor: number,\n) {\n cy.batch(() => {\n cy.nodes()\n .not(':parent')\n .positions((node) => {\n const original = positions.get(node.id()) ?? node.position();\n return {\n x: center.x + (original.x - center.x) * factor,\n y: center.y + (original.y - center.y) * factor,\n };\n });\n });\n}\n\n/*** Sweep measured bounds, ignoring intentional containment and stopping conservatively at budget. */\nfunction hasCollisions(cy: Core, ancestors: ReadonlyMap<string, ReadonlySet<string>>): boolean {\n const boxes = cy\n .nodes()\n .map((node) => ({\n id: node.id(),\n box: node.boundingBox({\n includeLabels: true,\n includeOverlays: false,\n includeUnderlays: false,\n }),\n }))\n .sort((a, b) => a.box.x1 - b.box.x1);\n const budget = { remaining: 200000 };\n for (const [index, first] of boxes.entries()) {\n for (let next = index + 1; next < boxes.length; next += 1) {\n const second = boxes.at(next);\n if (!second) break;\n if (second.box.x1 >= first.box.x2 + 8) break;\n if (--budget.remaining < 0) return true;\n if (ancestors.get(first.id)?.has(second.id) || ancestors.get(second.id)?.has(first.id))\n continue;\n if (first.box.y1 < second.box.y2 + 8 && second.box.y1 < first.box.y2 + 8) return true;\n }\n }\n return false;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"compactGraphSpacing.js","sourceRoot":"","sources":["../../../../../../src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAC;AAG1E,MAAM,mBAAmB,GAAG,IAAI,CAAC;AACjC,MAAM,sBAAsB,GAAG,CAAC,CAAC;AACjC,MAAM,cAAc,GAAG,IAAI,CAAC;AAC5B,MAAM,eAAe,GAAG,MAAM,CAAC;AAC/B,MAAM,iBAAiB,GAAG,CAAC,CAAC;AAE5B;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CAAC,EAAQ,EAAE,aAAqB;IACjE,MAAM,MAAM,GAAG,EAAE,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;IACtC,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,EAAE,CAAC,MAAM,GAAG,cAAc,IAAI,EAAE,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,MAAM,GAAG,CAAC;QAC3F,OAAO,aAAa,CAAC;IAEvB,MAAM,MAAM,GAAG,MAAM,CAAC,WAAW,EAAE,CAAC;IACpC,MAAM,MAAM,GAAG,EAAE,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,GAAG,MAAM,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,GAAG,MAAM,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC;IAClF,MAAM,YAAY,GAAG,MAAM,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IAClD,MAAM,MAAM,GAAG,EAAE,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAC9C,iBAAiB,CACf,IAAI,CAAC,EAAE,EAAE,EACT,IAAI,CAAC,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC,EAC7C,IAAI,CAAC,WAAW,EAAE,EAClB,YAAY,CACb,CACF,CAAC;IACF,MAAM,OAAO,GAAG,CAAC,MAAc,EAAE,EAAE,CACjC,CAAC,mBAAmB,CAAC,qBAAqB,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpF,MAAM,eAAe,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACnC,MAAM,OAAO,GAAG,eAAe,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,GAAG,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACvE,MAAM,OAAO,GAAG,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,oBAAoB,CAAC,OAAO,CAAC,CAAC;IACpE,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,aAAa,CAAC;IAEhD,MAAM,MAAM,GAAG,yBAAyB,CAAC;QACvC,OAAO;QACP,OAAO;QACP,UAAU,EAAE,iBAAiB;QAC7B,OAAO;KACR,CAAC,CAAC;IACH,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,aAAa,CAAC;IAE/C,UAAU,CAAC,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7C,OAAO,aAAa,GAAG,MAAM,CAAC;AAChC,CAAC;AA6CD,2FAA2F;AAC3F,SAAS,gBAAgB,CAAC,IAAkB;IAC1C,MAAM,OAAO,GAAG,IAAI,CAAC,WAAW,CAAC;QAC/B,aAAa,EAAE,IAAI;QACnB,eAAe,EAAE,KAAK;QACtB,gBAAgB,EAAE,KAAK;KACxB,CAAC,CAAC;IACH,MAAM,QAAQ,GAAG,IAAI,CAAC,WAAW,CAAC;QAChC,YAAY,EAAE,KAAK;QACnB,YAAY,EAAE,KAAK;QACnB,aAAa,EAAE,IAAI;QACnB,eAAe,EAAE,KAAK;QACtB,gBAAgB,EAAE,KAAK;KACxB,CAAC,CAAC;IACH,OAAO;QACL,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE;QACb,SAAS,EAAE,IAAI,GAAG,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC;QACjE,QAAQ,EAAE,EAAE,GAAG,IAAI,CAAC,QAAQ,EAAE,EAAE;QAChC,QAAQ,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO;QACpD,OAAO;KACR,CAAC;AACJ,CAAC;AAED,kGAAkG;AAClG,SAAS,iBAAiB,CACxB,EAAU,EACV,WAA8B,EAC9B,SAAiB,EACjB,MAA+B;IAE/B,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IACpE,MAAM,eAAe,GAAG,WAAW,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,IAAI,SAAS,CAAC;IAC1F,OAAO;QACL,EAAE;QACF,SAAS,EAAE,IAAI,GAAG,CAAC,WAAW,CAAC;QAC/B,WAAW;QACX,MAAM,EAAE;YACN,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,eAAe,CAAC,EAAE,GAAG,SAAS,CAAC,EAAE,CAAC;YACpD,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,SAAS,CAAC,EAAE,GAAG,eAAe,CAAC,EAAE,CAAC;YACrD,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,eAAe,CAAC,EAAE,GAAG,SAAS,CAAC,EAAE,CAAC;YACnD,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,SAAS,CAAC,EAAE,GAAG,eAAe,CAAC,EAAE,CAAC;SACvD;KACF,CAAC;AACJ,CAAC;AAED,4FAA4F;AAC5F,SAAS,qBAAqB,CAC5B,MAA+B,EAC/B,MAAgC,EAChC,MAAa,EACb,MAAc;IAEd,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QACnC,EAAE,EAAE,IAAI,CAAC,EAAE;QACX,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,IAAI,EAAE,OAAgB;QACtB,GAAG,EAAE,WAAW,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC;KAC/D,CAAC,CAAC,CAAC;IACJ,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,EAAE;QAC7C,MAAM,WAAW,GAAG,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CACjD,WAAW,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,CACzD,CAAC;QACF,MAAM,KAAK,GAAG,WAAW,CAAC,WAAW,CAAC,CAAC;QACvC,IAAI,CAAC,KAAK;YAAE,OAAO,EAAE,CAAC;QACtB,OAAO;YACL;gBACE,EAAE,EAAE,KAAK,CAAC,EAAE;gBACZ,SAAS,EAAE,KAAK,CAAC,SAAS;gBAC1B,IAAI,EAAE,OAAgB;gBACtB,GAAG,EAAE,YAAY,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC;aACvC;SACF,CAAC;IACJ,CAAC,CAAC,CAAC;IACH,OAAO,CAAC,GAAG,MAAM,EAAE,GAAG,aAAa,CAAC,CAAC;AACvC,CAAC;AAED,iGAAiG;AACjG,SAAS,oBAAoB,CAAC,OAAoC;IAChE,OAAO,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,sBAAsB,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CACxF,OAAO,CACR,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,SAAS,mBAAmB,CAAC,KAA8B;IACzD,MAAM,MAAM,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,GAAG,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAChF,MAAM,MAAM,GAAG,EAAE,SAAS,EAAE,eAAe,EAAE,CAAC;IAC9C,KAAK,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,EAAE,EAAE,CAAC;QAC9C,KAAK,IAAI,IAAI,GAAG,KAAK,GAAG,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,IAAI,IAAI,CAAC,EAAE,CAAC;YAC3D,MAAM,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;YAC/B,IAAI,CAAC,MAAM;gBAAE,MAAM;YACnB,IAAI,MAAM,CAAC,GAAG,CAAC,EAAE,IAAI,KAAK,CAAC,GAAG,CAAC,EAAE;gBAAE,MAAM;YACzC,MAAM,CAAC,SAAS,IAAI,CAAC,CAAC;YACtB,IAAI,MAAM,CAAC,SAAS,GAAG,CAAC;gBAAE,OAAO,IAAI,CAAC;YACtC,IAAI,wBAAwB,CAAC,KAAK,EAAE,MAAM,CAAC;gBAAE,SAAS;YACtD,IAAI,eAAe,CAAC,KAAK,EAAE,MAAM,CAAC;gBAAE,OAAO,IAAI,CAAC;QAClD,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,iGAAiG;AACjG,SAAS,wBAAwB,CAAC,KAAmB,EAAE,MAAoB;IACzE,OAAO,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;AAC1E,CAAC;AAED,iGAAiG;AACjG,SAAS,eAAe,CAAC,KAAmB,EAAE,MAAoB;IAChE,MAAM,YAAY,GAChB,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAChF,MAAM,aAAa,GACjB,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAChF,IAAI,YAAY,IAAI,CAAC,IAAI,aAAa,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IAC1D,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,IAAI,MAAM,CAAC,IAAI,KAAK,OAAO;QAAE,OAAO,IAAI,CAAC;IACnE,MAAM,UAAU,GAAG,YAAY,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IACtE,MAAM,WAAW,GAAG,aAAa,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IACxE,OAAO,UAAU,GAAG,mBAAmB,IAAI,WAAW,GAAG,mBAAmB,CAAC;AAC/E,CAAC;AAED,uFAAuF;AACvF,SAAS,WAAW,CAAC,GAAW,EAAE,QAAe,EAAE,MAAa,EAAE,MAAc;IAC9E,MAAM,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAClD,MAAM,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAClD,OAAO;QACL,EAAE,EAAE,GAAG,CAAC,EAAE,GAAG,EAAE;QACf,EAAE,EAAE,GAAG,CAAC,EAAE,GAAG,EAAE;QACf,EAAE,EAAE,GAAG,CAAC,EAAE,GAAG,EAAE;QACf,EAAE,EAAE,GAAG,CAAC,EAAE,GAAG,EAAE;QACf,CAAC,EAAE,GAAG,CAAC,CAAC;QACR,CAAC,EAAE,GAAG,CAAC,CAAC;KACT,CAAC;AACJ,CAAC;AAED,mFAAmF;AACnF,SAAS,YAAY,CAAC,GAAW,EAAE,MAAc;IAC/C,MAAM,EAAE,GAAG,GAAG,CAAC,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC;IAChC,MAAM,EAAE,GAAG,GAAG,CAAC,EAAE,GAAG,MAAM,CAAC,GAAG,CAAC;IAC/B,MAAM,EAAE,GAAG,GAAG,CAAC,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC;IACjC,MAAM,EAAE,GAAG,GAAG,CAAC,EAAE,GAAG,MAAM,CAAC,MAAM,CAAC;IAClC,OAAO,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,GAAG,EAAE,EAAE,CAAC,EAAE,EAAE,GAAG,EAAE,EAAE,CAAC;AACpD,CAAC;AAED,2FAA2F;AAC3F,SAAS,WAAW,CAAC,KAAwB;IAC3C,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,SAAS,CAAC;IACzC,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IACnD,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IACnD,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IACnD,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IACnD,OAAO,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,GAAG,EAAE,EAAE,CAAC,EAAE,EAAE,GAAG,EAAE,EAAE,CAAC;AACpD,CAAC;AAED,2EAA2E;AAC3E,SAAS,WAAW,CAAC,GAAW;IAC9B,OAAO,GAAG,CAAC,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC;AAChC,CAAC;AAED,sGAAsG;AACtG,SAAS,UAAU,CACjB,EAAQ,EACR,MAA+B,EAC/B,MAAa,EACb,MAAc;IAEd,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;IAC1E,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE;QACZ,EAAE,CAAC,KAAK,EAAE;aACP,GAAG,CAAC,SAAS,CAAC;aACd,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE;YAClB,MAAM,QAAQ,GAAG,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;YAC7D,OAAO;gBACL,CAAC,EAAE,MAAM,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM;gBAC9C,CAAC,EAAE,MAAM,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM;aAC/C,CAAC;QACJ,CAAC,CAAC,CAAC;IACP,CAAC,CAAC,CAAC;AACL,CAAC","sourcesContent":["import { findMinimumAcceptedNumber } from '@ankhorage/utility/algorithms';\nimport type { Core, NodeSingular } from 'cytoscape';\n\nconst LABEL_OVERLAP_RATIO = 0.12;\nconst MAX_EXPANSION_ATTEMPTS = 8;\nconst MAX_NODE_COUNT = 2000;\nconst MAX_PAIR_CHECKS = 200000;\nconst SEARCH_ITERATIONS = 8;\n\n/***\n * Optimize settled layout spacing around readable label bounds instead of full node clearance.\n * Compound containment stays intentional while sibling groups retain non-overlapping boundaries.\n * @performance Cytoscape geometry is measured once, then bounded pure numeric trials reuse the\n * immutable snapshot. No layout reruns, zoom work, or intermediate frames are painted.\n */\nexport function compactGraphSpacing(cy: Core, spacingFactor: number): number {\n const leaves = cy.nodes(':childless');\n if (leaves.length < 2 || cy.nodes().length > MAX_NODE_COUNT || cy.nodes(':locked').length > 0)\n return spacingFactor;\n\n const bounds = leaves.boundingBox();\n const center = { x: (bounds.x1 + bounds.x2) / 2, y: (bounds.y1 + bounds.y2) / 2 };\n const leafGeometry = leaves.map(readLeafGeometry);\n const groups = cy.nodes(':parent').map((node) =>\n readGroupGeometry(\n node.id(),\n node.ancestors().map((parent) => parent.id()),\n node.boundingBox(),\n leafGeometry,\n ),\n );\n const accepts = (factor: number) =>\n !hasUnsafeCollisions(resolveCollisionBoxes(leafGeometry, groups, center, factor));\n const currentAccepted = accepts(1);\n const minimum = currentAccepted ? Math.min(1, 0.1 / spacingFactor) : 1;\n const maximum = currentAccepted ? 1 : findExpansionMaximum(accepts);\n if (maximum === undefined) return spacingFactor;\n\n const factor = findMinimumAcceptedNumber({\n minimum,\n maximum,\n iterations: SEARCH_ITERATIONS,\n accepts,\n });\n if (factor === undefined) return spacingFactor;\n\n applyScale(cy, leafGeometry, center, factor);\n return spacingFactor * factor;\n}\n\ninterface Point {\n readonly x: number;\n readonly y: number;\n}\n\ninterface Bounds {\n readonly x1: number;\n readonly y1: number;\n readonly x2: number;\n readonly y2: number;\n readonly w: number;\n readonly h: number;\n}\n\ninterface Insets {\n readonly left: number;\n readonly right: number;\n readonly top: number;\n readonly bottom: number;\n}\n\ninterface LeafGeometry {\n readonly id: string;\n readonly ancestors: ReadonlySet<string>;\n readonly position: Point;\n readonly labelBox: Bounds;\n readonly nodeBox: Bounds;\n}\n\ninterface GroupGeometry {\n readonly id: string;\n readonly ancestors: ReadonlySet<string>;\n readonly descendants: readonly LeafGeometry[];\n readonly insets: Insets;\n}\n\ninterface CollisionBox {\n readonly id: string;\n readonly ancestors: ReadonlySet<string>;\n readonly kind: 'label' | 'group';\n readonly box: Bounds;\n}\n\n/*** Read one leaf's immutable position, hierarchy and renderer-measured label geometry. */\nfunction readLeafGeometry(node: NodeSingular): LeafGeometry {\n const nodeBox = node.boundingBox({\n includeLabels: true,\n includeOverlays: false,\n includeUnderlays: false,\n });\n const labelBox = node.boundingBox({\n includeNodes: false,\n includeEdges: false,\n includeLabels: true,\n includeOverlays: false,\n includeUnderlays: false,\n });\n return {\n id: node.id(),\n ancestors: new Set(node.ancestors().map((parent) => parent.id())),\n position: { ...node.position() },\n labelBox: isUsableBox(labelBox) ? labelBox : nodeBox,\n nodeBox,\n };\n}\n\n/*** Capture one compound group's descendant geometry and constant renderer-owned outer insets. */\nfunction readGroupGeometry(\n id: string,\n ancestorIds: readonly string[],\n parentBox: Bounds,\n leaves: readonly LeafGeometry[],\n): GroupGeometry {\n const descendants = leaves.filter((leaf) => leaf.ancestors.has(id));\n const descendantUnion = unionBounds(descendants.map((leaf) => leaf.nodeBox)) ?? parentBox;\n return {\n id,\n ancestors: new Set(ancestorIds),\n descendants,\n insets: {\n left: Math.max(0, descendantUnion.x1 - parentBox.x1),\n right: Math.max(0, parentBox.x2 - descendantUnion.x2),\n top: Math.max(0, descendantUnion.y1 - parentBox.y1),\n bottom: Math.max(0, parentBox.y2 - descendantUnion.y2),\n },\n };\n}\n\n/*** Resolve immutable label and compound boxes for one candidate uniform spacing factor. */\nfunction resolveCollisionBoxes(\n leaves: readonly LeafGeometry[],\n groups: readonly GroupGeometry[],\n center: Point,\n factor: number,\n): readonly CollisionBox[] {\n const labels = leaves.map((leaf) => ({\n id: leaf.id,\n ancestors: leaf.ancestors,\n kind: 'label' as const,\n box: shiftBounds(leaf.labelBox, leaf.position, center, factor),\n }));\n const compoundBoxes = groups.flatMap((group) => {\n const descendants = group.descendants.map((leaf) =>\n shiftBounds(leaf.nodeBox, leaf.position, center, factor),\n );\n const union = unionBounds(descendants);\n if (!union) return [];\n return [\n {\n id: group.id,\n ancestors: group.ancestors,\n kind: 'group' as const,\n box: expandBounds(union, group.insets),\n },\n ];\n });\n return [...labels, ...compoundBoxes];\n}\n\n/*** Find the first bounded expansion factor that makes the pure geometry snapshot acceptable. */\nfunction findExpansionMaximum(accepts: (factor: number) => boolean): number | undefined {\n return Array.from({ length: MAX_EXPANSION_ATTEMPTS }, (_, index) => 2 ** (index + 1)).find(\n accepts,\n );\n}\n\n/***\n * Sweep resolved boxes by x extent and stop conservatively if the pair-check budget is exhausted.\n * Local loop counters are intentionally mutable on this performance-critical boundary.\n */\nfunction hasUnsafeCollisions(boxes: readonly CollisionBox[]): boolean {\n const sorted = [...boxes].sort((first, second) => first.box.x1 - second.box.x1);\n const budget = { remaining: MAX_PAIR_CHECKS };\n for (const [index, first] of sorted.entries()) {\n for (let next = index + 1; next < sorted.length; next += 1) {\n const second = sorted.at(next);\n if (!second) break;\n if (second.box.x1 >= first.box.x2) break;\n budget.remaining -= 1;\n if (budget.remaining < 0) return true;\n if (isIntentionalContainment(first, second)) continue;\n if (isUnsafeOverlap(first, second)) return true;\n }\n }\n return false;\n}\n\n/*** Ignore intentional compound containment while retaining sibling and unrelated separation. */\nfunction isIntentionalContainment(first: CollisionBox, second: CollisionBox): boolean {\n return first.ancestors.has(second.id) || second.ancestors.has(first.id);\n}\n\n/*** Permit only a small bounded label-label overlap; every other intersection remains unsafe. */\nfunction isUnsafeOverlap(first: CollisionBox, second: CollisionBox): boolean {\n const overlapWidth =\n Math.min(first.box.x2, second.box.x2) - Math.max(first.box.x1, second.box.x1);\n const overlapHeight =\n Math.min(first.box.y2, second.box.y2) - Math.max(first.box.y1, second.box.y1);\n if (overlapWidth <= 0 || overlapHeight <= 0) return false;\n if (first.kind !== 'label' || second.kind !== 'label') return true;\n const widthRatio = overlapWidth / Math.min(first.box.w, second.box.w);\n const heightRatio = overlapHeight / Math.min(first.box.h, second.box.h);\n return widthRatio > LABEL_OVERLAP_RATIO && heightRatio > LABEL_OVERLAP_RATIO;\n}\n\n/*** Shift one fixed renderer box with its leaf center under uniform radial scaling. */\nfunction shiftBounds(box: Bounds, position: Point, center: Point, factor: number): Bounds {\n const dx = (position.x - center.x) * (factor - 1);\n const dy = (position.y - center.y) * (factor - 1);\n return {\n x1: box.x1 + dx,\n y1: box.y1 + dy,\n x2: box.x2 + dx,\n y2: box.y2 + dy,\n w: box.w,\n h: box.h,\n };\n}\n\n/*** Expand a descendant union by the compound renderer's measured outer insets. */\nfunction expandBounds(box: Bounds, insets: Insets): Bounds {\n const x1 = box.x1 - insets.left;\n const y1 = box.y1 - insets.top;\n const x2 = box.x2 + insets.right;\n const y2 = box.y2 + insets.bottom;\n return { x1, y1, x2, y2, w: x2 - x1, h: y2 - y1 };\n}\n\n/*** Return the immutable union of measured boxes, or undefined for an empty collection. */\nfunction unionBounds(boxes: readonly Bounds[]): Bounds | undefined {\n if (boxes.length === 0) return undefined;\n const x1 = Math.min(...boxes.map((box) => box.x1));\n const y1 = Math.min(...boxes.map((box) => box.y1));\n const x2 = Math.max(...boxes.map((box) => box.x2));\n const y2 = Math.max(...boxes.map((box) => box.y2));\n return { x1, y1, x2, y2, w: x2 - x1, h: y2 - y1 };\n}\n\n/*** Return whether renderer measurement produced a non-empty label box. */\nfunction isUsableBox(box: Bounds): boolean {\n return box.w > 0 && box.h > 0;\n}\n\n/*** Apply the accepted factor once, preserving node dimensions, hierarchy, ordering and edge data. */\nfunction applyScale(\n cy: Core,\n leaves: readonly LeafGeometry[],\n center: Point,\n factor: number,\n): void {\n const positions = new Map(leaves.map((leaf) => [leaf.id, leaf.position]));\n cy.batch(() => {\n cy.nodes()\n .not(':parent')\n .positions((node) => {\n const original = positions.get(node.id()) ?? node.position();\n return {\n x: center.x + (original.x - center.x) * factor,\n y: center.y + (original.y - center.y) * factor,\n };\n });\n });\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ankhorage/zora",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "21.0.
|
|
4
|
+
"version": "21.0.9",
|
|
5
5
|
"description": "Opinionated React Native and React Native Web UI kit built on @ankhorage/surface.",
|
|
6
6
|
"homepage": "https://github.com/ankhorage/zora#readme",
|
|
7
7
|
"bugs": {
|
|
@@ -1,59 +1,66 @@
|
|
|
1
1
|
import { expect, test } from 'bun:test';
|
|
2
|
-
import cytoscape from 'cytoscape';
|
|
2
|
+
import cytoscape, { type NodeSingular } from 'cytoscape';
|
|
3
3
|
|
|
4
4
|
import { compactGraphSpacing } from './compactGraphSpacing';
|
|
5
5
|
|
|
6
|
-
test('compacts
|
|
6
|
+
test('compacts around measured labels while allowing node backgrounds to overlap', () => {
|
|
7
7
|
const cy = cytoscape({
|
|
8
8
|
headless: true,
|
|
9
9
|
styleEnabled: true,
|
|
10
10
|
layout: { name: 'preset' },
|
|
11
11
|
elements: [
|
|
12
12
|
{ data: { id: 'a' }, position: { x: 0, y: 0 } },
|
|
13
|
-
{ data: { id: 'b' }, position: { x:
|
|
13
|
+
{ data: { id: 'b' }, position: { x: 200, y: 0 } },
|
|
14
14
|
{ data: { id: 'e', source: 'a', target: 'b', weight: 7 } },
|
|
15
15
|
],
|
|
16
16
|
style: [{ selector: 'node', style: { width: 100, height: 40, padding: '0px' } }],
|
|
17
17
|
});
|
|
18
18
|
try {
|
|
19
|
+
const a = cy.getElementById('a');
|
|
20
|
+
const b = cy.getElementById('b');
|
|
21
|
+
installMeasuredLabelBox(a, 40, 20);
|
|
22
|
+
installMeasuredLabelBox(b, 40, 20);
|
|
23
|
+
|
|
19
24
|
const factor = compactGraphSpacing(cy, 1);
|
|
20
|
-
expect(factor).
|
|
21
|
-
expect(factor).
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
expect(factor).toBeGreaterThan(0.17);
|
|
26
|
+
expect(factor).toBeLessThan(0.19);
|
|
27
|
+
expect(b.boundingBox().x1 - a.boundingBox().x2).toBeLessThan(0);
|
|
28
|
+
|
|
29
|
+
const labelA = readLabelBox(a);
|
|
30
|
+
const labelB = readLabelBox(b);
|
|
31
|
+
const overlap = Math.min(labelA.x2, labelB.x2) - Math.max(labelA.x1, labelB.x1);
|
|
32
|
+
expect(overlap).toBeGreaterThan(0);
|
|
33
|
+
expect(overlap).toBeLessThanOrEqual(40 * 0.12 + 0.2);
|
|
26
34
|
expect(cy.edges()[0].data('weight')).toBe(7);
|
|
27
|
-
expect(
|
|
28
|
-
// A second bounded search may refine the first interval, but cannot cross the clearance.
|
|
29
|
-
const refined = compactGraphSpacing(cy, factor);
|
|
30
|
-
expect(factor - refined).toBeLessThan(1 / 128);
|
|
31
|
-
expect(
|
|
32
|
-
cy.getElementById('b').boundingBox().x1 - cy.getElementById('a').boundingBox().x2,
|
|
33
|
-
).toBeGreaterThanOrEqual(8);
|
|
35
|
+
expect(a.width()).toBe(100);
|
|
34
36
|
} finally {
|
|
35
37
|
cy.destroy();
|
|
36
38
|
}
|
|
37
39
|
});
|
|
38
40
|
|
|
39
|
-
test('expands a uniformly cramped layout
|
|
41
|
+
test('expands a uniformly cramped layout only until measured labels are readable', () => {
|
|
40
42
|
const cy = cytoscape({
|
|
41
43
|
headless: true,
|
|
42
44
|
styleEnabled: true,
|
|
43
45
|
layout: { name: 'preset' },
|
|
44
46
|
elements: [
|
|
45
47
|
{ data: { id: 'a' }, position: { x: 0, y: 0 } },
|
|
46
|
-
{ data: { id: 'b' }, position: { x:
|
|
48
|
+
{ data: { id: 'b' }, position: { x: 20, y: 0 } },
|
|
47
49
|
],
|
|
48
50
|
style: [{ selector: 'node', style: { width: 100, height: 40, padding: '0px' } }],
|
|
49
51
|
});
|
|
50
52
|
try {
|
|
53
|
+
installMeasuredLabelBox(cy.getElementById('a'), 40, 20);
|
|
54
|
+
installMeasuredLabelBox(cy.getElementById('b'), 40, 20);
|
|
55
|
+
|
|
51
56
|
const factor = compactGraphSpacing(cy, 0.1);
|
|
52
|
-
expect(factor).toBeGreaterThan(0.
|
|
53
|
-
expect(factor).toBeLessThan(0.
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
expect(
|
|
57
|
+
expect(factor).toBeGreaterThan(0.17);
|
|
58
|
+
expect(factor).toBeLessThan(0.19);
|
|
59
|
+
const labelA = readLabelBox(cy.getElementById('a'));
|
|
60
|
+
const labelB = readLabelBox(cy.getElementById('b'));
|
|
61
|
+
expect(Math.min(labelA.x2, labelB.x2) - Math.max(labelA.x1, labelB.x1)).toBeLessThanOrEqual(
|
|
62
|
+
40 * 0.12 + 0.2,
|
|
63
|
+
);
|
|
57
64
|
} finally {
|
|
58
65
|
cy.destroy();
|
|
59
66
|
}
|
|
@@ -78,7 +85,7 @@ test('preserves locked or non-uniformly overlapping views', () => {
|
|
|
78
85
|
}
|
|
79
86
|
});
|
|
80
87
|
|
|
81
|
-
test('allows parent-child containment but
|
|
88
|
+
test('allows parent-child containment but keeps compound peer groups separated', () => {
|
|
82
89
|
const cy = cytoscape({
|
|
83
90
|
headless: true,
|
|
84
91
|
styleEnabled: true,
|
|
@@ -94,9 +101,37 @@ test('allows parent-child containment but preserves separation between compound
|
|
|
94
101
|
expect(compactGraphSpacing(cy, 1)).toBeLessThan(1);
|
|
95
102
|
expect(
|
|
96
103
|
cy.getElementById('q').boundingBox().x1 - cy.getElementById('p').boundingBox().x2,
|
|
97
|
-
).toBeGreaterThanOrEqual(
|
|
104
|
+
).toBeGreaterThanOrEqual(0);
|
|
98
105
|
expect(cy.getElementById('p.a').data('parent')).toBe('p');
|
|
99
106
|
} finally {
|
|
100
107
|
cy.destroy();
|
|
101
108
|
}
|
|
102
109
|
});
|
|
110
|
+
|
|
111
|
+
/*** Install a dynamic renderer-style label box for headless Cytoscape tests. */
|
|
112
|
+
function installMeasuredLabelBox(node: NodeSingular, width: number, height: number): void {
|
|
113
|
+
const originalBoundingBox = node.boundingBox.bind(node);
|
|
114
|
+
node.boundingBox = (options) => {
|
|
115
|
+
if (options?.includeNodes !== false) return originalBoundingBox(options);
|
|
116
|
+
const position = node.position();
|
|
117
|
+
return {
|
|
118
|
+
x1: position.x - width / 2,
|
|
119
|
+
y1: position.y - height / 2,
|
|
120
|
+
x2: position.x + width / 2,
|
|
121
|
+
y2: position.y + height / 2,
|
|
122
|
+
w: width,
|
|
123
|
+
h: height,
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/*** Read only the renderer-measured label box from one test node. */
|
|
129
|
+
function readLabelBox(node: NodeSingular) {
|
|
130
|
+
return node.boundingBox({
|
|
131
|
+
includeNodes: false,
|
|
132
|
+
includeEdges: false,
|
|
133
|
+
includeLabels: true,
|
|
134
|
+
includeOverlays: false,
|
|
135
|
+
includeUnderlays: false,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -1,86 +1,264 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { findMinimumAcceptedNumber } from '@ankhorage/utility/algorithms';
|
|
2
|
+
import type { Core, NodeSingular } from 'cytoscape';
|
|
3
|
+
|
|
4
|
+
const LABEL_OVERLAP_RATIO = 0.12;
|
|
5
|
+
const MAX_EXPANSION_ATTEMPTS = 8;
|
|
6
|
+
const MAX_NODE_COUNT = 2000;
|
|
7
|
+
const MAX_PAIR_CHECKS = 200000;
|
|
8
|
+
const SEARCH_ITERATIONS = 8;
|
|
2
9
|
|
|
3
10
|
/***
|
|
4
|
-
* Optimize settled layout spacing
|
|
5
|
-
* Compound containment
|
|
6
|
-
* @performance
|
|
7
|
-
*
|
|
8
|
-
* non-uniformly overlapping views retain their original positions.
|
|
11
|
+
* Optimize settled layout spacing around readable label bounds instead of full node clearance.
|
|
12
|
+
* Compound containment stays intentional while sibling groups retain non-overlapping boundaries.
|
|
13
|
+
* @performance Cytoscape geometry is measured once, then bounded pure numeric trials reuse the
|
|
14
|
+
* immutable snapshot. No layout reruns, zoom work, or intermediate frames are painted.
|
|
9
15
|
*/
|
|
10
16
|
export function compactGraphSpacing(cy: Core, spacingFactor: number): number {
|
|
11
17
|
const leaves = cy.nodes(':childless');
|
|
12
|
-
if (leaves.length < 2 || cy.nodes().length >
|
|
18
|
+
if (leaves.length < 2 || cy.nodes().length > MAX_NODE_COUNT || cy.nodes(':locked').length > 0)
|
|
13
19
|
return spacingFactor;
|
|
14
|
-
|
|
15
|
-
cy.nodes().map((node) => [node.id(), new Set(node.ancestors().map((parent) => parent.id()))]),
|
|
16
|
-
);
|
|
17
|
-
const positions = new Map(leaves.map((node) => [node.id(), { ...node.position() }]));
|
|
20
|
+
|
|
18
21
|
const bounds = leaves.boundingBox();
|
|
19
22
|
const center = { x: (bounds.x1 + bounds.x2) / 2, y: (bounds.y1 + bounds.y2) / 2 };
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
const leafGeometry = leaves.map(readLeafGeometry);
|
|
24
|
+
const groups = cy.nodes(':parent').map((node) =>
|
|
25
|
+
readGroupGeometry(
|
|
26
|
+
node.id(),
|
|
27
|
+
node.ancestors().map((parent) => parent.id()),
|
|
28
|
+
node.boundingBox(),
|
|
29
|
+
leafGeometry,
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
const accepts = (factor: number) =>
|
|
33
|
+
!hasUnsafeCollisions(resolveCollisionBoxes(leafGeometry, groups, center, factor));
|
|
34
|
+
const currentAccepted = accepts(1);
|
|
35
|
+
const minimum = currentAccepted ? Math.min(1, 0.1 / spacingFactor) : 1;
|
|
36
|
+
const maximum = currentAccepted ? 1 : findExpansionMaximum(accepts);
|
|
37
|
+
if (maximum === undefined) return spacingFactor;
|
|
38
|
+
|
|
39
|
+
const factor = findMinimumAcceptedNumber({
|
|
40
|
+
minimum,
|
|
41
|
+
maximum,
|
|
42
|
+
iterations: SEARCH_ITERATIONS,
|
|
43
|
+
accepts,
|
|
44
|
+
});
|
|
45
|
+
if (factor === undefined) return spacingFactor;
|
|
46
|
+
|
|
47
|
+
applyScale(cy, leafGeometry, center, factor);
|
|
48
|
+
return spacingFactor * factor;
|
|
23
49
|
}
|
|
24
50
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
ancestors: ReadonlyMap<string, ReadonlySet<string>>,
|
|
29
|
-
center: { x: number; y: number },
|
|
30
|
-
positions: ReadonlyMap<string, { x: number; y: number }>,
|
|
31
|
-
spacingFactor: number,
|
|
32
|
-
): number {
|
|
33
|
-
const search = { low: Math.min(1, 0.1 / spacingFactor), high: 1 };
|
|
34
|
-
for (const attempt of Array.from({ length: 8 }, (_, index) => index)) {
|
|
35
|
-
const factor = attempt === 0 ? search.low : (search.low + search.high) / 2;
|
|
36
|
-
applyScale(cy, positions, center, factor);
|
|
37
|
-
if (hasCollisions(cy, ancestors)) search.low = factor;
|
|
38
|
-
else search.high = factor;
|
|
39
|
-
if (search.high === search.low) break;
|
|
40
|
-
}
|
|
41
|
-
applyScale(cy, positions, center, search.high);
|
|
42
|
-
return spacingFactor * search.high;
|
|
51
|
+
interface Point {
|
|
52
|
+
readonly x: number;
|
|
53
|
+
readonly y: number;
|
|
43
54
|
}
|
|
44
55
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
56
|
+
interface Bounds {
|
|
57
|
+
readonly x1: number;
|
|
58
|
+
readonly y1: number;
|
|
59
|
+
readonly x2: number;
|
|
60
|
+
readonly y2: number;
|
|
61
|
+
readonly w: number;
|
|
62
|
+
readonly h: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface Insets {
|
|
66
|
+
readonly left: number;
|
|
67
|
+
readonly right: number;
|
|
68
|
+
readonly top: number;
|
|
69
|
+
readonly bottom: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface LeafGeometry {
|
|
73
|
+
readonly id: string;
|
|
74
|
+
readonly ancestors: ReadonlySet<string>;
|
|
75
|
+
readonly position: Point;
|
|
76
|
+
readonly labelBox: Bounds;
|
|
77
|
+
readonly nodeBox: Bounds;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface GroupGeometry {
|
|
81
|
+
readonly id: string;
|
|
82
|
+
readonly ancestors: ReadonlySet<string>;
|
|
83
|
+
readonly descendants: readonly LeafGeometry[];
|
|
84
|
+
readonly insets: Insets;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface CollisionBox {
|
|
88
|
+
readonly id: string;
|
|
89
|
+
readonly ancestors: ReadonlySet<string>;
|
|
90
|
+
readonly kind: 'label' | 'group';
|
|
91
|
+
readonly box: Bounds;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/*** Read one leaf's immutable position, hierarchy and renderer-measured label geometry. */
|
|
95
|
+
function readLeafGeometry(node: NodeSingular): LeafGeometry {
|
|
96
|
+
const nodeBox = node.boundingBox({
|
|
97
|
+
includeLabels: true,
|
|
98
|
+
includeOverlays: false,
|
|
99
|
+
includeUnderlays: false,
|
|
100
|
+
});
|
|
101
|
+
const labelBox = node.boundingBox({
|
|
102
|
+
includeNodes: false,
|
|
103
|
+
includeEdges: false,
|
|
104
|
+
includeLabels: true,
|
|
105
|
+
includeOverlays: false,
|
|
106
|
+
includeUnderlays: false,
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
id: node.id(),
|
|
110
|
+
ancestors: new Set(node.ancestors().map((parent) => parent.id())),
|
|
111
|
+
position: { ...node.position() },
|
|
112
|
+
labelBox: isUsableBox(labelBox) ? labelBox : nodeBox,
|
|
113
|
+
nodeBox,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/*** Capture one compound group's descendant geometry and constant renderer-owned outer insets. */
|
|
118
|
+
function readGroupGeometry(
|
|
119
|
+
id: string,
|
|
120
|
+
ancestorIds: readonly string[],
|
|
121
|
+
parentBox: Bounds,
|
|
122
|
+
leaves: readonly LeafGeometry[],
|
|
123
|
+
): GroupGeometry {
|
|
124
|
+
const descendants = leaves.filter((leaf) => leaf.ancestors.has(id));
|
|
125
|
+
const descendantUnion = unionBounds(descendants.map((leaf) => leaf.nodeBox)) ?? parentBox;
|
|
126
|
+
return {
|
|
127
|
+
id,
|
|
128
|
+
ancestors: new Set(ancestorIds),
|
|
129
|
+
descendants,
|
|
130
|
+
insets: {
|
|
131
|
+
left: Math.max(0, descendantUnion.x1 - parentBox.x1),
|
|
132
|
+
right: Math.max(0, parentBox.x2 - descendantUnion.x2),
|
|
133
|
+
top: Math.max(0, descendantUnion.y1 - parentBox.y1),
|
|
134
|
+
bottom: Math.max(0, parentBox.y2 - descendantUnion.y2),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/*** Resolve immutable label and compound boxes for one candidate uniform spacing factor. */
|
|
140
|
+
function resolveCollisionBoxes(
|
|
141
|
+
leaves: readonly LeafGeometry[],
|
|
142
|
+
groups: readonly GroupGeometry[],
|
|
143
|
+
center: Point,
|
|
144
|
+
factor: number,
|
|
145
|
+
): readonly CollisionBox[] {
|
|
146
|
+
const labels = leaves.map((leaf) => ({
|
|
147
|
+
id: leaf.id,
|
|
148
|
+
ancestors: leaf.ancestors,
|
|
149
|
+
kind: 'label' as const,
|
|
150
|
+
box: shiftBounds(leaf.labelBox, leaf.position, center, factor),
|
|
151
|
+
}));
|
|
152
|
+
const compoundBoxes = groups.flatMap((group) => {
|
|
153
|
+
const descendants = group.descendants.map((leaf) =>
|
|
154
|
+
shiftBounds(leaf.nodeBox, leaf.position, center, factor),
|
|
155
|
+
);
|
|
156
|
+
const union = unionBounds(descendants);
|
|
157
|
+
if (!union) return [];
|
|
158
|
+
return [
|
|
159
|
+
{
|
|
160
|
+
id: group.id,
|
|
161
|
+
ancestors: group.ancestors,
|
|
162
|
+
kind: 'group' as const,
|
|
163
|
+
box: expandBounds(union, group.insets),
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
});
|
|
167
|
+
return [...labels, ...compoundBoxes];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/*** Find the first bounded expansion factor that makes the pure geometry snapshot acceptable. */
|
|
171
|
+
function findExpansionMaximum(accepts: (factor: number) => boolean): number | undefined {
|
|
172
|
+
return Array.from({ length: MAX_EXPANSION_ATTEMPTS }, (_, index) => 2 ** (index + 1)).find(
|
|
173
|
+
accepts,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/***
|
|
178
|
+
* Sweep resolved boxes by x extent and stop conservatively if the pair-check budget is exhausted.
|
|
179
|
+
* Local loop counters are intentionally mutable on this performance-critical boundary.
|
|
180
|
+
*/
|
|
181
|
+
function hasUnsafeCollisions(boxes: readonly CollisionBox[]): boolean {
|
|
182
|
+
const sorted = [...boxes].sort((first, second) => first.box.x1 - second.box.x1);
|
|
183
|
+
const budget = { remaining: MAX_PAIR_CHECKS };
|
|
184
|
+
for (const [index, first] of sorted.entries()) {
|
|
185
|
+
for (let next = index + 1; next < sorted.length; next += 1) {
|
|
186
|
+
const second = sorted.at(next);
|
|
187
|
+
if (!second) break;
|
|
188
|
+
if (second.box.x1 >= first.box.x2) break;
|
|
189
|
+
budget.remaining -= 1;
|
|
190
|
+
if (budget.remaining < 0) return true;
|
|
191
|
+
if (isIntentionalContainment(first, second)) continue;
|
|
192
|
+
if (isUnsafeOverlap(first, second)) return true;
|
|
193
|
+
}
|
|
72
194
|
}
|
|
73
|
-
|
|
74
|
-
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/*** Ignore intentional compound containment while retaining sibling and unrelated separation. */
|
|
199
|
+
function isIntentionalContainment(first: CollisionBox, second: CollisionBox): boolean {
|
|
200
|
+
return first.ancestors.has(second.id) || second.ancestors.has(first.id);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/*** Permit only a small bounded label-label overlap; every other intersection remains unsafe. */
|
|
204
|
+
function isUnsafeOverlap(first: CollisionBox, second: CollisionBox): boolean {
|
|
205
|
+
const overlapWidth =
|
|
206
|
+
Math.min(first.box.x2, second.box.x2) - Math.max(first.box.x1, second.box.x1);
|
|
207
|
+
const overlapHeight =
|
|
208
|
+
Math.min(first.box.y2, second.box.y2) - Math.max(first.box.y1, second.box.y1);
|
|
209
|
+
if (overlapWidth <= 0 || overlapHeight <= 0) return false;
|
|
210
|
+
if (first.kind !== 'label' || second.kind !== 'label') return true;
|
|
211
|
+
const widthRatio = overlapWidth / Math.min(first.box.w, second.box.w);
|
|
212
|
+
const heightRatio = overlapHeight / Math.min(first.box.h, second.box.h);
|
|
213
|
+
return widthRatio > LABEL_OVERLAP_RATIO && heightRatio > LABEL_OVERLAP_RATIO;
|
|
75
214
|
}
|
|
76
215
|
|
|
77
|
-
/***
|
|
216
|
+
/*** Shift one fixed renderer box with its leaf center under uniform radial scaling. */
|
|
217
|
+
function shiftBounds(box: Bounds, position: Point, center: Point, factor: number): Bounds {
|
|
218
|
+
const dx = (position.x - center.x) * (factor - 1);
|
|
219
|
+
const dy = (position.y - center.y) * (factor - 1);
|
|
220
|
+
return {
|
|
221
|
+
x1: box.x1 + dx,
|
|
222
|
+
y1: box.y1 + dy,
|
|
223
|
+
x2: box.x2 + dx,
|
|
224
|
+
y2: box.y2 + dy,
|
|
225
|
+
w: box.w,
|
|
226
|
+
h: box.h,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/*** Expand a descendant union by the compound renderer's measured outer insets. */
|
|
231
|
+
function expandBounds(box: Bounds, insets: Insets): Bounds {
|
|
232
|
+
const x1 = box.x1 - insets.left;
|
|
233
|
+
const y1 = box.y1 - insets.top;
|
|
234
|
+
const x2 = box.x2 + insets.right;
|
|
235
|
+
const y2 = box.y2 + insets.bottom;
|
|
236
|
+
return { x1, y1, x2, y2, w: x2 - x1, h: y2 - y1 };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/*** Return the immutable union of measured boxes, or undefined for an empty collection. */
|
|
240
|
+
function unionBounds(boxes: readonly Bounds[]): Bounds | undefined {
|
|
241
|
+
if (boxes.length === 0) return undefined;
|
|
242
|
+
const x1 = Math.min(...boxes.map((box) => box.x1));
|
|
243
|
+
const y1 = Math.min(...boxes.map((box) => box.y1));
|
|
244
|
+
const x2 = Math.max(...boxes.map((box) => box.x2));
|
|
245
|
+
const y2 = Math.max(...boxes.map((box) => box.y2));
|
|
246
|
+
return { x1, y1, x2, y2, w: x2 - x1, h: y2 - y1 };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/*** Return whether renderer measurement produced a non-empty label box. */
|
|
250
|
+
function isUsableBox(box: Bounds): boolean {
|
|
251
|
+
return box.w > 0 && box.h > 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/*** Apply the accepted factor once, preserving node dimensions, hierarchy, ordering and edge data. */
|
|
78
255
|
function applyScale(
|
|
79
256
|
cy: Core,
|
|
80
|
-
|
|
81
|
-
center:
|
|
257
|
+
leaves: readonly LeafGeometry[],
|
|
258
|
+
center: Point,
|
|
82
259
|
factor: number,
|
|
83
|
-
) {
|
|
260
|
+
): void {
|
|
261
|
+
const positions = new Map(leaves.map((leaf) => [leaf.id, leaf.position]));
|
|
84
262
|
cy.batch(() => {
|
|
85
263
|
cy.nodes()
|
|
86
264
|
.not(':parent')
|
|
@@ -93,31 +271,3 @@ function applyScale(
|
|
|
93
271
|
});
|
|
94
272
|
});
|
|
95
273
|
}
|
|
96
|
-
|
|
97
|
-
/*** Sweep measured bounds, ignoring intentional containment and stopping conservatively at budget. */
|
|
98
|
-
function hasCollisions(cy: Core, ancestors: ReadonlyMap<string, ReadonlySet<string>>): boolean {
|
|
99
|
-
const boxes = cy
|
|
100
|
-
.nodes()
|
|
101
|
-
.map((node) => ({
|
|
102
|
-
id: node.id(),
|
|
103
|
-
box: node.boundingBox({
|
|
104
|
-
includeLabels: true,
|
|
105
|
-
includeOverlays: false,
|
|
106
|
-
includeUnderlays: false,
|
|
107
|
-
}),
|
|
108
|
-
}))
|
|
109
|
-
.sort((a, b) => a.box.x1 - b.box.x1);
|
|
110
|
-
const budget = { remaining: 200000 };
|
|
111
|
-
for (const [index, first] of boxes.entries()) {
|
|
112
|
-
for (let next = index + 1; next < boxes.length; next += 1) {
|
|
113
|
-
const second = boxes.at(next);
|
|
114
|
-
if (!second) break;
|
|
115
|
-
if (second.box.x1 >= first.box.x2 + 8) break;
|
|
116
|
-
if (--budget.remaining < 0) return true;
|
|
117
|
-
if (ancestors.get(first.id)?.has(second.id) || ancestors.get(second.id)?.has(first.id))
|
|
118
|
-
continue;
|
|
119
|
-
if (first.box.y1 < second.box.y2 + 8 && second.box.y1 < first.box.y2 + 8) return true;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return false;
|
|
123
|
-
}
|
|
@@ -122375,99 +122375,216 @@ var EVENT_TYPES = [
|
|
|
122375
122375
|
["mouseout", "pointer-leave"]
|
|
122376
122376
|
];
|
|
122377
122377
|
|
|
122378
|
-
//
|
|
122379
|
-
|
|
122380
|
-
|
|
122381
|
-
|
|
122382
|
-
|
|
122383
|
-
const
|
|
122384
|
-
const
|
|
122385
|
-
|
|
122386
|
-
|
|
122387
|
-
if (
|
|
122388
|
-
return
|
|
122389
|
-
return
|
|
122378
|
+
// node_modules/@ankhorage/utility/dist/algorithms/findMinimumAcceptedNumber.js
|
|
122379
|
+
var DEFAULT_ITERATIONS = 16;
|
|
122380
|
+
var MAX_ITERATIONS = 64;
|
|
122381
|
+
function findMinimumAcceptedNumber(input) {
|
|
122382
|
+
validateInput(input);
|
|
122383
|
+
const { accepts, maximum, minimum } = input;
|
|
122384
|
+
const iterations = input.iterations ?? DEFAULT_ITERATIONS;
|
|
122385
|
+
if (accepts(minimum))
|
|
122386
|
+
return minimum;
|
|
122387
|
+
if (!accepts(maximum))
|
|
122388
|
+
return;
|
|
122389
|
+
return searchAcceptedBoundary(minimum, maximum, iterations, accepts);
|
|
122390
122390
|
}
|
|
122391
|
-
function
|
|
122392
|
-
|
|
122393
|
-
|
|
122394
|
-
|
|
122395
|
-
|
|
122396
|
-
if (
|
|
122397
|
-
search.low = factor;
|
|
122398
|
-
else
|
|
122399
|
-
search.high = factor;
|
|
122400
|
-
if (search.high === search.low)
|
|
122391
|
+
function searchAcceptedBoundary(minimum, maximum, iterations, accepts) {
|
|
122392
|
+
let lower = minimum;
|
|
122393
|
+
let upper = maximum;
|
|
122394
|
+
for (let iteration = 0;iteration < iterations; iteration += 1) {
|
|
122395
|
+
const midpoint = lower + (upper - lower) / 2;
|
|
122396
|
+
if (midpoint === lower || midpoint === upper)
|
|
122401
122397
|
break;
|
|
122398
|
+
if (accepts(midpoint))
|
|
122399
|
+
upper = midpoint;
|
|
122400
|
+
else
|
|
122401
|
+
lower = midpoint;
|
|
122402
122402
|
}
|
|
122403
|
-
|
|
122404
|
-
return spacingFactor * search.high;
|
|
122403
|
+
return upper;
|
|
122405
122404
|
}
|
|
122406
|
-
function
|
|
122407
|
-
|
|
122408
|
-
|
|
122409
|
-
|
|
122410
|
-
while (attempts < 8) {
|
|
122411
|
-
high *= 2;
|
|
122412
|
-
attempts += 1;
|
|
122413
|
-
applyScale(cy, positions, center, high);
|
|
122414
|
-
if (!hasCollisions(cy, ancestors))
|
|
122415
|
-
break;
|
|
122416
|
-
low = high;
|
|
122405
|
+
function validateInput(input) {
|
|
122406
|
+
const iterations = input.iterations ?? DEFAULT_ITERATIONS;
|
|
122407
|
+
if (!Number.isFinite(input.minimum) || !Number.isFinite(input.maximum)) {
|
|
122408
|
+
throw new RangeError("Search bounds must be finite numbers.");
|
|
122417
122409
|
}
|
|
122418
|
-
if (
|
|
122419
|
-
|
|
122420
|
-
return spacingFactor;
|
|
122410
|
+
if (input.minimum > input.maximum) {
|
|
122411
|
+
throw new RangeError("Search minimum must not exceed maximum.");
|
|
122421
122412
|
}
|
|
122422
|
-
|
|
122423
|
-
|
|
122424
|
-
applyScale(cy, positions, center, factor);
|
|
122425
|
-
if (hasCollisions(cy, ancestors))
|
|
122426
|
-
low = factor;
|
|
122427
|
-
else
|
|
122428
|
-
high = factor;
|
|
122413
|
+
if (!Number.isInteger(iterations) || iterations < 0 || iterations > MAX_ITERATIONS) {
|
|
122414
|
+
throw new RangeError(`Search iterations must be an integer from 0 to ${MAX_ITERATIONS}.`);
|
|
122429
122415
|
}
|
|
122430
|
-
applyScale(cy, positions, center, high);
|
|
122431
|
-
return spacingFactor * high;
|
|
122432
122416
|
}
|
|
122433
|
-
|
|
122434
|
-
|
|
122435
|
-
|
|
122436
|
-
|
|
122437
|
-
|
|
122438
|
-
|
|
122439
|
-
|
|
122440
|
-
|
|
122441
|
-
|
|
122417
|
+
// src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.ts
|
|
122418
|
+
var LABEL_OVERLAP_RATIO = 0.12;
|
|
122419
|
+
var MAX_EXPANSION_ATTEMPTS = 8;
|
|
122420
|
+
var MAX_NODE_COUNT = 2000;
|
|
122421
|
+
var MAX_PAIR_CHECKS = 200000;
|
|
122422
|
+
var SEARCH_ITERATIONS = 8;
|
|
122423
|
+
function compactGraphSpacing(cy, spacingFactor) {
|
|
122424
|
+
const leaves = cy.nodes(":childless");
|
|
122425
|
+
if (leaves.length < 2 || cy.nodes().length > MAX_NODE_COUNT || cy.nodes(":locked").length > 0)
|
|
122426
|
+
return spacingFactor;
|
|
122427
|
+
const bounds = leaves.boundingBox();
|
|
122428
|
+
const center = { x: (bounds.x1 + bounds.x2) / 2, y: (bounds.y1 + bounds.y2) / 2 };
|
|
122429
|
+
const leafGeometry = leaves.map(readLeafGeometry);
|
|
122430
|
+
const groups = cy.nodes(":parent").map((node) => readGroupGeometry(node.id(), node.ancestors().map((parent) => parent.id()), node.boundingBox(), leafGeometry));
|
|
122431
|
+
const accepts = (factor) => !hasUnsafeCollisions(resolveCollisionBoxes(leafGeometry, groups, center, factor));
|
|
122432
|
+
const currentAccepted = accepts(1);
|
|
122433
|
+
const minimum = currentAccepted ? Math.min(1, 0.1 / spacingFactor) : 1;
|
|
122434
|
+
const maximum = currentAccepted ? 1 : findExpansionMaximum(accepts);
|
|
122435
|
+
if (maximum === undefined)
|
|
122436
|
+
return spacingFactor;
|
|
122437
|
+
const factor = findMinimumAcceptedNumber({
|
|
122438
|
+
minimum,
|
|
122439
|
+
maximum,
|
|
122440
|
+
iterations: SEARCH_ITERATIONS,
|
|
122441
|
+
accepts
|
|
122442
122442
|
});
|
|
122443
|
+
if (factor === undefined)
|
|
122444
|
+
return spacingFactor;
|
|
122445
|
+
applyScale(cy, leafGeometry, center, factor);
|
|
122446
|
+
return spacingFactor * factor;
|
|
122443
122447
|
}
|
|
122444
|
-
function
|
|
122445
|
-
const
|
|
122448
|
+
function readLeafGeometry(node) {
|
|
122449
|
+
const nodeBox = node.boundingBox({
|
|
122450
|
+
includeLabels: true,
|
|
122451
|
+
includeOverlays: false,
|
|
122452
|
+
includeUnderlays: false
|
|
122453
|
+
});
|
|
122454
|
+
const labelBox = node.boundingBox({
|
|
122455
|
+
includeNodes: false,
|
|
122456
|
+
includeEdges: false,
|
|
122457
|
+
includeLabels: true,
|
|
122458
|
+
includeOverlays: false,
|
|
122459
|
+
includeUnderlays: false
|
|
122460
|
+
});
|
|
122461
|
+
return {
|
|
122446
122462
|
id: node.id(),
|
|
122447
|
-
|
|
122448
|
-
|
|
122449
|
-
|
|
122450
|
-
|
|
122451
|
-
|
|
122452
|
-
|
|
122453
|
-
|
|
122454
|
-
|
|
122455
|
-
|
|
122456
|
-
|
|
122463
|
+
ancestors: new Set(node.ancestors().map((parent) => parent.id())),
|
|
122464
|
+
position: { ...node.position() },
|
|
122465
|
+
labelBox: isUsableBox(labelBox) ? labelBox : nodeBox,
|
|
122466
|
+
nodeBox
|
|
122467
|
+
};
|
|
122468
|
+
}
|
|
122469
|
+
function readGroupGeometry(id, ancestorIds, parentBox, leaves) {
|
|
122470
|
+
const descendants = leaves.filter((leaf) => leaf.ancestors.has(id));
|
|
122471
|
+
const descendantUnion = unionBounds(descendants.map((leaf) => leaf.nodeBox)) ?? parentBox;
|
|
122472
|
+
return {
|
|
122473
|
+
id,
|
|
122474
|
+
ancestors: new Set(ancestorIds),
|
|
122475
|
+
descendants,
|
|
122476
|
+
insets: {
|
|
122477
|
+
left: Math.max(0, descendantUnion.x1 - parentBox.x1),
|
|
122478
|
+
right: Math.max(0, parentBox.x2 - descendantUnion.x2),
|
|
122479
|
+
top: Math.max(0, descendantUnion.y1 - parentBox.y1),
|
|
122480
|
+
bottom: Math.max(0, parentBox.y2 - descendantUnion.y2)
|
|
122481
|
+
}
|
|
122482
|
+
};
|
|
122483
|
+
}
|
|
122484
|
+
function resolveCollisionBoxes(leaves, groups, center, factor) {
|
|
122485
|
+
const labels = leaves.map((leaf) => ({
|
|
122486
|
+
id: leaf.id,
|
|
122487
|
+
ancestors: leaf.ancestors,
|
|
122488
|
+
kind: "label",
|
|
122489
|
+
box: shiftBounds(leaf.labelBox, leaf.position, center, factor)
|
|
122490
|
+
}));
|
|
122491
|
+
const compoundBoxes = groups.flatMap((group) => {
|
|
122492
|
+
const descendants = group.descendants.map((leaf) => shiftBounds(leaf.nodeBox, leaf.position, center, factor));
|
|
122493
|
+
const union = unionBounds(descendants);
|
|
122494
|
+
if (!union)
|
|
122495
|
+
return [];
|
|
122496
|
+
return [
|
|
122497
|
+
{
|
|
122498
|
+
id: group.id,
|
|
122499
|
+
ancestors: group.ancestors,
|
|
122500
|
+
kind: "group",
|
|
122501
|
+
box: expandBounds(union, group.insets)
|
|
122502
|
+
}
|
|
122503
|
+
];
|
|
122504
|
+
});
|
|
122505
|
+
return [...labels, ...compoundBoxes];
|
|
122506
|
+
}
|
|
122507
|
+
function findExpansionMaximum(accepts) {
|
|
122508
|
+
return Array.from({ length: MAX_EXPANSION_ATTEMPTS }, (_, index) => 2 ** (index + 1)).find(accepts);
|
|
122509
|
+
}
|
|
122510
|
+
function hasUnsafeCollisions(boxes) {
|
|
122511
|
+
const sorted = [...boxes].sort((first, second) => first.box.x1 - second.box.x1);
|
|
122512
|
+
const budget = { remaining: MAX_PAIR_CHECKS };
|
|
122513
|
+
for (const [index, first] of sorted.entries()) {
|
|
122514
|
+
for (let next = index + 1;next < sorted.length; next += 1) {
|
|
122515
|
+
const second = sorted.at(next);
|
|
122457
122516
|
if (!second)
|
|
122458
122517
|
break;
|
|
122459
|
-
if (second.box.x1 >= first.box.x2
|
|
122518
|
+
if (second.box.x1 >= first.box.x2)
|
|
122460
122519
|
break;
|
|
122461
|
-
|
|
122520
|
+
budget.remaining -= 1;
|
|
122521
|
+
if (budget.remaining < 0)
|
|
122462
122522
|
return true;
|
|
122463
|
-
if (
|
|
122523
|
+
if (isIntentionalContainment(first, second))
|
|
122464
122524
|
continue;
|
|
122465
|
-
if (first
|
|
122525
|
+
if (isUnsafeOverlap(first, second))
|
|
122466
122526
|
return true;
|
|
122467
122527
|
}
|
|
122468
122528
|
}
|
|
122469
122529
|
return false;
|
|
122470
122530
|
}
|
|
122531
|
+
function isIntentionalContainment(first, second) {
|
|
122532
|
+
return first.ancestors.has(second.id) || second.ancestors.has(first.id);
|
|
122533
|
+
}
|
|
122534
|
+
function isUnsafeOverlap(first, second) {
|
|
122535
|
+
const overlapWidth = Math.min(first.box.x2, second.box.x2) - Math.max(first.box.x1, second.box.x1);
|
|
122536
|
+
const overlapHeight = Math.min(first.box.y2, second.box.y2) - Math.max(first.box.y1, second.box.y1);
|
|
122537
|
+
if (overlapWidth <= 0 || overlapHeight <= 0)
|
|
122538
|
+
return false;
|
|
122539
|
+
if (first.kind !== "label" || second.kind !== "label")
|
|
122540
|
+
return true;
|
|
122541
|
+
const widthRatio = overlapWidth / Math.min(first.box.w, second.box.w);
|
|
122542
|
+
const heightRatio = overlapHeight / Math.min(first.box.h, second.box.h);
|
|
122543
|
+
return widthRatio > LABEL_OVERLAP_RATIO && heightRatio > LABEL_OVERLAP_RATIO;
|
|
122544
|
+
}
|
|
122545
|
+
function shiftBounds(box, position, center, factor) {
|
|
122546
|
+
const dx = (position.x - center.x) * (factor - 1);
|
|
122547
|
+
const dy = (position.y - center.y) * (factor - 1);
|
|
122548
|
+
return {
|
|
122549
|
+
x1: box.x1 + dx,
|
|
122550
|
+
y1: box.y1 + dy,
|
|
122551
|
+
x2: box.x2 + dx,
|
|
122552
|
+
y2: box.y2 + dy,
|
|
122553
|
+
w: box.w,
|
|
122554
|
+
h: box.h
|
|
122555
|
+
};
|
|
122556
|
+
}
|
|
122557
|
+
function expandBounds(box, insets) {
|
|
122558
|
+
const x1 = box.x1 - insets.left;
|
|
122559
|
+
const y1 = box.y1 - insets.top;
|
|
122560
|
+
const x2 = box.x2 + insets.right;
|
|
122561
|
+
const y2 = box.y2 + insets.bottom;
|
|
122562
|
+
return { x1, y1, x2, y2, w: x2 - x1, h: y2 - y1 };
|
|
122563
|
+
}
|
|
122564
|
+
function unionBounds(boxes) {
|
|
122565
|
+
if (boxes.length === 0)
|
|
122566
|
+
return;
|
|
122567
|
+
const x1 = Math.min(...boxes.map((box) => box.x1));
|
|
122568
|
+
const y1 = Math.min(...boxes.map((box) => box.y1));
|
|
122569
|
+
const x2 = Math.max(...boxes.map((box) => box.x2));
|
|
122570
|
+
const y2 = Math.max(...boxes.map((box) => box.y2));
|
|
122571
|
+
return { x1, y1, x2, y2, w: x2 - x1, h: y2 - y1 };
|
|
122572
|
+
}
|
|
122573
|
+
function isUsableBox(box) {
|
|
122574
|
+
return box.w > 0 && box.h > 0;
|
|
122575
|
+
}
|
|
122576
|
+
function applyScale(cy, leaves, center, factor) {
|
|
122577
|
+
const positions = new Map(leaves.map((leaf) => [leaf.id, leaf.position]));
|
|
122578
|
+
cy.batch(() => {
|
|
122579
|
+
cy.nodes().not(":parent").positions((node) => {
|
|
122580
|
+
const original = positions.get(node.id()) ?? node.position();
|
|
122581
|
+
return {
|
|
122582
|
+
x: center.x + (original.x - center.x) * factor,
|
|
122583
|
+
y: center.y + (original.y - center.y) * factor
|
|
122584
|
+
};
|
|
122585
|
+
});
|
|
122586
|
+
});
|
|
122587
|
+
}
|
|
122471
122588
|
|
|
122472
122589
|
// src/features/graph-view/adapters/inbound/web-artifact/fitGraphViewport.ts
|
|
122473
122590
|
function fitGraphViewport(cy, options = {}, maxZoom = Infinity) {
|