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
26 changes: 26 additions & 0 deletions .changeset/settle-camera-presets.md
Original file line number Diff line number Diff line change
@@ -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"`.
4 changes: 3 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export type {
Accessor,
LayoutKind,
SimulationConfig,
SimulationInput,
SimulationPreset,
GraphHostUpdate,
LabelConfig,
AccessibilityConfig,
Expand Down Expand Up @@ -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 {
Expand Down
97 changes: 91 additions & 6 deletions packages/core/src/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<N, E>;
/**
Expand Down Expand Up @@ -1111,6 +1125,7 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri
): GraphInstance<N, E> {
const engineFactory = opts.engine;
const fitViewOnFirstData = opts.fitViewOnFirstData ?? true;
const fitViewOnSettle = opts.fitViewOnSettle ?? 'follow';

const store = createStore<GraphStoreState>(() => ({
status: 'idle',
Expand Down Expand Up @@ -1535,7 +1550,9 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri
let linkColor: Accessor<AcceptedEdge<E>, string> | undefined;
let linkWidth: Accessor<AcceptedEdge<E>, 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). */
Expand Down Expand Up @@ -5180,12 +5197,52 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri
}
}

// --- settle camera --------------------------------------------------------
// The first-data fit frames the SEED ring; the force 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. Armed by maybeFitView under a force layout, the
// follow rides the engine frame fan-out — no timers and no rAF of its own,
// so it dies with the frames it rides on — and ends at first quiescence,
// any user camera input, or the frame cap.
const SETTLE_FOLLOW_INTERVAL_FRAMES = 55; // ~0.9s at 60fps
const SETTLE_FOLLOW_CAP_FRAMES = 480; // ~8s at 60fps
let settleFollowFrames: number | null = null; // null = not armed
let settleFollowContainer: HTMLElement | null = null;
const settleFollowCancelListener = (): void => {
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);
}

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -8524,10 +8581,15 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri
{
const c: EngineConfigUpdate = {};
let any = false;
if (update.simulation !== undefined && !Object.is(update.simulation, simulation)) {
simulation = update.simulation;
c.simulation = update.simulation;
any = true;
if (update.simulation !== undefined) {
// preset strings resolve to frozen singletons, so Object.is keeps
// detecting real changes for presets and objects alike.
const nextSimulation = resolveSimulation(update.simulation);
if (!Object.is(nextSimulation, simulation)) {
simulation = nextSimulation;
c.simulation = nextSimulation;
any = true;
}
}
if (update.theme !== undefined) {
const nextTheme = resolveTheme(update.theme);
Expand Down Expand Up @@ -9319,6 +9381,14 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri
onFrame(timeMs) {
if (!active()) return;
frameCadence += 1; // the ONE hit-test/overlay cadence clock
if (settleFollowFrames !== null && fitViewOnSettle === 'follow') {
settleFollowFrames += 1;
if (settleFollowFrames > SETTLE_FOLLOW_CAP_FRAMES) {
cancelSettleFollow();
} else if (settleFollowFrames % SETTLE_FOLLOW_INTERVAL_FRAMES === 0) {
settleFollowFit(s.engine, 650);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
// 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);
Expand Down Expand Up @@ -9377,6 +9447,12 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri
},
onSimulationEnd() {
if (!active()) return;
// settle camera: one final animated fit at first quiescence — the
// single fit of 'once' mode, the finale of 'follow' mode.
if (settleFollowFrames !== null) {
settleFollowFit(s.engine, 800);
cancelSettleFollow();
}
// Release pinned accretion — the expansion's arrivals settled,
// so the engine pin set returns to just the user pin slice.
if (accretionPinIds !== null) {
Expand Down Expand Up @@ -9693,6 +9769,7 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri
s.alive = false;
session = null;
mountPromise = null;
cancelSettleFollow();
cancelRerankTimer();
flushCrossfilterNotify(); // a queued histogram batch must not strand
// A pending quiescence assertion belongs to the session that armed it
Expand Down Expand Up @@ -10355,6 +10432,10 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri
}

if (state.camera !== null) {
// A restored camera is an explicit statement of where the view
// belongs — the settle follow must not overwrite it with a later
// periodic or final fit.
cancelSettleFollow();
const eng = engineIfReady();
if (eng !== null) {
if (effectiveReducedMotion()) eng.setViewport(state.camera, { durationMs: 0 });
Expand Down Expand Up @@ -10779,6 +10860,7 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri
id: NodeId,
opts?: { highlightNeighbors?: boolean; hops?: 1 },
): readonly NodeId[] {
cancelSettleFollow();
const eng = engineIfReady();
if (eng === null || scene === null) return EMPTY_IDS;
const idx = scene.indexById.get(id);
Expand Down Expand Up @@ -10884,6 +10966,7 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri

/** camera durations coerce to 0 under effective reduced motion. */
function cameraZoom(factor: number): void {
cancelSettleFollow();
const eng = engineIfReady();
if (eng === null) return;
if (effectiveReducedMotion()) eng.zoom(factor, 0);
Expand Down Expand Up @@ -10951,6 +11034,7 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri
destroy,
on,
fitView: () => {
cancelSettleFollow();
const eng = engineIfReady();
if (eng === null) return;
if (effectiveReducedMotion()) eng.fitView({ durationMs: 0 });
Expand All @@ -10963,6 +11047,7 @@ export function createGraphInstance<N = Record<string, unknown>, E = Record<stri
cameraZoom(1 / ZOOM_STEP);
},
setViewport: (v: Partial<ViewportState>) => {
cancelSettleFollow();
const eng = engineIfReady();
if (eng === null) return;
if (effectiveReducedMotion()) eng.setViewport(v, { durationMs: 0 });
Expand Down
36 changes: 35 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<SimulationPreset, Readonly<SimulationConfig>>> =
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
Expand Down Expand Up @@ -392,7 +426,7 @@ export interface GraphHostUpdate<N = Record<string, unknown>, E = Record<string,
* suppresses every driver (hover, focusNode, emphasizeNode). */
emphasisRing?: boolean;
layout?: LayoutKind;
simulation?: SimulationConfig;
simulation?: SimulationInput;
/** Controlled selection (uncontrolled when never provided; subset). */
selection?: readonly NodeId[];
theme?: ThemeInput;
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 @@ -30,6 +30,7 @@ export function snap(

export interface MakeInstanceOptions {
fitViewOnFirstData?: boolean;
fitViewOnSettle?: 'follow' | 'once' | false;
/** Passed to every FakeEngine the factory constructs. */
engineOptions?: FakeEngineOptions;
}
Expand All @@ -53,6 +54,7 @@ export function makeInstance(opts: MakeInstanceOptions = {}): InstanceHarness {
...(opts.fitViewOnFirstData !== undefined
? { fitViewOnFirstData: opts.fitViewOnFirstData }
: {}),
...(opts.fitViewOnSettle !== undefined ? { fitViewOnSettle: opts.fitViewOnSettle } : {}),
});
return { instance, engines, factoryCalls: () => factoryCalls };
}
Expand Down
Loading
Loading