@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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 21.0.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 0d84538: Compact GraphView spacing around renderer-measured label readability bounds while preserving compound group separation.
8
+
3
9
  ## 21.0.8
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  # @ankhorage/zora
5
5
 
6
- ![license: MIT](././paradox/badges/license.svg) ![npm: v21.0.8](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg)
6
+ ![license: MIT](././paradox/badges/license.svg) ![npm: v21.0.9](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg)
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 uniformly until measured node and label bounds clear.
4
- * Compound containment is intentional; sibling and unrelated compound intersections are not.
5
- * @performance At most eight synchronous trials and 200,000 pair checks per trial. No layout
6
- * algorithm reruns, no work on zoom, and no intermediate frame is painted. Large, locked, or
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":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEtC;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,CAa3E"}
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 uniformly until measured node and label bounds clear.
3
- * Compound containment is intentional; sibling and unrelated compound intersections are not.
4
- * @performance At most eight synchronous trials and 200,000 pair checks per trial. No layout
5
- * algorithm reruns, no work on zoom, and no intermediate frame is painted. Large, locked, or
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 > 2000 || leaves.filter(':locked').length > 0)
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
- if (hasCollisions(cy, ancestors))
17
- return expandGraphSpacing(cy, ancestors, center, positions, spacingFactor);
18
- return compressGraphSpacing(cy, ancestors, center, positions, spacingFactor);
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
- /*** Compress a collision-free settled layout to its smallest measured clearance. */
21
- function compressGraphSpacing(cy, ancestors, center, positions, spacingFactor) {
22
- const search = { low: Math.min(1, 0.1 / spacingFactor), high: 1 };
23
- for (const attempt of Array.from({ length: 8 }, (_, index) => index)) {
24
- const factor = attempt === 0 ? search.low : (search.low + search.high) / 2;
25
- applyScale(cy, positions, center, factor);
26
- if (hasCollisions(cy, ancestors))
27
- search.low = factor;
28
- else
29
- search.high = factor;
30
- if (search.high === search.low)
31
- break;
32
- }
33
- applyScale(cy, positions, center, search.high);
34
- return spacingFactor * search.high;
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
- /*** Expand a uniformly cramped layout until measured node and label bounds clear. */
37
- function expandGraphSpacing(cy, ancestors, center, positions, spacingFactor) {
38
- let low = 1;
39
- let high = 1;
40
- let attempts = 0;
41
- while (attempts < 8) {
42
- high *= 2;
43
- attempts += 1;
44
- applyScale(cy, positions, center, high);
45
- if (!hasCollisions(cy, ancestors))
46
- break;
47
- low = high;
48
- }
49
- if (hasCollisions(cy, ancestors)) {
50
- applyScale(cy, positions, center, 1);
51
- return spacingFactor;
52
- }
53
- for (const _attempt of Array.from({ length: 8 - attempts }, (_, index) => index)) {
54
- const factor = (low + high) / 2;
55
- applyScale(cy, positions, center, factor);
56
- if (hasCollisions(cy, ancestors))
57
- low = factor;
58
- else
59
- high = factor;
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
- applyScale(cy, positions, center, high);
62
- return spacingFactor * high;
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
- /*** Transform leaf centers, preserving ordering, dimensions, compound membership and edge data. */
65
- function applyScale(cy, positions, center, factor) {
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":"AAEA;;;;;;GAMG;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,IAAI,IAAI,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,MAAM,GAAG,CAAC;QACtF,OAAO,aAAa,CAAC;IACvB,MAAM,SAAS,GAAG,IAAI,GAAG,CACvB,EAAE,CAAC,KAAK,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,GAAG,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAC9F,CAAC;IACF,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,EAAE,GAAG,IAAI,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;IACrF,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,IAAI,aAAa,CAAC,EAAE,EAAE,SAAS,CAAC;QAC9B,OAAO,kBAAkB,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,CAAC,CAAC;IAC7E,OAAO,oBAAoB,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,CAAC,CAAC;AAC/E,CAAC;AAED,oFAAoF;AACpF,SAAS,oBAAoB,CAC3B,EAAQ,EACR,SAAmD,EACnD,MAAgC,EAChC,SAAwD,EACxD,aAAqB;IAErB,MAAM,MAAM,GAAG,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,GAAG,aAAa,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;IAClE,KAAK,MAAM,OAAO,IAAI,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC;QACrE,MAAM,MAAM,GAAG,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC3E,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;QAC1C,IAAI,aAAa,CAAC,EAAE,EAAE,SAAS,CAAC;YAAE,MAAM,CAAC,GAAG,GAAG,MAAM,CAAC;;YACjD,MAAM,CAAC,IAAI,GAAG,MAAM,CAAC;QAC1B,IAAI,MAAM,CAAC,IAAI,KAAK,MAAM,CAAC,GAAG;YAAE,MAAM;IACxC,CAAC;IACD,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC;IAC/C,OAAO,aAAa,GAAG,MAAM,CAAC,IAAI,CAAC;AACrC,CAAC;AAED,qFAAqF;AACrF,SAAS,kBAAkB,CACzB,EAAQ,EACR,SAAmD,EACnD,MAAgC,EAChC,SAAwD,EACxD,aAAqB;IAErB,IAAI,GAAG,GAAG,CAAC,CAAC;IACZ,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,IAAI,QAAQ,GAAG,CAAC,CAAC;IACjB,OAAO,QAAQ,GAAG,CAAC,EAAE,CAAC;QACpB,IAAI,IAAI,CAAC,CAAC;QACV,QAAQ,IAAI,CAAC,CAAC;QACd,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC;QACxC,IAAI,CAAC,aAAa,CAAC,EAAE,EAAE,SAAS,CAAC;YAAE,MAAM;QACzC,GAAG,GAAG,IAAI,CAAC;IACb,CAAC;IACD,IAAI,aAAa,CAAC,EAAE,EAAE,SAAS,CAAC,EAAE,CAAC;QACjC,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;QACrC,OAAO,aAAa,CAAC;IACvB,CAAC;IACD,KAAK,MAAM,QAAQ,IAAI,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC,GAAG,QAAQ,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC;QACjF,MAAM,MAAM,GAAG,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC;QAChC,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;QAC1C,IAAI,aAAa,CAAC,EAAE,EAAE,SAAS,CAAC;YAAE,GAAG,GAAG,MAAM,CAAC;;YAC1C,IAAI,GAAG,MAAM,CAAC;IACrB,CAAC;IACD,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC;IACxC,OAAO,aAAa,GAAG,IAAI,CAAC;AAC9B,CAAC;AAED,mGAAmG;AACnG,SAAS,UAAU,CACjB,EAAQ,EACR,SAAwD,EACxD,MAAgC,EAChC,MAAc;IAEd,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;AAED,sGAAsG;AACtG,SAAS,aAAa,CAAC,EAAQ,EAAE,SAAmD;IAClF,MAAM,KAAK,GAAG,EAAE;SACb,KAAK,EAAE;SACP,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QACd,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE;QACb,GAAG,EAAE,IAAI,CAAC,WAAW,CAAC;YACpB,aAAa,EAAE,IAAI;YACnB,eAAe,EAAE,KAAK;YACtB,gBAAgB,EAAE,KAAK;SACxB,CAAC;KACH,CAAC,CAAC;SACF,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACvC,MAAM,MAAM,GAAG,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC;IACrC,KAAK,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,KAAK,CAAC,OAAO,EAAE,EAAE,CAAC;QAC7C,KAAK,IAAI,IAAI,GAAG,KAAK,GAAG,CAAC,EAAE,IAAI,GAAG,KAAK,CAAC,MAAM,EAAE,IAAI,IAAI,CAAC,EAAE,CAAC;YAC1D,MAAM,MAAM,GAAG,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;YAC9B,IAAI,CAAC,MAAM;gBAAE,MAAM;YACnB,IAAI,MAAM,CAAC,GAAG,CAAC,EAAE,IAAI,KAAK,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC;gBAAE,MAAM;YAC7C,IAAI,EAAE,MAAM,CAAC,SAAS,GAAG,CAAC;gBAAE,OAAO,IAAI,CAAC;YACxC,IAAI,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;gBACpF,SAAS;YACX,IAAI,KAAK,CAAC,GAAG,CAAC,EAAE,GAAG,MAAM,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,EAAE,GAAG,KAAK,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC;gBAAE,OAAO,IAAI,CAAC;QACxF,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC","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":"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","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.8",
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 the settled layout up to measured clearance without changing node sizes or edges', () => {
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: 500, y: 0 } },
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).toBeLessThan(0.3);
21
- expect(factor).toBeGreaterThan(0.2);
22
- const a = cy.getElementById('a').boundingBox();
23
- const b = cy.getElementById('b').boundingBox();
24
- expect(b.x1 - a.x2).toBeGreaterThanOrEqual(8);
25
- expect(cy.edges().length).toBe(1);
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(cy.getElementById('a').width()).toBe(100);
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 to measured clearance', () => {
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: 50, y: 0 } },
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.2);
53
- expect(factor).toBeLessThan(0.3);
54
- const a = cy.getElementById('a').boundingBox();
55
- const b = cy.getElementById('b').boundingBox();
56
- expect(b.x1 - a.x2).toBeGreaterThanOrEqual(8);
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 preserves separation between compound groups', () => {
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(8);
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 type { Core } from 'cytoscape';
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 uniformly until measured node and label bounds clear.
5
- * Compound containment is intentional; sibling and unrelated compound intersections are not.
6
- * @performance At most eight synchronous trials and 200,000 pair checks per trial. No layout
7
- * algorithm reruns, no work on zoom, and no intermediate frame is painted. Large, locked, or
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 > 2000 || leaves.filter(':locked').length > 0)
18
+ if (leaves.length < 2 || cy.nodes().length > MAX_NODE_COUNT || cy.nodes(':locked').length > 0)
13
19
  return spacingFactor;
