@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.
Files changed (27) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.d.ts +2 -0
  4. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.d.ts.map +1 -1
  5. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.js +68 -4
  6. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.js.map +1 -1
  7. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.d.ts +3 -1
  8. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.d.ts.map +1 -1
  9. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.js +41 -8
  10. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.js.map +1 -1
  11. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.d.ts.map +1 -1
  12. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.js +6 -2
  13. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.js.map +1 -1
  14. package/dist/features/graph-view/adapters/inbound/web-artifact/shouldFitGraphAfterLayout.d.ts +2 -2
  15. package/dist/features/graph-view/adapters/inbound/web-artifact/shouldFitGraphAfterLayout.d.ts.map +1 -1
  16. package/dist/features/graph-view/adapters/inbound/web-artifact/shouldFitGraphAfterLayout.js +3 -3
  17. package/dist/features/graph-view/adapters/inbound/web-artifact/shouldFitGraphAfterLayout.js.map +1 -1
  18. package/package.json +1 -1
  19. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphController.test.ts +145 -1
  20. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphController.ts +86 -5
  21. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.test.ts +163 -6
  22. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphResizeObserver.ts +57 -8
  23. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.test.ts +42 -0
  24. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.ts +7 -1
  25. package/src/features/graph-view/adapters/inbound/web-artifact/shouldFitGraphAfterLayout.test.ts +7 -3
  26. package/src/features/graph-view/adapters/inbound/web-artifact/shouldFitGraphAfterLayout.ts +7 -3
  27. 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) controller.fit();
99
- else if (state.relative) {
100
- setCenteredZoom(cy, logicalZoom * state.scale);
101
- cy.center(cy.nodes());
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() };
@@ -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: 0, completedPending: [] as boolean[] };
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
- settleViewport: () => {
61
- calls.settle += 1;
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: 0, completedPending: [] });
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).toBe(0);
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).toBe(1);
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 settleViewport: () => void;
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
- scheduleResizeFit(input);
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 observer;
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
- /*** Fit one genuine resize only after the active layout has settled. */
41
- function scheduleResizeFit(input: CreateGraphResizeObserverInput) {
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
- if (input.cy.destroyed() || input.layoutRunningRef.current) return;
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
- settleViewport: () => viewport.settle(true),
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;