@ankhorage/zora 22.1.1 → 22.1.3

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.d.ts +3 -3
  4. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.js +3 -3
  5. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.js.map +1 -1
  6. package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.d.ts +6 -5
  7. package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.d.ts.map +1 -1
  8. package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.js +107 -171
  9. package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.js.map +1 -1
  10. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.d.ts +4 -0
  11. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.d.ts.map +1 -1
  12. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.js +8 -0
  13. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.js.map +1 -1
  14. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.js +5 -1
  15. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.js.map +1 -1
  16. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.d.ts +9 -0
  17. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.d.ts.map +1 -0
  18. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.js +234 -0
  19. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.js.map +1 -0
  20. package/dist/types/graph-view-spacing.d.ts +15 -0
  21. package/dist/types/graph-view-spacing.d.ts.map +1 -0
  22. package/dist/types/graph-view-spacing.js +2 -0
  23. package/dist/types/graph-view-spacing.js.map +1 -0
  24. package/dist/types/graph-view.d.ts +4 -1
  25. package/dist/types/graph-view.d.ts.map +1 -1
  26. package/dist/types/graph-view.js.map +1 -1
  27. package/package.json +3 -3
  28. package/src/features/graph-view/adapters/inbound/web-artifact/GraphView.tsx +3 -3
  29. package/src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.test.ts +195 -69
  30. package/src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.ts +162 -241
  31. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphController.test.ts +2 -0
  32. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphController.ts +8 -0
  33. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.ts +5 -1
  34. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.ts +338 -0
  35. package/src/types/graph-view-spacing.ts +16 -0
  36. package/src/types/graph-view.ts +4 -1
  37. package/web-dist/components/graph-view/GraphView.d.ts +3 -3
  38. package/web-dist/components/graph-view/GraphView.js +237 -126
  39. package/web-dist/components/graph-view/GraphView.types-0.d.ts +4 -1
@@ -1,273 +1,194 @@
1
- import { findMinimumAcceptedNumber } from '@ankhorage/utility/algorithms';
2
- import type { Core, NodeSingular } from 'cytoscape';
1
+ import type { Core } from 'cytoscape';
2
+
3
+ import type {
4
+ GraphSpacingCandidate,
5
+ GraphSpacingFitOptions,
6
+ } from '../../../../../types/graph-view-spacing';
7
+ import { createGraphSpacingEvaluator } from './createGraphSpacingEvaluator';
3
8
 
4
- const LABEL_OVERLAP_RATIO = 0.12;
5
9
  const MAX_EXPANSION_ATTEMPTS = 8;
6
10
  const MAX_NODE_COUNT = 2000;
7
- const MAX_PAIR_CHECKS = 200000;
11
+ const MAX_RENDERED_BACKGROUND_OVERLAP = 4;
8
12
  const SEARCH_ITERATIONS = 8;
13
+ const ZERO_TOLERANCE = 0.001;
9
14
 
10
15
  /***
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
- * 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.
16
+ * Optimize settled spacing for useful rendered scale instead of minimum model-space distance.
17
+ * A graph already at its fit-size target remains unchanged. When compaction is useful, semantic
18
+ * label/compound collisions stay forbidden; only dense graphs may spend up to four rendered pixels
19
+ * of decorative leaf-background overlap. Locked/large views retain their settled geometry.
15
20
  */
