@ankhorage/zora 25.11.0 → 25.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.d.ts +2 -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 +68 -4
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.js.map +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.d.ts +3 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.d.ts.map +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.js +41 -8
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.js.map +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.d.ts.map +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.js +6 -2
- package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.js.map +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/shouldFitGraphAfterLayout.d.ts +2 -2
- package/dist/features/graph-view/adapters/inbound/web-artifact/shouldFitGraphAfterLayout.d.ts.map +1 -1
- package/dist/features/graph-view/adapters/inbound/web-artifact/shouldFitGraphAfterLayout.js +3 -3
- package/dist/features/graph-view/adapters/inbound/web-artifact/shouldFitGraphAfterLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/features/graph-view/adapters/inbound/web-artifact/createGraphController.test.ts +145 -1
- package/src/features/graph-view/adapters/inbound/web-artifact/createGraphController.ts +86 -5
- package/src/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.test.ts +163 -6
- package/src/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.ts +57 -8
- package/src/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.test.ts +42 -0
- package/src/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.ts +7 -1
- package/src/features/graph-view/adapters/inbound/web-artifact/shouldFitGraphAfterLayout.test.ts +7 -3
- package/src/features/graph-view/adapters/inbound/web-artifact/shouldFitGraphAfterLayout.ts +7 -3
- package/web-dist/components/graph-view/GraphView.js +107 -17
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test } from 'bun:test';
|
|
2
|
-
import cytoscape from 'cytoscape';
|
|
2
|
+
import cytoscape, { type Core } from 'cytoscape';
|
|
3
3
|
|
|
4
4
|
import { createGraphController } from './createGraphController';
|
|
5
5
|
|
|
@@ -68,6 +68,123 @@ test('keeps absolute zoom semantics for consumers that do not opt in', () => {
|
|
|
68
68
|
}
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
test('preserves manual pan while recomputing a fit-relative zoom reference', () => {
|
|
72
|
+
const cy = cytoscape({
|
|
73
|
+
headless: true,
|
|
74
|
+
elements: [{ data: { id: 'a' } }, { data: { id: 'b' }, position: { x: 300, y: 200 } }],
|
|
75
|
+
});
|
|
76
|
+
cy.width = () => 800;
|
|
77
|
+
cy.height = () => 600;
|
|
78
|
+
const owner = createGraphController(cy, { current: 50 });
|
|
79
|
+
owner.configure({ nodes: [], edges: [], richNodeRendering: false, zoomMode: 'fit-relative' });
|
|
80
|
+
try {
|
|
81
|
+
owner.settle(true);
|
|
82
|
+
owner.controller.setPan({ x: 140, y: 90 });
|
|
83
|
+
owner.settle(false);
|
|
84
|
+
expect(owner.controller.getViewport().pan).toEqual({ x: 140, y: 90 });
|
|
85
|
+
|
|
86
|
+
// Atlas #332: selecting a compound must not recenter a fit-relative viewport.
|
|
87
|
+
const atlasViewport = { x: -331.42857142857133, y: -222.85714285714278 };
|
|
88
|
+
owner.controller.setPan(atlasViewport);
|
|
89
|
+
cy.getElementById('a').select();
|
|
90
|
+
owner.settle(false);
|
|
91
|
+
expect(owner.controller.getViewport().pan).toEqual(atlasViewport);
|
|
92
|
+
} finally {
|
|
93
|
+
cy.destroy();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('preserves the manual fit-relative center through grow and sidebar resizes', () => {
|
|
98
|
+
let width = 800;
|
|
99
|
+
let height = 600;
|
|
100
|
+
const cy = cytoscape({
|
|
101
|
+
headless: true,
|
|
102
|
+
elements: [
|
|
103
|
+
{ data: { id: 'a' }, position: { x: 0, y: 0 } },
|
|
104
|
+
{ data: { id: 'b' }, position: { x: 100, y: 80 } },
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
cy.width = () => width;
|
|
108
|
+
cy.height = () => height;
|
|
109
|
+
const owner = createGraphController(cy, { current: 0 });
|
|
110
|
+
owner.configure({ nodes: [], edges: [], richNodeRendering: false, zoomMode: 'fit-relative' });
|
|
111
|
+
try {
|
|
112
|
+
owner.settle(true);
|
|
113
|
+
owner.controller.setZoom(0.5);
|
|
114
|
+
owner.controller.setPan({ x: 330, y: 250 });
|
|
115
|
+
const center = getViewportModelCenter(cy, width, height);
|
|
116
|
+
|
|
117
|
+
owner.beginResize();
|
|
118
|
+
width = 1000;
|
|
119
|
+
height = 700;
|
|
120
|
+
owner.settle(false);
|
|
121
|
+
expect(owner.controller.getViewport().zoom).toBeCloseTo(0.5);
|
|
122
|
+
expectViewportModelCenter(cy, width, height, center);
|
|
123
|
+
|
|
124
|
+
owner.beginResize();
|
|
125
|
+
width = 700;
|
|
126
|
+
owner.settle(false);
|
|
127
|
+
expect(owner.controller.getViewport().zoom).toBeCloseTo(0.5);
|
|
128
|
+
expectViewportModelCenter(cy, width, height, center);
|
|
129
|
+
} finally {
|
|
130
|
+
cy.destroy();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('fits only when a shrink clips graph bounds that were previously visible', () => {
|
|
135
|
+
let width = 800;
|
|
136
|
+
const cy = cytoscape({
|
|
137
|
+
headless: true,
|
|
138
|
+
styleEnabled: true,
|
|
139
|
+
layout: { name: 'preset' },
|
|
140
|
+
elements: [
|
|
141
|
+
{ data: { id: 'a' }, position: { x: 0, y: 0 } },
|
|
142
|
+
{ data: { id: 'b' }, position: { x: 300, y: 200 } },
|
|
143
|
+
],
|
|
144
|
+
style: [{ selector: 'node', style: { height: 40, width: 40 } }],
|
|
145
|
+
});
|
|
146
|
+
cy.width = () => width;
|
|
147
|
+
cy.height = () => 600;
|
|
148
|
+
const owner = createGraphController(cy, { current: 0 });
|
|
149
|
+
owner.configure({ nodes: [], edges: [], richNodeRendering: false });
|
|
150
|
+
try {
|
|
151
|
+
owner.settle(true);
|
|
152
|
+
owner.beginResize();
|
|
153
|
+
width = 200;
|
|
154
|
+
owner.settle(false);
|
|
155
|
+
expectRenderedNodesToFit(cy, width, 600);
|
|
156
|
+
} finally {
|
|
157
|
+
cy.destroy();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('keeps an intentionally clipped manual absolute viewport instead of auto-fitting it', () => {
|
|
162
|
+
let width = 800;
|
|
163
|
+
const cy = cytoscape({
|
|
164
|
+
headless: true,
|
|
165
|
+
elements: [
|
|
166
|
+
{ data: { id: 'a' }, position: { x: 0, y: 0 } },
|
|
167
|
+
{ data: { id: 'b' }, position: { x: 300, y: 200 } },
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
cy.width = () => width;
|
|
171
|
+
cy.height = () => 600;
|
|
172
|
+
const owner = createGraphController(cy, { current: 0 });
|
|
173
|
+
owner.configure({ nodes: [], edges: [], richNodeRendering: false });
|
|
174
|
+
try {
|
|
175
|
+
owner.settle(true);
|
|
176
|
+
owner.controller.setPan({ x: -600, y: -400 });
|
|
177
|
+
const center = getViewportModelCenter(cy, width, 600);
|
|
178
|
+
owner.beginResize();
|
|
179
|
+
width = 700;
|
|
180
|
+
owner.settle(false);
|
|
181
|
+
expectViewportModelCenter(cy, width, 600, center);
|
|
182
|
+
expect(cy.nodes().renderedBoundingBox().x2).toBeLessThan(0);
|
|
183
|
+
} finally {
|
|
184
|
+
cy.destroy();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
71
188
|
test('caps automatic label size for tiny graphs and expands manual zoom for large graphs', () => {
|
|
72
189
|
const cy = cytoscape({
|
|
73
190
|
headless: true,
|
|
@@ -146,3 +263,30 @@ test('measures requested rendered nodes without exposing Cytoscape', () => {
|
|
|
146
263
|
cy.destroy();
|
|
147
264
|
}
|
|
148
265
|
});
|
|
266
|
+
|
|
267
|
+
function getViewportModelCenter(cy: Core, width: number, height: number) {
|
|
268
|
+
const pan = cy.pan();
|
|
269
|
+
return {
|
|
270
|
+
x: (width / 2 - pan.x) / cy.zoom(),
|
|
271
|
+
y: (height / 2 - pan.y) / cy.zoom(),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function expectRenderedNodesToFit(cy: Core, width: number, height: number) {
|
|
276
|
+
const bounds = cy.nodes().renderedBoundingBox();
|
|
277
|
+
expect(bounds.x1).toBeGreaterThanOrEqual(0);
|
|
278
|
+
expect(bounds.y1).toBeGreaterThanOrEqual(0);
|
|
279
|
+
expect(bounds.x2).toBeLessThanOrEqual(width);
|
|
280
|
+
expect(bounds.y2).toBeLessThanOrEqual(height);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function expectViewportModelCenter(
|
|
284
|
+
cy: Core,
|
|
285
|
+
width: number,
|
|
286
|
+
height: number,
|
|
287
|
+
expected: { readonly x: number; readonly y: number },
|
|
288
|
+
) {
|
|
289
|
+
const actual = getViewportModelCenter(cy, width, height);
|
|
290
|
+
expect(actual.x).toBeCloseTo(expected.x);
|
|
291
|
+
expect(actual.y).toBeCloseTo(expected.y);
|
|
292
|
+
}
|
|
@@ -23,6 +23,7 @@ export function createGraphController(
|
|
|
23
23
|
fitSize: Infinity,
|
|
24
24
|
readableZoom: 0,
|
|
25
25
|
fitZoom: Infinity,
|
|
26
|
+
resizeSnapshot: null as GraphResizeSnapshot | null,
|
|
26
27
|
};
|
|
27
28
|
const controller: GraphViewController = {
|
|
28
29
|
fit(options) {
|
|
@@ -78,6 +79,10 @@ export function createGraphController(
|
|
|
78
79
|
getReadableZoomTarget() {
|
|
79
80
|
return state.readableZoom;
|
|
80
81
|
},
|
|
82
|
+
/*** Capture the model point beneath the current viewport center before Cytoscape resizes. */
|
|
83
|
+
beginResize() {
|
|
84
|
+
state.resizeSnapshot = captureResizeSnapshot(cy);
|
|
85
|
+
},
|
|
81
86
|
settle(fit: boolean) {
|
|
82
87
|
const logicalZoom = cy.zoom() / state.scale;
|
|
83
88
|
const fonts = cy
|
|
@@ -95,15 +100,91 @@ export function createGraphController(
|
|
|
95
100
|
? Math.min(getNodeFitZoom(cy, fitPaddingRef.current), state.fitZoom)
|
|
96
101
|
: 1;
|
|
97
102
|
applyZoomLimits(cy, state);
|
|
98
|
-
if (fit)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
+
if (fit) {
|
|
104
|
+
state.resizeSnapshot = null;
|
|
105
|
+
controller.fit();
|
|
106
|
+
} else if (settlePreservedViewport(cy, state, logicalZoom)) controller.fit();
|
|
103
107
|
},
|
|
104
108
|
};
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
interface GraphResizeSnapshot {
|
|
112
|
+
readonly center: { readonly x: number; readonly y: number };
|
|
113
|
+
readonly nodeBounds: GraphNodeBoundsStatus;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type GraphNodeBoundsStatus = 'clipped' | 'empty' | 'fit' | 'unusable';
|
|
117
|
+
|
|
118
|
+
/*** Capture the model-space center and whether the prior viewport was an intact graph fit. */
|
|
119
|
+
function captureResizeSnapshot(cy: Core): GraphResizeSnapshot | null {
|
|
120
|
+
const viewport = { width: cy.width(), height: cy.height() };
|
|
121
|
+
const zoom = cy.zoom();
|
|
122
|
+
if (!isUsableViewport(viewport) || !Number.isFinite(zoom) || zoom <= 0) return null;
|
|
123
|
+
const pan = cy.pan();
|
|
124
|
+
const renderedCenter = { x: viewport.width / 2, y: viewport.height / 2 };
|
|
125
|
+
return {
|
|
126
|
+
center: {
|
|
127
|
+
x: (renderedCenter.x - pan.x) / zoom,
|
|
128
|
+
y: (renderedCenter.y - pan.y) / zoom,
|
|
129
|
+
},
|
|
130
|
+
nodeBounds: getNodeBoundsStatus(cy, viewport),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/*** Preserve a manual viewport through resize, fitting only when an intact prior fit becomes invalid. */
|
|
135
|
+
function settlePreservedViewport(
|
|
136
|
+
cy: Core,
|
|
137
|
+
state: {
|
|
138
|
+
readonly relative: boolean;
|
|
139
|
+
resizeSnapshot: GraphResizeSnapshot | null;
|
|
140
|
+
readonly scale: number;
|
|
141
|
+
},
|
|
142
|
+
logicalZoom: number,
|
|
143
|
+
): boolean {
|
|
144
|
+
const snapshot = state.resizeSnapshot;
|
|
145
|
+
state.resizeSnapshot = null;
|
|
146
|
+
const zoom = state.relative ? logicalZoom * state.scale : cy.zoom();
|
|
147
|
+
if (snapshot === null) {
|
|
148
|
+
if (state.relative) setCenteredZoom(cy, zoom);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
const viewport = { width: cy.width(), height: cy.height() };
|
|
152
|
+
if (!isUsableViewport(viewport)) return false;
|
|
153
|
+
cy.viewport({
|
|
154
|
+
zoom,
|
|
155
|
+
pan: {
|
|
156
|
+
x: viewport.width / 2 - snapshot.center.x * zoom,
|
|
157
|
+
y: viewport.height / 2 - snapshot.center.y * zoom,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
const nodeBounds = getNodeBoundsStatus(cy, viewport);
|
|
161
|
+
return nodeBounds === 'unusable' || (snapshot.nodeBounds === 'fit' && nodeBounds === 'clipped');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/*** Accept only finite non-empty node bounds that are wholly visible in a usable viewport. */
|
|
165
|
+
function getNodeBoundsStatus(
|
|
166
|
+
cy: Core,
|
|
167
|
+
viewport: { readonly width: number; readonly height: number },
|
|
168
|
+
): GraphNodeBoundsStatus {
|
|
169
|
+
if (cy.nodes().empty()) return 'empty';
|
|
170
|
+
if (!isUsableViewport(viewport)) return 'unusable';
|
|
171
|
+
const bounds = cy.nodes().renderedBoundingBox();
|
|
172
|
+
if (![bounds.x1, bounds.x2, bounds.y1, bounds.y2].every(Number.isFinite)) return 'unusable';
|
|
173
|
+
const fitsHorizontally = bounds.x1 >= 0 && bounds.x2 <= viewport.width;
|
|
174
|
+
const fitsVertically = bounds.y1 >= 0 && bounds.y2 <= viewport.height;
|
|
175
|
+
return fitsHorizontally && fitsVertically ? 'fit' : 'clipped';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/*** Reject zero, negative, and non-finite renderer dimensions before viewport arithmetic. */
|
|
179
|
+
function isUsableViewport(viewport: { readonly width: number; readonly height: number }): boolean {
|
|
180
|
+
return (
|
|
181
|
+
Number.isFinite(viewport.width) &&
|
|
182
|
+
Number.isFinite(viewport.height) &&
|
|
183
|
+
viewport.width > 0 &&
|
|
184
|
+
viewport.height > 0
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
107
188
|
/*** Measure rendered node geometry for public consumers while keeping Cytoscape private. */
|
|
108
189
|
function measureRenderedNodes(cy: Core, nodeIds: readonly string[]): GraphViewMeasurement | null {
|
|
109
190
|
const viewport = { width: cy.width(), height: cy.height() };
|
package/src/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.test.ts
CHANGED
|
@@ -18,7 +18,7 @@ test('settles a pending initial fit only after the viewport becomes usable', ()
|
|
|
18
18
|
let callback: ResizeCallback | null = null;
|
|
19
19
|
let width = 0;
|
|
20
20
|
let height = 0;
|
|
21
|
-
const calls = { resize: 0, settle:
|
|
21
|
+
const calls = { resize: 0, settle: [] as boolean[], completedPending: [] as boolean[] };
|
|
22
22
|
const observerCalls = { disconnect: 0, observe: 0 };
|
|
23
23
|
|
|
24
24
|
class FakeResizeObserver {
|
|
@@ -57,8 +57,9 @@ test('settles a pending initial fit only after the viewport becomes usable', ()
|
|
|
57
57
|
const observer = createGraphResizeObserver({
|
|
58
58
|
container: {},
|
|
59
59
|
cy,
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
beginViewportResize: () => undefined,
|
|
61
|
+
settleViewport: (fit) => {
|
|
62
|
+
calls.settle.push(fit);
|
|
62
63
|
},
|
|
63
64
|
layoutRunningRef,
|
|
64
65
|
pendingInitialFitRef,
|
|
@@ -71,12 +72,12 @@ test('settles a pending initial fit only after the viewport becomes usable', ()
|
|
|
71
72
|
expect(observerCalls.observe).toBe(1);
|
|
72
73
|
|
|
73
74
|
callback?.([{ contentRect: { height: 0, width: 0 } }]);
|
|
74
|
-
expect(calls).toEqual({ resize: 0, settle:
|
|
75
|
+
expect(calls).toEqual({ resize: 0, settle: [], completedPending: [] });
|
|
75
76
|
|
|
76
77
|
pendingInitialFitRef.current = true;
|
|
77
78
|
callback?.([{ contentRect: { height: 1, width: 1 } }]);
|
|
78
79
|
expect(calls.resize).toBe(1);
|
|
79
|
-
expect(calls.settle).
|
|
80
|
+
expect(calls.settle).toEqual([]);
|
|
80
81
|
expect(pendingInitialFitRef.current).toBe(true);
|
|
81
82
|
|
|
82
83
|
width = 640;
|
|
@@ -84,7 +85,7 @@ test('settles a pending initial fit only after the viewport becomes usable', ()
|
|
|
84
85
|
callback?.([{ contentRect: { height, width } }]);
|
|
85
86
|
|
|
86
87
|
expect(calls.resize).toBe(2);
|
|
87
|
-
expect(calls.settle).
|
|
88
|
+
expect(calls.settle).toEqual([true]);
|
|
88
89
|
expect(calls.completedPending).toEqual([true]);
|
|
89
90
|
expect(pendingInitialFitRef.current).toBe(false);
|
|
90
91
|
|
|
@@ -97,3 +98,159 @@ test('settles a pending initial fit only after the viewport becomes usable', ()
|
|
|
97
98
|
else globals.requestAnimationFrame = previousFrame;
|
|
98
99
|
}
|
|
99
100
|
});
|
|
101
|
+
|
|
102
|
+
test('preserves the settled viewport on ordinary resizes', () => {
|
|
103
|
+
interface ResizeEntry {
|
|
104
|
+
readonly contentRect: { readonly height: number; readonly width: number };
|
|
105
|
+
}
|
|
106
|
+
type ResizeCallback = (entries: readonly ResizeEntry[]) => void;
|
|
107
|
+
|
|
108
|
+
const globals = globalThis as unknown as {
|
|
109
|
+
ResizeObserver?: unknown;
|
|
110
|
+
requestAnimationFrame?: (callback: () => void) => number;
|
|
111
|
+
};
|
|
112
|
+
const previousObserver = globals.ResizeObserver;
|
|
113
|
+
const previousFrame = globals.requestAnimationFrame;
|
|
114
|
+
let callback: ResizeCallback | null = null;
|
|
115
|
+
const calls = { settle: [] as boolean[] };
|
|
116
|
+
const frames: (() => void)[] = [];
|
|
117
|
+
|
|
118
|
+
class FakeResizeObserver {
|
|
119
|
+
constructor(next: ResizeCallback) {
|
|
120
|
+
callback = next;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
observe() {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
disconnect() {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
globals.ResizeObserver = FakeResizeObserver;
|
|
133
|
+
globals.requestAnimationFrame = (scheduled) => {
|
|
134
|
+
frames.push(scheduled);
|
|
135
|
+
return frames.length;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
createGraphResizeObserver({
|
|
140
|
+
container: {},
|
|
141
|
+
cy: {
|
|
142
|
+
destroyed: () => false,
|
|
143
|
+
height: () => 480,
|
|
144
|
+
resize: () => undefined,
|
|
145
|
+
width: () => 640,
|
|
146
|
+
} as unknown as Core,
|
|
147
|
+
beginViewportResize: () => undefined,
|
|
148
|
+
settleViewport: (fit) => calls.settle.push(fit),
|
|
149
|
+
layoutRunningRef: { current: false },
|
|
150
|
+
pendingInitialFitRef: { current: false },
|
|
151
|
+
readyRef: { current: true },
|
|
152
|
+
onViewportSettled: () => undefined,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
callback?.([{ contentRect: { height: 480, width: 640 } }]);
|
|
156
|
+
callback?.([{ contentRect: { height: 600, width: 800 } }]);
|
|
157
|
+
expect(frames).toHaveLength(1);
|
|
158
|
+
frames[0]?.();
|
|
159
|
+
expect(calls.settle).toEqual([false]);
|
|
160
|
+
} finally {
|
|
161
|
+
if (previousObserver === undefined) delete globals.ResizeObserver;
|
|
162
|
+
else globals.ResizeObserver = previousObserver;
|
|
163
|
+
if (previousFrame === undefined) delete globals.requestAnimationFrame;
|
|
164
|
+
else globals.requestAnimationFrame = previousFrame;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('transfers a queued manual viewport resize to a newly started unchanged layout', () => {
|
|
169
|
+
interface ResizeEntry {
|
|
170
|
+
readonly contentRect: { readonly height: number; readonly width: number };
|
|
171
|
+
}
|
|
172
|
+
type ResizeCallback = (entries: readonly ResizeEntry[]) => void;
|
|
173
|
+
|
|
174
|
+
const globals = globalThis as unknown as {
|
|
175
|
+
ResizeObserver?: unknown;
|
|
176
|
+
requestAnimationFrame?: (callback: () => void) => number;
|
|
177
|
+
};
|
|
178
|
+
const previousObserver = globals.ResizeObserver;
|
|
179
|
+
const previousFrame = globals.requestAnimationFrame;
|
|
180
|
+
let callback: ResizeCallback | null = null;
|
|
181
|
+
const frames: (() => void)[] = [];
|
|
182
|
+
const calls = {
|
|
183
|
+
begin: [] as {
|
|
184
|
+
readonly pan: { readonly x: number; readonly y: number };
|
|
185
|
+
readonly zoom: number;
|
|
186
|
+
}[],
|
|
187
|
+
resize: 0,
|
|
188
|
+
settle: [] as boolean[],
|
|
189
|
+
};
|
|
190
|
+
const layoutRunningRef = { current: false };
|
|
191
|
+
const manualViewport = { pan: { x: 140, y: 90 }, zoom: 0.5 };
|
|
192
|
+
|
|
193
|
+
class FakeResizeObserver {
|
|
194
|
+
constructor(next: ResizeCallback) {
|
|
195
|
+
callback = next;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
observe() {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
disconnect() {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
globals.ResizeObserver = FakeResizeObserver;
|
|
208
|
+
globals.requestAnimationFrame = (scheduled) => {
|
|
209
|
+
frames.push(scheduled);
|
|
210
|
+
return frames.length;
|
|
211
|
+
};
|
|
212
|
+
try {
|
|
213
|
+
const observer = createGraphResizeObserver({
|
|
214
|
+
container: {},
|
|
215
|
+
cy: {
|
|
216
|
+
destroyed: () => false,
|
|
217
|
+
height: () => 480,
|
|
218
|
+
resize: () => {
|
|
219
|
+
calls.resize += 1;
|
|
220
|
+
},
|
|
221
|
+
width: () => 640,
|
|
222
|
+
} as unknown as Core,
|
|
223
|
+
beginViewportResize: () => {
|
|
224
|
+
calls.begin.push(manualViewport);
|
|
225
|
+
},
|
|
226
|
+
settleViewport: (fit) => {
|
|
227
|
+
calls.settle.push(fit);
|
|
228
|
+
},
|
|
229
|
+
layoutRunningRef,
|
|
230
|
+
pendingInitialFitRef: { current: false },
|
|
231
|
+
readyRef: { current: true },
|
|
232
|
+
onViewportSettled: () => undefined,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
callback?.([{ contentRect: { height: 480, width: 640 } }]);
|
|
236
|
+
expect(calls.begin).toEqual([manualViewport]);
|
|
237
|
+
expect(frames).toHaveLength(1);
|
|
238
|
+
|
|
239
|
+
// A same-topology layout starts after ResizeObserver captured the manual pan/zoom.
|
|
240
|
+
layoutRunningRef.current = true;
|
|
241
|
+
frames[0]?.();
|
|
242
|
+
expect(calls).toEqual({ begin: [manualViewport], resize: 0, settle: [] });
|
|
243
|
+
|
|
244
|
+
// Its latest completion consumes the preserved snapshot through the normal non-fitting settle.
|
|
245
|
+
layoutRunningRef.current = false;
|
|
246
|
+
observer?.flushAfterLayout();
|
|
247
|
+
expect(calls).toEqual({ begin: [manualViewport], resize: 1, settle: [] });
|
|
248
|
+
calls.settle.push(false);
|
|
249
|
+
expect(calls.settle).toEqual([false]);
|
|
250
|
+
} finally {
|
|
251
|
+
if (previousObserver === undefined) delete globals.ResizeObserver;
|
|
252
|
+
else globals.ResizeObserver = previousObserver;
|
|
253
|
+
if (previousFrame === undefined) delete globals.requestAnimationFrame;
|
|
254
|
+
else globals.requestAnimationFrame = previousFrame;
|
|
255
|
+
}
|
|
256
|
+
});
|
|
@@ -5,7 +5,8 @@ import { scheduleGraphFrame } from './scheduleGraphFrame';
|
|
|
5
5
|
interface CreateGraphResizeObserverInput {
|
|
6
6
|
readonly container: unknown;
|
|
7
7
|
readonly cy: Core;
|
|
8
|
-
readonly
|
|
8
|
+
readonly beginViewportResize: () => void;
|
|
9
|
+
readonly settleViewport: (fit: boolean) => void;
|
|
9
10
|
readonly layoutRunningRef: { current: boolean };
|
|
10
11
|
readonly pendingInitialFitRef: { current: boolean };
|
|
11
12
|
readonly readyRef: { current: boolean };
|
|
@@ -14,6 +15,7 @@ interface CreateGraphResizeObserverInput {
|
|
|
14
15
|
|
|
15
16
|
export interface GraphResizeObserver {
|
|
16
17
|
disconnect(): void;
|
|
18
|
+
flushAfterLayout(): void;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/*** Observe settled container resizing without racing an active layout generation. */
|
|
@@ -23,29 +25,76 @@ export function createGraphResizeObserver(
|
|
|
23
25
|
const Observer = readResizeObserverConstructor();
|
|
24
26
|
if (Observer === null) return null;
|
|
25
27
|
const sizeRef = { height: -1, width: -1 };
|
|
28
|
+
const pendingLayoutResizeRef = { current: false };
|
|
29
|
+
const pendingResizeSnapshotRef = { current: false };
|
|
30
|
+
const resizeScheduledRef = { current: false };
|
|
26
31
|
|
|
27
32
|
const observer = new Observer((entries) => {
|
|
28
33
|
const entry = entries.at(0);
|
|
29
34
|
if (entry === undefined || !hasSizeChanged(sizeRef, entry.contentRect)) return;
|
|
30
35
|
sizeRef.height = entry.contentRect.height;
|
|
31
36
|
sizeRef.width = entry.contentRect.width;
|
|
32
|
-
if (input.layoutRunningRef.current) return;
|
|
33
37
|
if (!input.readyRef.current && !input.pendingInitialFitRef.current) return;
|
|
34
|
-
|
|
38
|
+
captureResizeSnapshot(input, pendingResizeSnapshotRef);
|
|
39
|
+
if (input.layoutRunningRef.current) {
|
|
40
|
+
pendingLayoutResizeRef.current = true;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
scheduleResizeFit(input, pendingLayoutResizeRef, pendingResizeSnapshotRef, resizeScheduledRef);
|
|
35
44
|
});
|
|
36
45
|
observer.observe(input.container);
|
|
37
|
-
return
|
|
46
|
+
return {
|
|
47
|
+
disconnect: () => observer.disconnect(),
|
|
48
|
+
flushAfterLayout: () =>
|
|
49
|
+
flushAfterLayout(input, pendingLayoutResizeRef, pendingResizeSnapshotRef),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/*** Keep the first pre-resize viewport intent until its resize or layout settlement consumes it. */
|
|
54
|
+
function captureResizeSnapshot(
|
|
55
|
+
input: CreateGraphResizeObserverInput,
|
|
56
|
+
pendingResizeSnapshotRef: { current: boolean },
|
|
57
|
+
) {
|
|
58
|
+
if (pendingResizeSnapshotRef.current) return;
|
|
59
|
+
input.beginViewportResize();
|
|
60
|
+
pendingResizeSnapshotRef.current = true;
|
|
38
61
|
}
|
|
39
62
|
|
|
40
|
-
/***
|
|
41
|
-
function
|
|
63
|
+
/*** Apply a resize observed during layout before that layout settles the preserved viewport. */
|
|
64
|
+
function flushAfterLayout(
|
|
65
|
+
input: CreateGraphResizeObserverInput,
|
|
66
|
+
pendingLayoutResizeRef: { current: boolean },
|
|
67
|
+
pendingResizeSnapshotRef: { current: boolean },
|
|
68
|
+
) {
|
|
69
|
+
if (!pendingLayoutResizeRef.current || input.cy.destroyed() || input.layoutRunningRef.current)
|
|
70
|
+
return;
|
|
71
|
+
pendingLayoutResizeRef.current = false;
|
|
72
|
+
pendingResizeSnapshotRef.current = false;
|
|
73
|
+
input.cy.resize();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/*** Preserve settled viewport intent across one coalesced resize, except the deferred initial fit. */
|
|
77
|
+
function scheduleResizeFit(
|
|
78
|
+
input: CreateGraphResizeObserverInput,
|
|
79
|
+
pendingLayoutResizeRef: { current: boolean },
|
|
80
|
+
pendingResizeSnapshotRef: { current: boolean },
|
|
81
|
+
resizeScheduledRef: { current: boolean },
|
|
82
|
+
) {
|
|
83
|
+
if (resizeScheduledRef.current) return;
|
|
84
|
+
resizeScheduledRef.current = true;
|
|
42
85
|
scheduleGraphFrame(() => {
|
|
43
|
-
|
|
86
|
+
resizeScheduledRef.current = false;
|
|
87
|
+
if (input.cy.destroyed()) return;
|
|
88
|
+
if (input.layoutRunningRef.current) {
|
|
89
|
+
pendingLayoutResizeRef.current = true;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
44
92
|
input.cy.resize();
|
|
45
93
|
if (input.cy.width() <= 0 || input.cy.height() <= 0) return;
|
|
94
|
+
pendingResizeSnapshotRef.current = false;
|
|
46
95
|
const completedPendingInitialFit = input.pendingInitialFitRef.current;
|
|
47
96
|
input.pendingInitialFitRef.current = false;
|
|
48
|
-
input.settleViewport();
|
|
97
|
+
input.settleViewport(completedPendingInitialFit);
|
|
49
98
|
input.onViewportSettled(completedPendingInitialFit);
|
|
50
99
|
});
|
|
51
100
|
}
|
|
@@ -109,6 +109,48 @@ test('fits changed topology but preserves viewport and avoids relayout for highl
|
|
|
109
109
|
}
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
test('fits a changed layout algorithm even when visible topology is unchanged', async () => {
|
|
113
|
+
const initial = Promise.withResolvers<GraphViewController>();
|
|
114
|
+
const completed = Promise.withResolvers<GraphViewController>();
|
|
115
|
+
const callbacks: { current: GraphViewCallbacks } = { current: { onReady: initial.resolve } };
|
|
116
|
+
const runtime = createGraphRuntime(undefined, callbacks);
|
|
117
|
+
const input = {
|
|
118
|
+
nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
|
|
119
|
+
edges: [
|
|
120
|
+
{ source: 'a', target: 'b' },
|
|
121
|
+
{ source: 'b', target: 'c' },
|
|
122
|
+
],
|
|
123
|
+
layout: 'grid' as const,
|
|
124
|
+
richNodeRendering: false,
|
|
125
|
+
zoomMode: 'fit-relative' as const,
|
|
126
|
+
};
|
|
127
|
+
try {
|
|
128
|
+
runtime.update(input);
|
|
129
|
+
const controller = await initial.promise;
|
|
130
|
+
controller.setZoom(0.7);
|
|
131
|
+
controller.setPan({ x: 40, y: 30 });
|
|
132
|
+
callbacks.current = { onLayoutComplete: completed.resolve };
|
|
133
|
+
runtime.update({ ...input, layout: 'circle' });
|
|
134
|
+
const settled = await completed.promise;
|
|
135
|
+
expect(settled.getViewport().zoom).not.toBeCloseTo(0.7);
|
|
136
|
+
expectMeasuredNodesToFit(settled, ['a', 'b', 'c']);
|
|
137
|
+
} finally {
|
|
138
|
+
runtime.destroy();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
function expectMeasuredNodesToFit(controller: GraphViewController, nodeIds: readonly string[]) {
|
|
143
|
+
const measurement = controller.measureNodes?.(nodeIds);
|
|
144
|
+
expect(measurement).not.toBeNull();
|
|
145
|
+
if (measurement === null) return;
|
|
146
|
+
for (const node of measurement.nodes) {
|
|
147
|
+
expect(node.position.x - node.size.width / 2).toBeGreaterThanOrEqual(0);
|
|
148
|
+
expect(node.position.y - node.size.height / 2).toBeGreaterThanOrEqual(0);
|
|
149
|
+
expect(node.position.x + node.size.width / 2).toBeLessThanOrEqual(measurement.viewport.width);
|
|
150
|
+
expect(node.position.y + node.size.height / 2).toBeLessThanOrEqual(measurement.viewport.height);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
112
154
|
test('preserves selection and positions through hover presentation updates without relayout', async () => {
|
|
113
155
|
const ready = Promise.withResolvers<GraphViewController>();
|
|
114
156
|
const events: GraphViewElementEvent[] = [];
|
|
@@ -61,6 +61,7 @@ interface GraphRuntimeState {
|
|
|
61
61
|
readonly loopSizer: ReturnType<typeof createGraphLoopSizer>;
|
|
62
62
|
readonly pendingInitialFitRef: { current: boolean };
|
|
63
63
|
readonly readyRef: { current: boolean };
|
|
64
|
+
readonly settledLayoutRef: { current: GraphRuntimeUpdate['layout'] | null };
|
|
64
65
|
readonly settledTopologyRef: { current: string | null };
|
|
65
66
|
readonly relayoutScheduledRef: { current: boolean };
|
|
66
67
|
readonly renderedNodeListeners: Set<RenderedNodeListener>;
|
|
@@ -127,6 +128,7 @@ function createRuntimeState(
|
|
|
127
128
|
loopSizer: createGraphLoopSizer(cy),
|
|
128
129
|
pendingInitialFitRef: { current: false },
|
|
129
130
|
readyRef,
|
|
131
|
+
settledLayoutRef: { current: null as GraphRuntimeUpdate['layout'] | null },
|
|
130
132
|
settledTopologyRef: { current: null as string | null },
|
|
131
133
|
relayoutScheduledRef: { current: false },
|
|
132
134
|
renderedNodeListeners,
|
|
@@ -137,7 +139,8 @@ function createRuntimeState(
|
|
|
137
139
|
const resizeObserver = createGraphResizeObserver({
|
|
138
140
|
container,
|
|
139
141
|
cy,
|
|
140
|
-
|
|
142
|
+
beginViewportResize: () => viewport.beginResize(),
|
|
143
|
+
settleViewport: (fit) => viewport.settle(fit),
|
|
141
144
|
layoutRunningRef,
|
|
142
145
|
pendingInitialFitRef: stateBase.pendingInitialFitRef,
|
|
143
146
|
onViewportSettled: (completedPendingInitialFit) => {
|
|
@@ -242,6 +245,7 @@ function completeCurrentLayout(state: GraphRuntimeState, generation: number) {
|
|
|
242
245
|
state.layoutRef.current = null;
|
|
243
246
|
state.cy.resize();
|
|
244
247
|
state.layoutRunningRef.current = false;
|
|
248
|
+
state.resizeObserver?.flushAfterLayout();
|
|
245
249
|
if (!hasUsableViewport(state.cy)) {
|
|
246
250
|
if (!state.readyRef.current) state.pendingInitialFitRef.current = true;
|
|
247
251
|
return;
|
|
@@ -253,8 +257,10 @@ function completeCurrentLayout(state: GraphRuntimeState, generation: number) {
|
|
|
253
257
|
const shouldFit = shouldFitGraphAfterLayout(
|
|
254
258
|
state.readyRef.current,
|
|
255
259
|
topology !== state.settledTopologyRef.current,
|
|
260
|
+
input.layout !== state.settledLayoutRef.current,
|
|
256
261
|
);
|
|
257
262
|
state.viewport.settle(shouldFit);
|
|
263
|
+
state.settledLayoutRef.current = input.layout;
|
|
258
264
|
state.settledTopologyRef.current = topology;
|
|
259
265
|
const fitRequest = state.fitRequestRef.current;
|
|
260
266
|
state.fitRequestRef.current = null;
|