14
- const ancestors = new Map(
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
- if (hasCollisions(cy, ancestors))
21
- return expandGraphSpacing(cy, ancestors, center, positions, spacingFactor);
22
- return compressGraphSpacing(cy, ancestors, center, positions, spacingFactor);
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
- /*** Compress a collision-free settled layout to its smallest measured clearance. */
26
- function compressGraphSpacing(
27
- cy: Core,
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
- /*** Expand a uniformly cramped layout until measured node and label bounds clear. */
46
- function expandGraphSpacing(
47
- cy: Core,
48
- ancestors: ReadonlyMap<string, ReadonlySet<string>>,
49
- center: { x: number; y: number },
50
- positions: ReadonlyMap<string, { x: number; y: number }>,
51
- spacingFactor: number,
52
- ): number {
53
- let low = 1;
54
- let high = 1;
55
- let attempts = 0;
56
- while (attempts < 8) {
57
- high *= 2;
58
- attempts += 1;
59
- applyScale(cy, positions, center, high);
60
- if (!hasCollisions(cy, ancestors)) break;
61
- low = high;
62
- }
63
- if (hasCollisions(cy, ancestors)) {
64
- applyScale(cy, positions, center, 1);
65
- return spacingFactor;
66
- }
67
- for (const _attempt of Array.from({ length: 8 - attempts }, (_, index) => index)) {
68
- const factor = (low + high) / 2;
69
- applyScale(cy, positions, center, factor);
70
- if (hasCollisions(cy, ancestors)) low = factor;
71
- else high = factor;
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
- applyScale(cy, positions, center, high);
74
- return spacingFactor * high;
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
- /*** Transform leaf centers, preserving ordering, dimensions, compound membership and edge data. */
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
- positions: ReadonlyMap<string, { x: number; y: number }>,
81
- center: { x: number; y: number },
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
- // src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.ts
122379
- function compactGraphSpacing(cy, spacingFactor) {
122380
- const leaves = cy.nodes(":childless");
122381
- if (leaves.length < 2 || cy.nodes().length > 2000 || leaves.filter(":locked").length > 0)
122382
- return spacingFactor;
122383
- const ancestors = new Map(cy.nodes().map((node) => [node.id(), new Set(node.ancestors().map((parent) => parent.id()))]));
122384
- const positions = new Map(leaves.map((node) => [node.id(), { ...node.position() }]));
122385
- const bounds = leaves.boundingBox();
122386
- const center = { x: (bounds.x1 + bounds.x2) / 2, y: (bounds.y1 + bounds.y2) / 2 };
122387
- if (hasCollisions(cy, ancestors))
122388
- return expandGraphSpacing(cy, ancestors, center, positions, spacingFactor);
122389
- return compressGraphSpacing(cy, ancestors, center, positions, spacingFactor);
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 compressGraphSpacing(cy, ancestors, center, positions, spacingFactor) {
122392
- const search = { low: Math.min(1, 0.1 / spacingFactor), high: 1 };
122393
- for (const attempt of Array.from({ length: 8 }, (_, index) => index)) {
122394
- const factor = attempt === 0 ? search.low : (search.low + search.high) / 2;
122395
- applyScale(cy, positions, center, factor);
122396
- if (hasCollisions(cy, ancestors))
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
- applyScale(cy, positions, center, search.high);
122404
- return spacingFactor * search.high;
122403
+ return upper;
122405
122404
  }
122406
- function expandGraphSpacing(cy, ancestors, center, positions, spacingFactor) {
122407
- let low = 1;
122408
- let high = 1;
122409
- let attempts = 0;
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 (hasCollisions(cy, ancestors)) {
122419
- applyScale(cy, positions, center, 1);
122420
- return spacingFactor;
122410
+ if (input.minimum > input.maximum) {
122411
+ throw new RangeError("Search minimum must not exceed maximum.");
122421
122412
  }
122422
- for (const _attempt of Array.from({ length: 8 - attempts }, (_, index) => index)) {
122423
- const factor = (low + high) / 2;
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
- function applyScale(cy, positions, center, factor) {
122434
- cy.batch(() => {
122435
- cy.nodes().not(":parent").positions((node) => {
122436
- const original = positions.get(node.id()) ?? node.position();
122437
- return {
122438
- x: center.x + (original.x - center.x) * factor,
122439
- y: center.y + (original.y - center.y) * factor
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 hasCollisions(cy, ancestors) {
122445
- const boxes = cy.nodes().map((node) => ({
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
- box: node.boundingBox({
122448
- includeLabels: true,
122449
- includeOverlays: false,
122450
- includeUnderlays: false
122451
- })
122452
- })).sort((a, b) => a.box.x1 - b.box.x1);
122453
- const budget = { remaining: 200000 };
122454
- for (const [index, first] of boxes.entries()) {
122455
- for (let next = index + 1;next < boxes.length; next += 1) {
122456
- const second = boxes.at(next);
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 + 8)
122518
+ if (second.box.x1 >= first.box.x2)
122460
122519
  break;
122461
- if (--budget.remaining < 0)
122520
+ budget.remaining -= 1;
122521
+ if (budget.remaining < 0)
122462
122522
  return true;
122463
- if (ancestors.get(first.id)?.has(second.id) || ancestors.get(second.id)?.has(first.id))
122523
+ if (isIntentionalContainment(first, second))
122464
122524
  continue;
122465
- if (first.box.y1 < second.box.y2 + 8 && second.box.y1 < first.box.y2 + 8)
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) {