16
- export function compactGraphSpacing(cy: Core, spacingFactor: number): number {
17
- const leaves = cy.nodes(':childless');
18
- if (leaves.length < 2 || cy.nodes().length > MAX_NODE_COUNT || cy.nodes(':locked').length > 0)
21
+ export function compactGraphSpacing(
22
+ cy: Core,
23
+ spacingFactor: number,
24
+ options: GraphSpacingFitOptions = {},
25
+ ): number {
26
+ if (
27
+ spacingFactor <= 0 ||
28
+ cy.nodes(':childless').length < 2 ||
29
+ cy.nodes().length > MAX_NODE_COUNT ||
30
+ cy.nodes(':locked').length > 0
31
+ )
19
32
  return spacingFactor;
20
33
 
21
- const bounds = leaves.boundingBox();
22
- const center = { x: (bounds.x1 + bounds.x2) / 2, y: (bounds.y1 + bounds.y2) / 2 };
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);
34
+ const evaluator = createGraphSpacingEvaluator(cy, options);
35
+ if (evaluator === null) return spacingFactor;
36
+ const current = evaluator.evaluate(1);
37
+ const maxFitZoom = options.maxFitZoom ?? Infinity;
38
+ const minReadableZoom = options.minReadableZoom ?? 0;
39
+ const factor = needsExpansion(current)
40
+ ? chooseExpansionFactor(evaluator.evaluate, minReadableZoom)
41
+ : chooseCompactionFactor(
42
+ evaluator.evaluate,
43
+ spacingFactor,
44
+ maxFitZoom,
45
+ minReadableZoom,
46
+ current,
47
+ );
48
+ if (factor === 1) return spacingFactor;
49
+
50
+ evaluator.apply(factor);
48
51
  return spacingFactor * factor;
49
52
  }
50
53
 
51
- interface Point {
52
- readonly x: number;
53
- readonly y: number;
54
- }
55
-
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
- }
54
+ /*** Choose a smaller factor only while it improves rendered fit or reaches the configured cap. */
55
+ function chooseCompactionFactor(
56
+ evaluate: (factor: number) => GraphSpacingCandidate,
57
+ spacingFactor: number,
58
+ maxFitZoom: number,
59
+ minReadableZoom: number,
60
+ current: GraphSpacingCandidate,
61
+ ): number {
62
+ if (reachesFitTarget(current, maxFitZoom)) return 1;
63
+
64
+ const minimum = Math.min(1, 0.1 / spacingFactor);
65
+ if (current.renderedBackgroundOverlap > ZERO_TOLERANCE) {
66
+ if (reachesReadableTarget(current, minReadableZoom)) return 1;
67
+ return chooseSoftCompactionFactor(evaluate, minimum, 1, minReadableZoom);
68
+ }
138
69
 
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),
70
+ const strictMinimum = findMinimumAcceptedFactor(minimum, 1, (factor) =>
71
+ isStrictCandidate(evaluate(factor)),
72
+ );
73
+ if (strictMinimum === undefined) return 1;
74
+
75
+ const strict = evaluate(strictMinimum);
76
+ if (reachesFitTarget(strict, maxFitZoom)) {
77
+ return (
78
+ findMaximumAcceptedFactor(strictMinimum, 1, (factor) => {
79
+ const candidate = evaluate(factor);
80
+ return isStrictCandidate(candidate) && reachesFitTarget(candidate, maxFitZoom);
81
+ }) ?? strictMinimum
155
82
  );
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
- }
83
+ }
84
+ if (reachesReadableTarget(strict, minReadableZoom)) return strictMinimum;
169
85
 
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
- );
86
+ return chooseSoftCompactionFactor(evaluate, minimum, strictMinimum, minReadableZoom);
175
87
  }
176
88
 
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
- }
194
- }
195
- return false;
89
+ /*** Spend background-overlap budget only to recover the configured readable-label threshold. */
90
+ function chooseSoftCompactionFactor(
91
+ evaluate: (factor: number) => GraphSpacingCandidate,
92
+ minimum: number,
93
+ maximum: number,
94
+ minReadableZoom: number,
95
+ ): number {
96
+ const softMinimum = findMinimumAcceptedFactor(minimum, maximum, (factor) =>
97
+ isSoftCandidate(evaluate(factor)),
98
+ );
99
+ if (softMinimum === undefined) return maximum;
100
+
101
+ const soft = evaluate(softMinimum);
102
+ if (!reachesReadableTarget(soft, minReadableZoom)) return softMinimum;
103
+ return (
104
+ findMaximumAcceptedFactor(softMinimum, maximum, (factor) => {
105
+ const candidate = evaluate(factor);
106
+ return isSoftCandidate(candidate) && reachesReadableTarget(candidate, minReadableZoom);
107
+ }) ?? softMinimum
108
+ );
196
109
  }
