From 893a1d26a302e5f7e8495fa0f6b3d7a4b0101b5b Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Tue, 25 Aug 2026 03:02:57 +0300 Subject: [PATCH 1/2] Settle camera + simulation presets: fix the first-load feel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two measured problems on every fresh force mount (800-node clustered protocol, max displacement sampled at 500ms): the first-data fit frames the seed ring while the simulation contracts the graph to 5-17% viewport fill across the whole parameter space (a distant blob), and the engine's default cooling never reaches visible stillness inside 20s (reads as endless jitter). - fitViewOnSettle construction option ('follow' default | 'once' | false): 'follow' keeps the settling graph framed with periodic animated refits riding the engine frame fan-out (frame-counted — no timers, no rAF of its own, dies with the frames it rides), plus a final fit at first quiescence; user camera input (pointer/wheel on the container or any public camera call) cancels it. Fixed layouts never arm. - simulation accepts preset names ('calm' | 'spread' | 'tight' | 'lively'); SIMULATION_PRESETS + resolveSimulation exported; presets are frozen singletons so identity comparison keeps working. - DEFAULT changed: omitted simulation resolves to 'calm' (visually still in ~5s) instead of engine defaults; the old feel is simulation="lively". - Verified live: fill 0.5x0.8 after settle vs 0.07x0.12 before. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018SeFxK217ZrHSERqK6kjcb --- .changeset/settle-camera-presets.md | 26 ++++ packages/core/src/index.ts | 4 +- packages/core/src/instance.ts | 93 +++++++++++++- packages/core/src/types.ts | 36 +++++- packages/core/test/helpers.ts | 2 + packages/core/test/settle-camera.test.ts | 143 ++++++++++++++++++++++ packages/react/src/Graph.tsx | 17 ++- packages/react/test/sim-controls.test.tsx | 10 +- 8 files changed, 317 insertions(+), 14 deletions(-) create mode 100644 .changeset/settle-camera-presets.md create mode 100644 packages/core/test/settle-camera.test.ts diff --git a/.changeset/settle-camera-presets.md b/.changeset/settle-camera-presets.md new file mode 100644 index 0000000..b6c8163 --- /dev/null +++ b/.changeset/settle-camera-presets.md @@ -0,0 +1,26 @@ +--- +'@modernrelay/orbit-core': minor +'@modernrelay/orbit-react': minor +--- + +First-load feel: the settle camera and simulation presets. + +Two measured problems on every fresh force-layout mount: the first-data fit +frames the seed ring while the simulation contracts the graph to 5–17% of +that frame (a distant blob), and the engine's default cooling keeps visible +motion alive for tens of seconds (reads as endless jitter). + +- **Settle camera** — new `fitViewOnSettle` option (`'follow'` (default) | + `'once'` | `false`). Under `'follow'` the camera keeps the settling graph + framed with periodic animated refits riding the engine frame fan-out (no + timers, no extra rAF) and a final fit at first quiescence; any user camera + input cancels it. `'once'` fits a single time at quiescence; `false` + restores the previous behavior. +- **Simulation presets** — `simulation` now also accepts a preset name: + `'calm'`, `'spread'`, `'tight'`, or `'lively'` (`SIMULATION_PRESETS` and + `resolveSimulation` are exported). Presets were selected on a measured + protocol: seconds until sustained visible stillness on an 800-node + clustered graph. +- **Default changed**: an omitted `simulation` now resolves to the `'calm'` + preset (visually still in ~5s) instead of the engine's own defaults. The + old feel is one prop away: `simulation="lively"`. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0678b82..5200583 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,8 @@ export type { Accessor, LayoutKind, SimulationConfig, + SimulationInput, + SimulationPreset, GraphHostUpdate, LabelConfig, AccessibilityConfig, @@ -90,7 +92,7 @@ export type { GraphEventMap, GraphEventName, } from './types'; -export { DIAGNOSTIC_SAMPLE_CAP } from './types'; +export { DIAGNOSTIC_SAMPLE_CAP, SIMULATION_PRESETS, resolveSimulation } from './types'; // error taxonomy export type { diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 87eda0e..1256486 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -74,7 +74,7 @@ import type { TimelinePlayback, ViewportState, } from './types'; -import { DIAGNOSTIC_SAMPLE_CAP } from './types'; +import { DIAGNOSTIC_SAMPLE_CAP, resolveSimulation } from './types'; import { AcceptanceQueue, INGEST_MAX_FLUSH_LATENCY_MS_DEFAULT, @@ -343,6 +343,20 @@ export interface CreateGraphInstanceOptions< engine: EngineFactory; /** Fit the camera once when the first data-bearing commit reaches a fresh engine. Default true. */ fitViewOnFirstData?: boolean; + /** + * Camera behavior while the FIRST force-layout settle runs. The fit at + * first data frames the seed ring; the simulation then contracts the graph + * to a fraction of that frame (measured 5-17% viewport fill across the + * parameter space), so without a follow-up the graph reads as a distant + * blob. + * - 'follow' (default): periodic animated refits ride the engine frame + * fan-out while the first settle runs (no extra timers or rAF), with a + * final fit at quiescence. Any user camera input cancels the follow. + * - 'once': a single animated fit at first quiescence. + * - false: v0.15 behavior (first-data fit only). + * Only force layouts follow; the fixed layout keeps the single fit. + */ + fitViewOnSettle?: 'follow' | 'once' | false; /** revision-aware services (expansion + search). */ services?: GraphServices; /** @@ -1111,6 +1125,7 @@ export function createGraphInstance, E = Record { const engineFactory = opts.engine; const fitViewOnFirstData = opts.fitViewOnFirstData ?? true; + const fitViewOnSettle = opts.fitViewOnSettle ?? 'follow'; const store = createStore(() => ({ status: 'idle', @@ -1535,7 +1550,9 @@ export function createGraphInstance, E = Record, string> | undefined; let linkWidth: Accessor, number> | undefined; let layout: LayoutKind = 'force'; - let simulation: SimulationConfig | undefined; + // Default = the 'calm' preset: the engine's own defaults keep visible + // motion alive for tens of seconds, which reads as jitter on first load. + let simulation: SimulationConfig | undefined = resolveSimulation(undefined); /** resolved theme tokens — always defined (dark base by default). */ let theme: GraphTheme = resolveTheme(undefined); /** desired arrowheads (capability-gated; inert when unsupported). */ @@ -5180,12 +5197,52 @@ export function createGraphInstance, E = Record { + cancelSettleFollow(); + }; + function cancelSettleFollow(): void { + if (settleFollowContainer !== null) { + // headless hosts (tests, exotic embeddings) may hand over a container + // without the DOM event surface — the camera math never needs it. + settleFollowContainer.removeEventListener?.('pointerdown', settleFollowCancelListener); + settleFollowContainer.removeEventListener?.('wheel', settleFollowCancelListener); + settleFollowContainer = null; + } + settleFollowFrames = null; + } + function armSettleFollow(s: MountSession): void { + if (fitViewOnSettle === false || layout !== 'force') return; + settleFollowFrames = 0; + if (typeof s.container.addEventListener === 'function') { + settleFollowContainer = s.container; + s.container.addEventListener('pointerdown', settleFollowCancelListener); + s.container.addEventListener('wheel', settleFollowCancelListener); + } + } + function settleFollowFit(eng: GraphEngine, durationMs: number): void { + if (effectiveReducedMotion()) eng.fitView({ durationMs: 0 }); + else eng.fitView({ 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(); + armSettleFollow(session); } // ------------------------------------------------------------------------- @@ -8524,10 +8581,15 @@ export function createGraphInstance, E = Record, E = Record SETTLE_FOLLOW_CAP_FRAMES) { + cancelSettleFollow(); + } else if (settleFollowFrames % SETTLE_FOLLOW_INTERVAL_FRAMES === 0) { + settleFollowFit(s.engine, 650); + } + } // O(1) pressure accounting per tick; a tick while the // sim is settled is an idle wakeup (0 = the gated clock is honest). pressureSampler.noteFrame(timeMs, !store.getState().simulationRunning); @@ -9377,6 +9447,12 @@ export function createGraphInstance, E = Record, E = Record, E = Record, E = Record, E = Record { + cancelSettleFollow(); const eng = engineIfReady(); if (eng === null) return; if (effectiveReducedMotion()) eng.fitView({ durationMs: 0 }); @@ -10963,6 +11043,7 @@ export function createGraphInstance, E = Record) => { + cancelSettleFollow(); const eng = engineIfReady(); if (eng === null) return; if (effectiveReducedMotion()) eng.setViewport(v, { durationMs: 0 }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 705e0db..5a994e6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -295,6 +295,40 @@ export interface SimulationConfig { repulsionFromMouse?: number; } +/** + * Named simulation presets, measured on the reference protocol (800-node + * clustered graph, max node displacement sampled at 500ms): the times below + * are seconds until visible stillness (< 1.5 space units/s sustained). + * + * - `calm` — damped and settles in ~5s; the DEFAULT when no `simulation` + * value is given. The engine's own defaults (`lively`) keep visible motion + * alive for tens of seconds, which reads as jitter on first load. + * - `spread` — airier inter-cluster spacing, ~7s to stillness. + * - `tight` — compact clusters, ~6s to stillness. + * - `lively` — the engine's own defaults: ambient continuous motion. + */ +export type SimulationPreset = 'calm' | 'spread' | 'tight' | 'lively'; + +/** The `simulation` input surface: a full config or a named preset. */ +export type SimulationInput = SimulationConfig | SimulationPreset; + +export const SIMULATION_PRESETS: Readonly>> = + Object.freeze({ + calm: Object.freeze({ repulsion: 1.4, gravity: 0.15, friction: 0.6, decay: 1000 }), + spread: Object.freeze({ repulsion: 2, gravity: 0.1, friction: 0.6, decay: 1400 }), + tight: Object.freeze({ repulsion: 0.8, gravity: 0.3, friction: 0.55, decay: 1200 }), + lively: Object.freeze({ repulsion: 1, gravity: 0.25, friction: 0.85, decay: 5000 }), + }); + +/** Resolve a `simulation` input to a concrete config. Omitted input resolves + * to the `calm` preset — the measured-good default. Preset strings resolve to + * frozen singletons, so identity comparison stays meaningful. */ +export function resolveSimulation(input: SimulationInput | undefined): SimulationConfig { + if (input === undefined) return SIMULATION_PRESETS.calm; + if (typeof input === 'string') return SIMULATION_PRESETS[input] ?? SIMULATION_PRESETS.calm; + return input; +} + // --------------------------------------------------------------------------- // Host update — the atomic boundary: one call carries data + config + // controlled state and publishes exactly one store revision and at most one @@ -392,7 +426,7 @@ export interface GraphHostUpdate, E = Record factoryCalls }; } diff --git a/packages/core/test/settle-camera.test.ts b/packages/core/test/settle-camera.test.ts new file mode 100644 index 0000000..c824af9 --- /dev/null +++ b/packages/core/test/settle-camera.test.ts @@ -0,0 +1,143 @@ +/** + * Settle camera + simulation presets. + * + * The first-data fit frames the SEED ring; the force simulation then + * contracts the graph to a fraction of that frame, so the camera follows the + * first settle: periodic animated refits riding the engine frame fan-out, + * a final fit at quiescence, cancelled by any user camera input. Presets + * resolve to frozen configs; omitted simulation resolves to 'calm' (the + * engine's own defaults keep visible motion alive for tens of seconds). + */ + +import { describe, expect, it } from 'vitest'; + +import { container, makeInstance, snap } from './helpers'; +import { SIMULATION_PRESETS } from '../src/types'; +import type { RecordedCall } from '../src/testing/index'; + +const DATA = snap(1, ['a', 'b', 'c'], [ + ['a', 'b'], + ['b', 'c'], +]); + +function fitCalls(calls: readonly RecordedCall[]): RecordedCall[] { + return calls.filter((c) => c.method === 'fitView'); +} + +async function mounted(opts: Parameters[0] = {}) { + const h = makeInstance(opts); + await h.instance.attach(container); + h.instance.applyHostUpdate({ data: DATA }); + const engine = h.engines[0]!; + return { h, engine }; +} + +describe('simulation presets', () => { + it('omitted simulation resolves to the calm preset in the engine config', async () => { + const { engine } = await mounted(); + const configs = engine.commits + .map((c) => c.config?.simulation) + .filter((s): s is NonNullable => s !== undefined); + expect(configs.length).toBeGreaterThan(0); + expect(configs[configs.length - 1]).toEqual(SIMULATION_PRESETS.calm); + }); + + it('a preset name resolves to its frozen config and repeats are no-ops', async () => { + const { h, engine } = await mounted(); + h.instance.applyHostUpdate({ simulation: 'spread' }); + const afterFirst = engine.commits.length; + const last = engine.commits[afterFirst - 1]!; + expect(last.config?.simulation).toBe(SIMULATION_PRESETS.spread); + // the same preset again resolves to the same frozen object — no commit + h.instance.applyHostUpdate({ simulation: 'spread' }); + expect(engine.commits.length).toBe(afterFirst); + }); +}); + +describe('settle camera', () => { + it("default 'follow': periodic fits ride the frame fan-out, quiescence fits once more, then silence", async () => { + const { engine } = await mounted(); + const initialFits = fitCalls(engine.cameraCalls).length; + expect(initialFits).toBe(1); // fitViewOnFirstData + + 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 }); + + 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 }); + + // dead after quiescence: further frames fit nothing + for (let i = 0; i < 200; i++) engine.emitFrame(); + expect(fitCalls(engine.cameraCalls).length).toBe(initialFits + 3); + }); + + it("'once': no periodic fits, exactly one at first quiescence", async () => { + const { engine } = await mounted({ fitViewOnSettle: 'once' }); + const initialFits = fitCalls(engine.cameraCalls).length; + for (let i = 0; i < 200; i++) engine.emitFrame(); + expect(fitCalls(engine.cameraCalls).length).toBe(initialFits); + engine.injectSimulationEnd(); + expect(fitCalls(engine.cameraCalls).length).toBe(initialFits + 1); + engine.injectSimulationEnd(); // later settles (reheats) fit nothing + expect(fitCalls(engine.cameraCalls).length).toBe(initialFits + 1); + }); + + it('false: first-data fit only — frames and quiescence add nothing', async () => { + const { engine } = await mounted({ fitViewOnSettle: false }); + const initialFits = fitCalls(engine.cameraCalls).length; + for (let i = 0; i < 200; i++) engine.emitFrame(); + engine.injectSimulationEnd(); + expect(fitCalls(engine.cameraCalls).length).toBe(initialFits); + }); + + it('the frame cap retires the follow', async () => { + const { engine } = await mounted(); + const initialFits = fitCalls(engine.cameraCalls).length; + for (let i = 0; i < 481; i++) engine.emitFrame(); + const atCap = fitCalls(engine.cameraCalls).length; + expect(atCap).toBe(initialFits + 8); // 480/55 → 8 periodic fits + for (let i = 0; i < 200; i++) engine.emitFrame(); + expect(fitCalls(engine.cameraCalls).length).toBe(atCap); + // quiescence after the cap adds no fit either — the follow is gone + engine.injectSimulationEnd(); + expect(fitCalls(engine.cameraCalls).length).toBe(atCap); + }); + + it('user camera input cancels the follow (public setViewport)', async () => { + const { h, engine } = await mounted(); + const initialFits = fitCalls(engine.cameraCalls).length; + h.instance.setViewport({ zoom: 2 }); + for (let i = 0; i < 200; i++) engine.emitFrame(); + engine.injectSimulationEnd(); + expect(fitCalls(engine.cameraCalls).length).toBe(initialFits); + }); + + it('an explicit host fitView cancels the follow but still fits', async () => { + const { h, engine } = await mounted(); + const initialFits = fitCalls(engine.cameraCalls).length; + h.instance.fitView(); + const afterHostFit = fitCalls(engine.cameraCalls).length; + expect(afterHostFit).toBe(initialFits + 1); + for (let i = 0; i < 200; i++) engine.emitFrame(); + engine.injectSimulationEnd(); + expect(fitCalls(engine.cameraCalls).length).toBe(afterHostFit); + }); + + it('the fixed layout never arms the follow', async () => { + const h = makeInstance(); + await h.instance.attach(container); + h.instance.applyHostUpdate({ layout: 'fixed', data: DATA }); + const engine = h.engines[0]!; + const initialFits = fitCalls(engine.cameraCalls).length; + for (let i = 0; i < 200; i++) engine.emitFrame(); + engine.injectSimulationEnd(); + expect(fitCalls(engine.cameraCalls).length).toBe(initialFits); + }); +}); diff --git a/packages/react/src/Graph.tsx b/packages/react/src/Graph.tsx index d23f6c5..4283382 100644 --- a/packages/react/src/Graph.tsx +++ b/packages/react/src/Graph.tsx @@ -68,7 +68,7 @@ import type { DegradeEvent, GraphPerfSnapshot, SetViewStateResult, - SimulationConfig, + SimulationInput, SubgraphSpec, ThemeInput, TimelinePlayback, @@ -102,7 +102,9 @@ export interface GraphProps, E = Record, string>; linkWidth?: Accessor, number>; layout?: LayoutKind; - simulation?: SimulationConfig; + /** Full config or a named preset ('calm' | 'spread' | 'tight' | + * 'lively'). Omitted = the 'calm' preset — the measured-good default. */ + simulation?: SimulationInput; /** theme tokens: full GraphTheme, Partial over a named base, or the * v0.1 `{background}` shorthand. Diffed structurally (JSON). */ theme?: ThemeInput; @@ -213,6 +215,14 @@ export interface GraphProps, E = Record void; /** Captured at first render (instance construction option). Default true. */ fitViewOnFirstData?: boolean; + /** + * Camera behavior while the FIRST force-layout settle runs (construction- + * only). 'follow' (default): the camera keeps the contracting graph framed + * with periodic animated refits and a final fit at quiescence; any user + * camera input cancels it. 'once': single fit at first quiescence. + * false: fit at first data only. + */ + fitViewOnSettle?: 'follow' | 'once' | false; /** 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 @@ -423,7 +433,7 @@ interface CommittedProps { linkColor: Accessor, string> | undefined; linkWidth: Accessor, number> | undefined; layout: LayoutKind | undefined; - simulation: SimulationConfig | undefined; + simulation: SimulationInput | undefined; /** JSON form of the last committed `theme` prop (small token set). */ themeJson: string | undefined; metrics: readonly MetricColumn[] | undefined; @@ -606,6 +616,7 @@ function GraphInner( if (props.fitViewOnFirstData !== undefined) { options.fitViewOnFirstData = props.fitViewOnFirstData; } + if (props.fitViewOnSettle !== undefined) options.fitViewOnSettle = props.fitViewOnSettle; if (props.searchIndex !== undefined) options.searchIndex = props.searchIndex; if (props.limits !== undefined) options.limits = props.limits; if (props.execution !== undefined) options.execution = props.execution; diff --git a/packages/react/test/sim-controls.test.tsx b/packages/react/test/sim-controls.test.tsx index 905e377..49cffeb 100644 --- a/packages/react/test/sim-controls.test.tsx +++ b/packages/react/test/sim-controls.test.tsx @@ -128,9 +128,13 @@ describe(' applicability gating', () => { const { engine, view } = await setup({ layout: 'fixed' }); expect(view.container.querySelector('[data-orbit-simcontrols]')).toBeNull(); expect(view.container.textContent).toBe(''); - // Rendering the gated-off panel never touches the engine. - const commitConfigs = engine.commits.filter((c) => c.config?.simulation !== undefined); - expect(commitConfigs).toEqual([]); + // Rendering the gated-off panel never touches the engine. (The mount + // replay itself carries the resolved DEFAULT simulation config — the + // 'calm' preset — so the assertion starts after setup.) + const baseline = engine.commits.length; + expect(view.container.querySelector('[data-orbit-simcontrols]')).toBeNull(); + const appended = engine.commits.slice(baseline).filter((c) => c.config?.simulation !== undefined); + expect(appended).toEqual([]); // N/A note: the spec's "static while its convergence run is active" case // cannot be gated in v0.10 — `LayoutKind = 'force' | 'fixed'` (core // types.ts) has no 'static' variant and GraphStoreState publishes no From 5227972e65e80268f780cc142ac94d608bb36ce4 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Wed, 26 Aug 2026 20:40:33 +0300 Subject: [PATCH 2/2] Settle follow yields to a restored view-state camera Review finding: a deep-linked setViewState restore during the first settle window would have its explicitly restored camera overwritten by the follow's next periodic or final fit. The restore now cancels the follow; pinned by a test asserting the restored viewport stands. --- packages/core/src/instance.ts | 4 ++++ packages/core/test/settle-camera.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 1256486..66c583f 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -10432,6 +10432,10 @@ export function createGraphInstance, E = Record { expect(fitCalls(engine.cameraCalls).length).toBe(afterHostFit); }); + it('a restored view-state camera cancels the follow (deep links win)', async () => { + const { h, engine } = await mounted(); + const initialFits = fitCalls(engine.cameraCalls).length; + const state = h.instance.getViewState(); + const restored = { ...state, camera: { x: 100, y: 200, zoom: 2 } }; + const result = await h.instance.setViewState(restored); + expect(result.status).toBe('applied'); + for (let i = 0; i < 200; i++) engine.emitFrame(); + engine.injectSimulationEnd(); + // no follow fit after the restore — the deep-linked camera stands + expect(fitCalls(engine.cameraCalls).length).toBe(initialFits); + const viewports = engine.cameraCalls.filter((c) => c.method === 'setViewport'); + expect(viewports[viewports.length - 1]!.args[0]).toEqual({ x: 100, y: 200, zoom: 2 }); + }); + it('the fixed layout never arms the follow', async () => { const h = makeInstance(); await h.instance.attach(container);