Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/fit-zoom-clamp.md
Original file line number Diff line number Diff line change
@@ -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).
7 changes: 7 additions & 0 deletions packages/core/src/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 18 additions & 6 deletions packages/core/src/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import type {
EngineConfigUpdate,
EngineFactory,
EngineHostEvents,
FitViewOptions,
GraphEngine,
} from './engine/index';
import type { ErrorPhase, GraphError, GraphOperationError } from './errors';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1126,6 +1134,10 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri
const engineFactory = opts.engine;
const fitViewOnFirstData = opts.fitViewOnFirstData ?? true;
const fitViewOnSettle = opts.fitViewOnSettle ?? 'follow';
const fitViewMaxZoom = opts.fitViewMaxZoom === undefined ? 1.5 : opts.fitViewMaxZoom;
/** every internally issued fit carries the zoom clamp (null disables). */
const clampFit = (o: FitViewOptions): FitViewOptions =>
fitViewMaxZoom === null ? o : { ...o, maxZoom: fitViewMaxZoom };

const store = createStore<GraphStoreState>(() => ({
status: 'idle',
Expand Down Expand Up @@ -5232,16 +5244,16 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri
}
}
function settleFollowFit(eng: GraphEngine, durationMs: number): void {
if (effectiveReducedMotion()) eng.fitView({ durationMs: 0 });
else eng.fitView({ durationMs });
if (effectiveReducedMotion()) eng.fitView(clampFit({ durationMs: 0 }));
else eng.fitView(clampFit({ durationMs }));
}

function maybeFitView(eng: GraphEngine): void {
if (!fitViewOnFirstData || session === null || session.fitDone) return;
if (accepted === null) return; // "first data": only fit once data exists
session.fitDone = true;
if (effectiveReducedMotion()) eng.fitView({ durationMs: 0 });
else eng.fitView();
if (effectiveReducedMotion()) eng.fitView(clampFit({ durationMs: 0 }));
else eng.fitView(clampFit({}));
armSettleFollow(session);
}

Expand Down Expand Up @@ -11037,8 +11049,8 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri
cancelSettleFollow();
const eng = engineIfReady();
if (eng === null) return;
if (effectiveReducedMotion()) eng.fitView({ durationMs: 0 });
else eng.fitView();
if (effectiveReducedMotion()) eng.fitView(clampFit({ durationMs: 0 }));
else eng.fitView(clampFit({}));
},
zoomIn: () => {
cameraZoom(ZOOM_STEP);
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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 };
}
Expand Down
4 changes: 2 additions & 2 deletions packages/core/test/overlay-scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] },
Expand All @@ -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 });
});
Expand Down
19 changes: 17 additions & 2 deletions packages/core/test/settle-camera.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,15 @@ 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);

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();
Expand Down Expand Up @@ -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);
Expand Down
61 changes: 60 additions & 1 deletion packages/engine-cosmos/src/CosmosEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CosmosEngine['graph']>,
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 {
Expand Down
53 changes: 52 additions & 1 deletion packages/engine-cosmos/test/cosmos-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions packages/react/src/Graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,12 @@ export interface GraphProps<N = Record<string, unknown>, E = Record<string, unkn
* false: fit at first data only.
*/
fitViewOnSettle?: 'follow' | 'once' | false;
/**
* Upper bound on the zoom any internally issued fit lands at (construction-
* only). Small graphs otherwise fit until nodes balloon. Default 1.5;
* null disables the clamp.
*/
fitViewMaxZoom?: number | null;
/** service seam (instance construction option, D7): custom
* revision-aware services — most usefully an async `expansion` service
* backed by the host's own data source, so `expandNode`/the context menu's
Expand Down Expand Up @@ -617,6 +623,7 @@ function GraphInner<N, E>(
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;
Expand Down
Loading