@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.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.d.ts +3 -3
- package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.js +3 -3
- package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.js.map +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.d.ts +6 -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 +107 -171
- package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.js.map +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.d.ts +4 -0
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.d.ts.map +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.js +8 -0
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.js.map +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.js +5 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.js.map +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.d.ts +9 -0
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.d.ts.map +1 -0
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.js +234 -0
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.js.map +1 -0
- package/dist/types/graph-view-spacing.d.ts +15 -0
- package/dist/types/graph-view-spacing.d.ts.map +1 -0
- package/dist/types/graph-view-spacing.js +2 -0
- package/dist/types/graph-view-spacing.js.map +1 -0
- package/dist/types/graph-view.d.ts +4 -1
- package/dist/types/graph-view.d.ts.map +1 -1
- package/dist/types/graph-view.js.map +1 -1
- package/package.json +3 -3
- package/src/features/graph-view/adapters/inbound/web-artifact/GraphView.tsx +3 -3
- package/src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.test.ts +195 -69
- package/src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.ts +162 -241
- package/src/features/graph-view/adapters/inbound/web-artifact/createGraphController.test.ts +2 -0
- package/src/features/graph-view/adapters/inbound/web-artifact/createGraphController.ts +8 -0
- package/src/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.ts +5 -1
- package/src/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.ts +338 -0
- package/src/types/graph-view-spacing.ts +16 -0
- package/src/types/graph-view.ts +4 -1
- package/web-dist/components/graph-view/GraphView.d.ts +3 -3
- package/web-dist/components/graph-view/GraphView.js +237 -126
- package/web-dist/components/graph-view/GraphView.types-0.d.ts +4 -1
|
@@ -1,273 +1,194 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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(
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
/***
|
|
199
|
-
function
|
|
200
|
-
|
|
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
|
-
/***
|
|
204
|
-
function
|
|
205
|
-
|
|
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
|
-
/***
|
|
217
|
-
function
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
/***
|
|
231
|
-
function
|
|
232
|
-
|
|
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
|
-
/***
|
|
240
|
-
function
|
|
241
|
-
if (
|
|
242
|
-
|
|
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
|
|
250
|
-
function
|
|
251
|
-
return
|
|
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
|
-
/***
|
|
255
|
-
function
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
.
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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);
|