197
110
 
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);
111
+ /*** Expand only enough to restore strict geometry unless that would sacrifice readable labels. */
112
+ function chooseExpansionFactor(
113
+ evaluate: (factor: number) => GraphSpacingCandidate,
114
+ minReadableZoom: number,
115
+ ): number {
116
+ const strict = findExpansionFactor((factor) => isStrictCandidate(evaluate(factor)));
117
+ if (strict !== undefined && reachesReadableTarget(evaluate(strict), minReadableZoom))
118
+ return strict;
119
+ return findExpansionFactor((factor) => isSoftCandidate(evaluate(factor))) ?? strict ?? 1;
201
120
  }
202
121
 
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;
122
+ /*** Return whether a candidate is collision-free, including decorative leaf backgrounds. */
123
+ function isStrictCandidate(candidate: GraphSpacingCandidate): boolean {
124
+ return !candidate.hardCollision && candidate.renderedBackgroundOverlap <= ZERO_TOLERANCE;
214
125
  }
215
126
 
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
- };
127
+ /*** Allow only bounded rendered-pixel background overlap as a dense-graph fallback. */
128
+ function isSoftCandidate(candidate: GraphSpacingCandidate): boolean {
129
+ return (
130
+ !candidate.hardCollision &&
131
+ candidate.renderedBackgroundOverlap <= MAX_RENDERED_BACKGROUND_OVERLAP + ZERO_TOLERANCE
132
+ );
228
133
  }
229
134
 
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 };
135
+ /*** Detect whether current geometry must expand before compaction can be considered. */
136
+ function needsExpansion(candidate: GraphSpacingCandidate): boolean {
137
+ return candidate.hardCollision || !isSoftCandidate(candidate);
237
138
  }
238
139
 
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 };
140
+ /*** Treat a finite fit-size ceiling as the point after which tighter spacing cannot improve output. */
141
+ function reachesFitTarget(candidate: GraphSpacingCandidate, maxFitZoom: number): boolean {
142
+ if (!Number.isFinite(maxFitZoom)) return false;
143
+ return candidate.effectiveFitZoom >= maxFitZoom - ZERO_TOLERANCE;
247
144
  }
248
145
 
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;
146
+ /*** Return whether full-graph fit keeps semantic labels at the configured readable threshold. */
147
+ function reachesReadableTarget(candidate: GraphSpacingCandidate, minReadableZoom: number): boolean {
148
+ return minReadableZoom <= 0 || candidate.effectiveFitZoom >= minReadableZoom - ZERO_TOLERANCE;
252
149
  }
253
150
 
