From 212b31f57a3b0d548e6f2643b986ccbc569034d1 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Wed, 26 Aug 2026 21:08:02 +0300 Subject: [PATCH] Fit zoom clamp: small graphs no longer balloon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured: a 60-node graph fit at zoom 4.3, 300 nodes at 3.3 — nodes render as balloons. Every internally issued fit (first-data, settle follow, public fitView) now carries a zoom bound: fitViewMaxZoom option, default 1.5, null disables. FitViewOptions gains maxZoom; when the natural fit zoom exceeds it, CosmosEngine centers the scene bbox at the bound with one animated setZoomTransformByPointPositions instead (headless/zero-size containers and unknown positions fall back to the native fit). Verified live: small-graph fits land at exactly 1.5. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018SeFxK217ZrHSERqK6kjcb --- .changeset/fit-zoom-clamp.md | 16 +++++ packages/core/src/engine/index.ts | 7 +++ packages/core/src/instance.ts | 24 ++++++-- packages/core/test/helpers.ts | 2 + packages/core/test/overlay-scheduler.test.ts | 4 +- packages/core/test/settle-camera.test.ts | 19 +++++- packages/engine-cosmos/src/CosmosEngine.ts | 61 ++++++++++++++++++- .../engine-cosmos/test/cosmos-engine.test.ts | 53 +++++++++++++++- packages/react/src/Graph.tsx | 7 +++ 9 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 .changeset/fit-zoom-clamp.md diff --git a/.changeset/fit-zoom-clamp.md b/.changeset/fit-zoom-clamp.md new file mode 100644 index 0000000..67710e5 --- /dev/null +++ b/.changeset/fit-zoom-clamp.md @@ -0,0 +1,16 @@ +--- +'@modernrelay/orbit-core': minor +'@modernrelay/orbit-engine-cosmos': minor +'@modernrelay/orbit-react': minor +--- + +Fit zoom clamp: small graphs no longer balloon. + +Measured: a 60-node graph fit at zoom 4.3 (nodes render as balloons), 300 +nodes at 3.3. Every internally issued fit (first-data fit, settle follow, +public `fitView`) now carries a zoom upper bound — new `fitViewMaxZoom` +option, default 1.5, `null` to disable. The engine contract's +`FitViewOptions` gains `maxZoom`; when the natural fit zoom exceeds the +bound, the cosmos engine centers the scene bbox at the bound with one +animated transform instead. Verified live: small-graph fits land at exactly +1.5 (previously 3.1–4.3). diff --git a/packages/core/src/engine/index.ts b/packages/core/src/engine/index.ts index 3cf1825..805c05c 100644 --- a/packages/core/src/engine/index.ts +++ b/packages/core/src/engine/index.ts @@ -193,6 +193,13 @@ export interface EngineHostEvents { export interface FitViewOptions { durationMs?: number; padding?: number; + /** + * Upper bound on the zoom the fit may land at. Small scenes otherwise fit + * until nodes balloon (measured: a 60-node graph fits at zoom 4.3). When + * the natural fit zoom exceeds the bound, the engine centers the scene at + * `maxZoom` instead. Omitted = unclamped. + */ + maxZoom?: number; } export interface GraphEngine { diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 66c583f..013b272 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -103,6 +103,7 @@ import type { EngineConfigUpdate, EngineFactory, EngineHostEvents, + FitViewOptions, GraphEngine, } from './engine/index'; import type { ErrorPhase, GraphError, GraphOperationError } from './errors'; @@ -343,6 +344,13 @@ export interface CreateGraphInstanceOptions< engine: EngineFactory; /** Fit the camera once when the first data-bearing commit reaches a fresh engine. Default true. */ fitViewOnFirstData?: boolean; + /** + * Upper bound on the zoom any internally issued fit may land at (first-data + * fit, settle follow, public fitView). Small graphs otherwise fit until + * nodes balloon — measured: a 60-node graph fits at zoom 4.3. Default 1.5; + * null disables the clamp. + */ + fitViewMaxZoom?: number | null; /** * Camera behavior while the FIRST force-layout settle runs. The fit at * first data frames the seed ring; the simulation then contracts the graph @@ -1126,6 +1134,10 @@ export function createGraphInstance, E = Record + fitViewMaxZoom === null ? o : { ...o, maxZoom: fitViewMaxZoom }; const store = createStore(() => ({ status: 'idle', @@ -5232,16 +5244,16 @@ export function createGraphInstance, E = Record, E = Record { cameraZoom(ZOOM_STEP); diff --git a/packages/core/test/helpers.ts b/packages/core/test/helpers.ts index 32d8897..baaf741 100644 --- a/packages/core/test/helpers.ts +++ b/packages/core/test/helpers.ts @@ -31,6 +31,7 @@ export function snap( export interface MakeInstanceOptions { fitViewOnFirstData?: boolean; fitViewOnSettle?: 'follow' | 'once' | false; + fitViewMaxZoom?: number | null; /** Passed to every FakeEngine the factory constructs. */ engineOptions?: FakeEngineOptions; } @@ -55,6 +56,7 @@ export function makeInstance(opts: MakeInstanceOptions = {}): InstanceHarness { ? { fitViewOnFirstData: opts.fitViewOnFirstData } : {}), ...(opts.fitViewOnSettle !== undefined ? { fitViewOnSettle: opts.fitViewOnSettle } : {}), + ...(opts.fitViewMaxZoom !== undefined ? { fitViewMaxZoom: opts.fitViewMaxZoom } : {}), }); return { instance, engines, factoryCalls: () => factoryCalls }; } diff --git a/packages/core/test/overlay-scheduler.test.ts b/packages/core/test/overlay-scheduler.test.ts index 3384ce3..a723587 100644 --- a/packages/core/test/overlay-scheduler.test.ts +++ b/packages/core/test/overlay-scheduler.test.ts @@ -285,7 +285,7 @@ describe('reduced motion', () => { h.instance.focusNode('a', { highlightNeighbors: false }); expect(engine.cameraCalls).toEqual([ - { method: 'fitView', args: [{ durationMs: 0 }] }, + { method: 'fitView', args: [{ durationMs: 0, maxZoom: 1.5 }] }, { method: 'setViewport', args: [{ zoom: 2 }, { durationMs: 0 }] }, { method: 'zoom', args: [1.5, 0] }, { method: 'zoomToIndex', args: [0, 0] }, @@ -303,7 +303,7 @@ describe('reduced motion', () => { h.instance.setReducedMotion(true); // config wins → full motion h.instance.fitView(); - expect(engine.cameraCalls).toEqual([{ method: 'fitView', args: [] }]); + expect(engine.cameraCalls).toEqual([{ method: 'fitView', args: [{ maxZoom: 1.5 }] }]); expect(h.instance.getAccessibility()).toEqual({ reducedMotion: false }); }); diff --git a/packages/core/test/settle-camera.test.ts b/packages/core/test/settle-camera.test.ts index 7944245..3317d86 100644 --- a/packages/core/test/settle-camera.test.ts +++ b/packages/core/test/settle-camera.test.ts @@ -63,7 +63,7 @@ describe('settle camera', () => { for (let i = 0; i < 55; i++) engine.emitFrame(); expect(fitCalls(engine.cameraCalls).length).toBe(initialFits + 1); const followFit = fitCalls(engine.cameraCalls)[initialFits]!; - expect(followFit.args[0]).toEqual({ durationMs: 650 }); + expect(followFit.args[0]).toEqual({ durationMs: 650, maxZoom: 1.5 }); for (let i = 0; i < 55; i++) engine.emitFrame(); expect(fitCalls(engine.cameraCalls).length).toBe(initialFits + 2); @@ -71,7 +71,7 @@ describe('settle camera', () => { engine.injectSimulationEnd(); const afterSettle = fitCalls(engine.cameraCalls); expect(afterSettle.length).toBe(initialFits + 3); - expect(afterSettle[afterSettle.length - 1]!.args[0]).toEqual({ durationMs: 800 }); + expect(afterSettle[afterSettle.length - 1]!.args[0]).toEqual({ durationMs: 800, maxZoom: 1.5 }); // dead after quiescence: further frames fit nothing for (let i = 0; i < 200; i++) engine.emitFrame(); @@ -145,6 +145,21 @@ describe('settle camera', () => { expect(viewports[viewports.length - 1]!.args[0]).toEqual({ x: 100, y: 200, zoom: 2 }); }); + it('every internal fit carries the default 1.5 zoom clamp; null strips it; custom rides', async () => { + const a = await mounted(); + const aFits = fitCalls(a.engine.cameraCalls); + expect(aFits[0]!.args[0]).toEqual({ maxZoom: 1.5 }); + + const b = await mounted({ fitViewMaxZoom: null }); + const bFits = fitCalls(b.engine.cameraCalls); + expect(bFits[0]!.args[0]).toEqual({}); + + const c = await mounted({ fitViewMaxZoom: 3 }); + c.h.instance.fitView(); + const cFits = fitCalls(c.engine.cameraCalls); + expect(cFits[cFits.length - 1]!.args[0]).toEqual({ maxZoom: 3 }); + }); + it('the fixed layout never arms the follow', async () => { const h = makeInstance(); await h.instance.attach(container); diff --git a/packages/engine-cosmos/src/CosmosEngine.ts b/packages/engine-cosmos/src/CosmosEngine.ts index b39bcb2..c8ff193 100644 --- a/packages/engine-cosmos/src/CosmosEngine.ts +++ b/packages/engine-cosmos/src/CosmosEngine.ts @@ -373,8 +373,67 @@ export class CosmosEngine implements GraphEngine { // no duration bookkeeping is needed here. fitView(opts?: FitViewOptions): void { - this.activeGraph?.fitView(opts?.durationMs, opts?.padding); + const graph = this.activeGraph; + if (!graph) return; + const maxZoom = opts?.maxZoom; + if (maxZoom !== undefined && Number.isFinite(maxZoom) && maxZoom > 0) { + if (this.fitViewClamped(graph, maxZoom, opts)) return; + } + graph.fitView(opts?.durationMs, opts?.padding); + this.requestTicks(2); + } + + /** + * Zoom-clamped fit: when the natural fit zoom would exceed `maxZoom` + * (small scenes ballooning), center the scene bbox at `maxZoom` with one + * animated transform instead. Returns false to fall back to the native + * fit — unknown positions, detached container, or a fit that stays under + * the bound anyway (cosmos then applies its own padding semantics). + */ + private fitViewClamped( + graph: NonNullable, + maxZoom: number, + opts?: FitViewOptions, + ): boolean { + const div = this.innerDiv; + if (div === null) return false; + const w = div.clientWidth; + const h = div.clientHeight; + if (!(w > 0) || !(h > 0)) return false; + const raw = graph.getPointPositions(); + if (raw === undefined || raw.length === 0) return false; + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (let i = 0; i + 1 < raw.length; i += 2) { + const x = raw[i]!; + const y = raw[i + 1]!; + if (Number.isNaN(x) || Number.isNaN(y)) continue; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + if (minX > maxX || minY > maxY) return false; + const padding = opts?.padding ?? 0.1; + const availW = w * Math.max(0.1, 1 - 2 * padding); + const availH = h * Math.max(0.1, 1 - 2 * padding); + const bboxW = maxX - minX; + const bboxH = maxY - minY; + // degenerate bbox (single point / colinear) fits at ANY zoom → clamp + const fitZoom = + bboxW <= 0 && bboxH <= 0 + ? Infinity + : Math.min(bboxW > 0 ? availW / bboxW : Infinity, bboxH > 0 ? availH / bboxH : Infinity); + if (fitZoom <= maxZoom) return false; + graph.setZoomTransformByPointPositions( + Float32Array.of((minX + maxX) / 2, (minY + maxY) / 2), + opts?.durationMs ?? 250, + maxZoom, + ); this.requestTicks(2); + return true; } zoom(factor: number, durationMs?: number): void { diff --git a/packages/engine-cosmos/test/cosmos-engine.test.ts b/packages/engine-cosmos/test/cosmos-engine.test.ts index d72968f..0ba8db9 100644 --- a/packages/engine-cosmos/test/cosmos-engine.test.ts +++ b/packages/engine-cosmos/test/cosmos-engine.test.ts @@ -96,7 +96,8 @@ const h = vi.hoisted(() => { if (scale !== undefined) this.zoomLevel = scale; } getZoomLevel(): number { return this.zoomLevel; } - getPointPositions(): number[] { return [1, 2, 3, 4]; } + pointPositions: number[] = [1, 2, 3, 4]; + getPointPositions(): number[] { return this.pointPositions; } screenToSpacePosition(p: [number, number]): [number, number] { return [p[0] * 2 - 5, p[1] * 2 - 5]; } @@ -1073,6 +1074,56 @@ describe('CosmosEngine', () => { expect(graph.calls.filter((c) => c.method === 'render')).toHaveLength(2); }); + describe('fitView zoom clamp', () => { + async function mountedSized(w = 800, h = 600) { + const m = await mounted(); + const inner = m.container.firstElementChild as HTMLElement; + Object.defineProperty(inner, 'clientWidth', { value: w, configurable: true }); + Object.defineProperty(inner, 'clientHeight', { value: h, configurable: true }); + m.graph.calls.length = 0; + return m; + } + + it('clamps a small scene: centers the bbox at maxZoom instead of the native fit', async () => { + const { engine, graph } = await mountedSized(); + graph.pointPositions = [0, 0, 100, 50]; // natural fit zoom 6.4 at 800x600/pad 0.1 + engine.fitView({ durationMs: 300, padding: 0.1, maxZoom: 1.5 }); + expect(graph.calls).toEqual([ + { + method: 'setZoomTransformByPointPositions', + args: [Float32Array.of(50, 25), 300, 1.5, undefined], + }, + ]); + }); + + it('falls back to the native fit when the natural zoom stays under the bound', async () => { + const { engine, graph } = await mountedSized(); + graph.pointPositions = [0, 0, 4000, 3000]; // natural fit ~0.16 + engine.fitView({ durationMs: 300, maxZoom: 1.5 }); + expect(graph.calls).toEqual([{ method: 'fitView', args: [300, undefined] }]); + }); + + it('a single point clamps at maxZoom (degenerate bbox fits at any zoom)', async () => { + const { engine, graph } = await mountedSized(); + graph.pointPositions = [10, 20]; + engine.fitView({ maxZoom: 1.5 }); + expect(graph.calls).toEqual([ + { + method: 'setZoomTransformByPointPositions', + args: [Float32Array.of(10, 20), 250, 1.5, undefined], + }, + ]); + }); + + it('a zero-sized container (headless) falls back to the native fit', async () => { + const { engine, graph } = await mounted(); // jsdom: clientWidth 0 + graph.calls.length = 0; + engine.fitView({ maxZoom: 1.5 }); + engine.fitView({ durationMs: 100 }); + expect(methodsOf(graph.calls)).toEqual(['fitView', 'fitView']); + }); + }); + it('maps camera, selection, focus, and simulation controls to cosmos APIs', async () => { const { engine, graph } = await mounted(); graph.calls.length = 0; diff --git a/packages/react/src/Graph.tsx b/packages/react/src/Graph.tsx index 4283382..81c9a30 100644 --- a/packages/react/src/Graph.tsx +++ b/packages/react/src/Graph.tsx @@ -223,6 +223,12 @@ export interface GraphProps, E = Record( options.fitViewOnFirstData = props.fitViewOnFirstData; } if (props.fitViewOnSettle !== undefined) options.fitViewOnSettle = props.fitViewOnSettle; + if (props.fitViewMaxZoom !== undefined) options.fitViewMaxZoom = props.fitViewMaxZoom; if (props.searchIndex !== undefined) options.searchIndex = props.searchIndex; if (props.limits !== undefined) options.limits = props.limits; if (props.execution !== undefined) options.execution = props.execution;