254
- /*** Apply the accepted factor once, preserving node dimensions, hierarchy, ordering and edge data. */
255
- function applyScale(
256
- cy: Core,
257
- leaves: readonly LeafGeometry[],
258
- center: Point,
259
- factor: number,
260
- ): void {
261
- const positions = new Map(leaves.map((leaf) => [leaf.id, leaf.position]));
262
- cy.batch(() => {
263
- cy.nodes()
264
- .not(':parent')
265
- .positions((node) => {
266
- const original = positions.get(node.id()) ?? node.position();
267
- return {
268
- x: center.x + (original.x - center.x) * factor,
269
- y: center.y + (original.y - center.y) * factor,
270
- };
271
- });
272
- });
151
+ /*** Binary-search the smallest factor accepted by a monotonic spacing constraint. */
152
+ function findMinimumAcceptedFactor(
153
+ minimum: number,
154
+ maximum: number,
155
+ accepts: (factor: number) => boolean,
156
+ ): number | undefined {
157
+ if (!accepts(maximum)) return undefined;
158
+ if (accepts(minimum)) return minimum;
159
+ return Array.from({ length: SEARCH_ITERATIONS }).reduce<{ low: number; high: number }>(
160
+ (range) => {
161
+ const factor = (range.low + range.high) / 2;
162
+ return accepts(factor) ? { low: range.low, high: factor } : { low: factor, high: range.high };
163
+ },
164
+ { low: minimum, high: maximum },
165
+ ).high;
166
+ }
167
+
168
+ /*** Binary-search the largest factor that still reaches a target under its collision policy. */
169
+ function findMaximumAcceptedFactor(
170
+ minimum: number,
171
+ maximum: number,
172
+ accepts: (factor: number) => boolean,
173
+ ): number | undefined {
174
+ if (!accepts(minimum)) return undefined;
175
+ if (accepts(maximum)) return maximum;
176
+ return Array.from({ length: SEARCH_ITERATIONS }).reduce<{ low: number; high: number }>(
177
+ (range) => {
178
+ const factor = (range.low + range.high) / 2;
179
+ return accepts(factor) ? { low: factor, high: range.high } : { low: range.low, high: factor };
180
+ },
181
+ { low: minimum, high: maximum },
182
+ ).low;
183
+ }
184
+
185
+ /*** Find the first bounded expansion threshold and refine it without unbounded layout work. */
186
+ function findExpansionFactor(accepts: (factor: number) => boolean): number | undefined {
187
+ if (accepts(1)) return 1;
188
+ const maximum = Array.from(
189
+ { length: MAX_EXPANSION_ATTEMPTS },
190
+ (_, index) => 2 ** (index + 1),
191
+ ).find(accepts);
192
+ if (maximum === undefined) return undefined;
193
+ return findMinimumAcceptedFactor(maximum / 2, maximum, accepts);
273
194
  }
@@ -92,6 +92,8 @@ test('caps automatic label size for tiny graphs and expands manual zoom for larg
92
92
  try {
93
93
  owner.settle(true);
94
94
  expect(cy.zoom() * 14).toBeCloseTo(24);
95
+ expect(owner.getFitZoomLimit()).toBeCloseTo(24 / 14);
96
+ expect(owner.getReadableZoomTarget()).toBeCloseTo(16 / 14);
95
97
  expect(owner.controller.getViewport().zoom).toBeCloseTo(1);
96
98
  cy.add({ data: { id: 'b' }, position: { x: 10000, y: 0 } });
97
99
  owner.settle(true);
@@ -70,6 +70,14 @@ export function createGraphController(
70
70
  if (!state.relative) state.scale = 1;
71
71
  applyZoomLimits(cy, state);
72
72
  },
73
+ /*** Return the native automatic-fit ceiling used by spacing optimization. */
74
+ getFitZoomLimit() {
75
+ return state.relative ? state.fitZoom : Math.min(state.fitZoom, state.max);
76
+ },
77
+ /*** Return the native label-readability threshold used before allowing background overlap. */
78
+ getReadableZoomTarget() {
79
+ return state.readableZoom;
80
+ },
73
81
  settle(fit: boolean) {
74
82
  const logicalZoom = cy.zoom() / state.scale;
75
83
  const fonts = cy
@@ -276,7 +276,11 @@ function requestOptimizedFit(state: GraphRuntimeState, options: GraphViewFitOpti
276
276
  const input = state.latestUpdateRef.current;
277
277
  if (!input) return;
278
278
  const previous = state.optimizedSpacingRef.current ?? input.spacingFactor ?? 1;
279
- const spacing = compactGraphSpacing(state.cy, previous);
279
+ const spacing = compactGraphSpacing(state.cy, previous, {
280
+ fitPadding: options.padding ?? state.fitPaddingRef.current,
281
+ maxFitZoom: state.viewport.getFitZoomLimit(),
282
+ minReadableZoom: state.viewport.getReadableZoomTarget(),
283
+ });
280
284
  state.geometryRef.current = getGraphGeometryKey(state.cy);
281
285
  state.loopSizer.update();
282
286
  state.viewport.settle(false);