Skip to content

Commit 36a5684

Browse files
committed
feat(graph): entrance choreography + seeded layout (Phase 6)
Warmup + seeded initial layout + a staggered entrance reveal for the force-directed graph, landed across the worker and sync-fallback paths. - worker-protocol/worker/sync: carry warmupTicks, warmupBudgetMs (default 250), initialAlpha. Worker builds the sim stopped, applies initialAlpha, runs a warmup loop bounded by BOTH tick count AND ms budget, posts the warmed first positions, then restart()s. Old dist workers ignore the new fields (graceful). Sync path chunks warmup PRE-reveal (nothing renders until warmup completes) so the main thread never freezes. - seed.ts: FNV-1a hash32 + seedNodePositions — a pure function of (id, seed, community) so the settled layout is reproducible and adding a node leaves the others' start positions unchanged. Called with seed ?? 0 before SimulationManager.create. - simulation.ts: derive the sync tick cap from alphaDecay via ticksToAlphaMin (ceil(log(alphaMin)/log(1-alphaDecay)), ceilinged at 800), aligning the sync path with the worker's alpha<alphaMin stop instead of a fixed 300 that diverged under settle:'thorough'. - entrance.ts: nodeEnterProgress (per-node reveal quantized to <=8 buckets for fill batching) + ENTRANCE_STAGGER_MAX_NODES (3000). - zoom.fitBounds: add a spread flag (default true); the mount passes false after warmup so near-final bounds aren't over-inflated (would fit ~2x too small at 10k nodes). - graph-mount: first post-warmup tick pulls the camera back to 0.92x the fit, optionally flies to the fit (enter.cameraFit), and drives a mount-level entranceProgress read by buildRenderState. Warmup runs even under reduced motion / animation:false; only the reveal/flight skip. Resize mid-entrance cancels the fit flight and snaps to the new-viewport fit while the reveal continues; update() finishAll()s first. - canvas-renderer: thread entrance into GraphRenderState; ramp node alpha/scale (0.6 + 0.4*nodeT), lag edges 30%, fade labels by progress, preserving Phase 5 focus-crossfade + batching. Determinism is scoped PER EXECUTION PATH (same spec + seed => identical settled layout within a path); worker-vs-sync parity is not guaranteed. Tests: seed determinism, warmup (alpha<1 + non-phyllotaxis spread + ms budget via injected clock), ticksToAlphaMin, entrance stagger math, first frame != fit / post-duration == fit, reduced-motion (warmup yes, reveal no), resize mid-entrance, fitBounds spread:false tighter, and a source- parity grep drift alarm across simulation.ts / simulation-worker.ts. Deviation: seed R0 is a fixed constant, not the plan's 10*sqrt(n). An n-dependent R0 rescales every node when one is added, violating the "adding a node leaves others unchanged" acceptance criterion. Per-node determinism wins; d3 charge repulsion still spreads the cloud. Claude-Session: https://claude.ai/code/session_016rS7hi2g4bSzRBfnD1p2ji
1 parent 0642d78 commit 36a5684

16 files changed

Lines changed: 1085 additions & 33 deletions

packages/vanilla/src/graph-mount.ts

Lines changed: 127 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
import { buildEdgeTooltip, compileGraph } from '@opendata-ai/openchart-engine';
2323
import { type CameraFlightOptions, clampK, createCameraFlight } from './graph/camera';
2424
import { GraphCanvasRenderer } from './graph/canvas-renderer';
25+
import { ENTRANCE_STAGGER_MAX_NODES } from './graph/entrance';
2526
import {
2627
composeStandingFocus,
2728
type FocusSnapshot,
@@ -34,6 +35,7 @@ import { createGraphLegend, type GraphLegendController } from './graph/legend';
3435
import { createTween, prefersReducedMotion, resolveEase } from './graph/motion';
3536
import { AnimationScheduler, type GraphAnimation } from './graph/scheduler';
3637
import { GraphSearchManager } from './graph/search';
38+
import { seedNodePositions } from './graph/seed';
3739
import { SimulationManager } from './graph/simulation';
3840
import { SpatialIndex } from './graph/spatial-index';
3941
import type { GraphRenderState, PositionedEdge, PositionedNode } from './graph/types';
@@ -240,6 +242,17 @@ export function createGraph(
240242
// Hovered-node radius tween (1 → 1.15), null when settled at 1.
241243
let hoverRadiusTween: { nodeId: string; scale: number } | null = null;
242244

245+
// Entrance choreography state (Phase 6). `entranceProgress` is a mount-level
246+
// 0→1 value read by buildRenderState; < 1 means the reveal is mid-flight.
247+
// `entranceActive` gates the render-state `entrance` field. `entranceFitInFlight`
248+
// tracks the entrance camera flight so a resize can cancel just it (keeping the
249+
// reveal). `entranceReveal` is the scheduler tween driving `entranceProgress`.
250+
let entranceProgress = 1;
251+
let entranceActive = false;
252+
let entranceStagger = false;
253+
let entranceFitInFlight = false;
254+
let entranceReveal: GraphAnimation | null = null;
255+
243256
// ---------------------------------------------------------------------------
244257
// Helpers
245258
// ---------------------------------------------------------------------------
@@ -510,6 +523,11 @@ export function createGraph(
510523
const simEdges = toSimEdges(compilation.edges);
511524
const config = compilation.simulationConfig;
512525

526+
// Seed deterministic initial positions BEFORE the simulation starts, so the
527+
// settled layout is reproducible for a given (spec, seed). `seed ?? 0` keeps
528+
// the default path seeded too (still deterministic, just a fixed seed).
529+
seedNodePositions(simNodes, config.seed ?? 0);
530+
513531
simulation = SimulationManager.create(simNodes, simEdges, {
514532
chargeStrength: config.chargeStrength,
515533
linkDistance: config.linkDistance,
@@ -520,6 +538,9 @@ export function createGraph(
520538
collisionPadding: config.collisionPadding,
521539
linkStrength: config.linkStrength,
522540
centerForce: config.centerForce,
541+
warmupTicks: config.warmupTicks,
542+
warmupBudgetMs: config.warmupBudgetMs,
543+
initialAlpha: config.initialAlpha,
523544
});
524545

525546
let initialSettleDone = false;
@@ -535,9 +556,9 @@ export function createGraph(
535556
}
536557

537558
// Build positioned nodes
538-
positionedNodes = compilation.nodes.map((node) => {
559+
positionedNodes = compilation.nodes.map((node, index) => {
539560
const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
540-
return { ...node, x: pos.x, y: pos.y };
561+
return { ...node, x: pos.x, y: pos.y, index };
541562
});
542563

543564
// Build positioned edges
@@ -556,19 +577,17 @@ export function createGraph(
556577
// Rebuild spatial index
557578
spatialIndex.rebuild(positionedNodes);
558579

559-
// Fit the viewport once on the first tick so the graph is visible and
560-
// centered immediately. After that, let the user interact freely while
561-
// the simulation continues settling in the background.
580+
// Fit + entrance choreography on the FIRST post-warmup tick (the sim only
581+
// starts streaming ticks once warmup, if any, has completed). After that,
582+
// let the user interact freely while the simulation keeps settling.
562583
if (
563584
!initialFitDone &&
564585
positionedNodes.length > 0 &&
565586
interactionManager &&
566587
options?.fitOnLoad !== false
567588
) {
568589
initialFitDone = true;
569-
const { width: cw, height: ch } = getCanvasDimensions();
570-
const { transform: fitTransform } = ZoomTransform.fitBounds(positionedNodes, cw, ch);
571-
interactionManager.setTransform(fitTransform);
590+
startEntrance();
572591
} else if (!initialFitDone && options?.fitOnLoad === false) {
573592
// Skip the fit but mark it done so a saved camera (getCamera/flyTo) sticks.
574593
initialFitDone = true;
@@ -584,6 +603,76 @@ export function createGraph(
584603
});
585604
}
586605

606+
/**
607+
* Compute the initial fit transform. Bypasses fitBounds' spread inflation when
608+
* warmup ran (warmed bounds are near-final; inflating them fits too small).
609+
*/
610+
function computeInitialFit(): ZoomTransform {
611+
const { width: cw, height: ch } = getCanvasDimensions();
612+
const warmed = (compilation.simulationConfig.warmupTicks ?? 0) > 0;
613+
const { transform } = ZoomTransform.fitBounds(positionedNodes, cw, ch, undefined, {
614+
spread: !warmed,
615+
});
616+
return transform;
617+
}
618+
619+
/**
620+
* Fit + run the entrance reveal on the first post-warmup tick. Under an enabled
621+
* `enter` phase and normal motion, the camera starts pulled back to 0.92× the
622+
* fit and (optionally) flies in while a mount-level `entranceProgress` tween
623+
* ramps node/edge/label reveal. Under reduced motion or `animation: false`,
624+
* it's an instant fit (warmup still ran — it reduces motion, it isn't motion).
625+
*/
626+
function startEntrance(): void {
627+
if (!interactionManager) return;
628+
const fit = computeInitialFit();
629+
const enter = compilation.animation?.enter ?? null;
630+
631+
if (!enter || prefersReducedMotion()) {
632+
interactionManager.setTransform(fit);
633+
entranceActive = false;
634+
entranceProgress = 1;
635+
return;
636+
}
637+
638+
// Start pulled back so the reveal has somewhere to fly in from.
639+
const { width: cw, height: ch } = getCanvasDimensions();
640+
const pulledBack = fit.zoomAt(fit.k * 0.92, cw / 2, ch / 2);
641+
interactionManager.setTransform(pulledBack);
642+
643+
entranceActive = true;
644+
entranceProgress = 0;
645+
// Stagger only when the spec asks for it AND the graph is small enough that
646+
// per-node start times still batch. Above the cap: a single global fade.
647+
entranceStagger = enter.stagger && positionedNodes.length <= ENTRANCE_STAGGER_MAX_NODES;
648+
649+
// Optional camera flight from the pulled-back framing to the true fit.
650+
if (enter.cameraFit) {
651+
entranceFitInFlight = true;
652+
flyCamera(fit, { duration: enter.duration + 100 }, () => {
653+
entranceFitInFlight = false;
654+
});
655+
}
656+
657+
// Reveal tween drives entranceProgress 0→1; ends the entrance on completion.
658+
const ease = resolveEase(enter.ease);
659+
entranceReveal = createTween({
660+
duration: enter.duration,
661+
ease,
662+
apply: (t) => {
663+
entranceProgress = t;
664+
needsRender = true;
665+
},
666+
onDone: () => {
667+
entranceProgress = 1;
668+
entranceActive = false;
669+
entranceReveal = null;
670+
needsRender = true;
671+
},
672+
});
673+
scheduler.add(entranceReveal);
674+
}
675+
587676
function getCanvasDimensions(): { width: number; height: number } {
588677
if (!canvas) return { width: 600, height: 400 };
589678
const rect = canvas.getBoundingClientRect();
@@ -840,6 +929,10 @@ export function createGraph(
840929
focus: focus ?? { t: 1, prev: settledNext, next: settledNext },
841930
hoverRadiusScale,
842931
dimOpacity: highlightDimOpacity ?? compilation.interaction.dimOpacity,
932+
entrance:
933+
entranceActive && entranceProgress < 1
934+
? { t: entranceProgress, stagger: entranceStagger }
935+
: undefined,
843936
};
844937
}
845938

@@ -1244,6 +1337,16 @@ export function createGraph(
12441337
const { width, height } = getContainerDimensions();
12451338
const canvasHeight = Math.max(height, 200);
12461339
renderer.resize(width, canvasHeight);
1340+
1341+
// Mid-entrance: the in-flight camera fit targets the OLD viewport, so cancel
1342+
// just that flight and snap to the new-viewport fit. The reveal tween (node
1343+
// alpha/scale ramp) is orthogonal to the camera and keeps running.
1344+
if (entranceFitInFlight && interactionManager) {
1345+
cancelFlight();
1346+
entranceFitInFlight = false;
1347+
interactionManager.setTransform(computeInitialFit());
1348+
}
1349+
12471350
needsRender = true;
12481351
scheduleRender();
12491352
}
@@ -1252,6 +1355,15 @@ export function createGraph(
12521355
if (destroyed) return;
12531356
currentSpec = newSpec;
12541357

1358+
// Finish any in-flight animations (e.g. an entrance reveal) before teardown
1359+
// so they snap to their final state and fire onDone, rather than being hard
1360+
// cancelled mid-flight. teardownSubsystems() then cancels whatever remains.
1361+
scheduler.finishAll();
1362+
entranceActive = false;
1363+
entranceProgress = 1;
1364+
entranceFitInFlight = false;
1365+
entranceReveal = null;
1366+
12551367
// Tear down old simulation + interaction
12561368
teardownSubsystems();
12571369

@@ -1300,9 +1412,9 @@ export function createGraph(
13001412
buildDataMaps();
13011413

13021414
// Transfer positions to new compiled nodes
1303-
positionedNodes = compilation.nodes.map((node) => {
1415+
positionedNodes = compilation.nodes.map((node, index) => {
13041416
const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
1305-
return { ...node, x: pos.x, y: pos.y };
1417+
return { ...node, x: pos.x, y: pos.y, index };
13061418
});
13071419

13081420
// Rebuild positioned edges from existing positions
@@ -1342,6 +1454,11 @@ export function createGraph(
13421454
// writes to removed state.
13431455
scheduler.cancelAll();
13441456
activeFlight = null;
1457+
// Reset entrance state so a remount (React StrictMode) starts clean.
1458+
entranceReveal = null;
1459+
entranceActive = false;
1460+
entranceProgress = 1;
1461+
entranceFitInFlight = false;
13451462
if (animFrameId !== null) {
13461463
cancelAnimationFrame(animFrameId);
13471464
animFrameId = null;

packages/vanilla/src/graph/__tests__/canvas-renderer.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ function makeNode(overrides: Partial<PositionedNode> & { id: string }): Position
128128
return {
129129
x: 0,
130130
y: 0,
131+
index: 0,
131132
radius: 5,
132133
fill: '#3b82f6',
133134
stroke: '#2563eb',
@@ -871,3 +872,89 @@ describe('label halo on transparent background', () => {
871872
expect(haloColor(false)).toBe('rgba(255, 255, 255, 0.85)');
872873
});
873874
});
875+
876+
// ---------------------------------------------------------------------------
877+
// Entrance reveal ramp
878+
// ---------------------------------------------------------------------------
879+
880+
describe('GraphCanvasRenderer entrance', () => {
881+
it('mid-entrance node fills use a ramped alpha (0.6 + 0.4·t), below 1', () => {
882+
const { canvas, calls } = createRecordingCanvas();
883+
const renderer = new GraphCanvasRenderer(canvas);
884+
renderer.resize(400, 400);
885+
886+
renderer.render(
887+
makeState({
888+
nodes: [makeNode({ id: 'a', index: 0 })],
889+
// Global fade (no stagger): nodeT = t = 0.5 → alpha 0.6 + 0.4·0.5 = 0.8.
890+
entrance: { t: 0.5, stagger: false },
891+
}),
892+
);
893+
894+
const fills = calls.filter((c) => c.method === 'fill' && c.alpha !== undefined);
895+
expect(fills.length).toBeGreaterThan(0);
896+
const nodeFill = fills.find((c) => (c.alpha ?? 1) < 1);
897+
expect(nodeFill).toBeDefined();
898+
expect(nodeFill!.alpha).toBeCloseTo(0.8, 5);
899+
});
900+
901+
it('settled (t≥1) renders at full alpha — entrance is a no-op', () => {
902+
const { canvas, calls } = createRecordingCanvas();
903+
const renderer = new GraphCanvasRenderer(canvas);
904+
renderer.resize(400, 400);
905+
906+
renderer.render(
907+
makeState({
908+
nodes: [makeNode({ id: 'a', index: 0 })],
909+
entrance: { t: 1, stagger: false },
910+
}),
911+
);
912+
913+
const fills = calls.filter((c) => c.method === 'fill' && c.alpha !== undefined);
914+
// No ramped (sub-1) node fill: the entrance path is skipped at t≥1.
915+
expect(fills.every((c) => (c.alpha ?? 1) >= 1 - 1e-9)).toBe(true);
916+
});
917+
918+
it('edges lag 30% behind the reveal (edge alpha 0 until t>0.3)', () => {
919+
const { canvas, calls } = createRecordingCanvas();
920+
const renderer = new GraphCanvasRenderer(canvas);
921+
renderer.resize(400, 400);
922+
923+
renderer.render(
924+
makeState({
925+
nodes: [makeNode({ id: 'a', index: 0 }), makeNode({ id: 'b', index: 1, x: 100 })],
926+
edges: [makeEdge('a', 'b')],
927+
// At t=0.2 (< 0.3 lag), the edge alpha scales to 0 → invisible stroke.
928+
entrance: { t: 0.2, stagger: false },
929+
}),
930+
);
931+
932+
// Edges are stroked before nodes; the edge stroke lands at alpha 0 (fully
933+
// lagged), while node strokes ramp to 0.68 (0.6 + 0.4·0.2). So the very
934+
// first stroke recorded is the edge, and it must be transparent.
935+
const strokes = calls.filter((c) => c.method === 'stroke' && c.alpha !== undefined);
936+
expect(strokes.length).toBeGreaterThan(0);
937+
expect(strokes[0].alpha).toBe(0);
938+
// At least one stroke (the edge) is fully lagged to 0.
939+
expect(strokes.some((c) => c.alpha === 0)).toBe(true);
940+
});
941+
942+
it('past the 30% lag, edges fade in (edge alpha > 0)', () => {
943+
const { canvas, calls } = createRecordingCanvas();
944+
const renderer = new GraphCanvasRenderer(canvas);
945+
renderer.resize(400, 400);
946+
947+
renderer.render(
948+
makeState({
949+
nodes: [makeNode({ id: 'a', index: 0 }), makeNode({ id: 'b', index: 1, x: 100 })],
950+
edges: [makeEdge('a', 'b')],
951+
// t=0.65 → edgeAlpha = (0.65-0.3)/0.7 = 0.5, scaling the 0.35 default edge
952+
// alpha to 0.175. The first stroke (edge) is now visible.
953+
entrance: { t: 0.65, stagger: false },
954+
}),
955+
);
956+
957+
const strokes = calls.filter((c) => c.method === 'stroke' && c.alpha !== undefined);
958+
expect(strokes[0].alpha).toBeGreaterThan(0);
959+
});
960+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Entrance stagger math: per-node reveal window, quantization, and the
3+
* stagger-vs-global-fade node-count threshold.
4+
*/
5+
6+
import { describe, expect, it } from 'vitest';
7+
import { ENTRANCE_STAGGER_MAX_NODES, nodeEnterProgress } from '../entrance';
8+
9+
describe('nodeEnterProgress', () => {
10+
it('clamps global progress to [0, 1]', () => {
11+
expect(nodeEnterProgress(-0.5, 0, 10)).toBe(0);
12+
expect(nodeEnterProgress(2, 0, 10)).toBe(1);
13+
});
14+
15+
it('first node fully revealed by t=0.6, last node by t=1.0', () => {
16+
const total = 10;
17+
// Node 0 starts at t=0, ramps over 0.6 → fully in at 0.6.
18+
expect(nodeEnterProgress(0.6, 0, total)).toBe(1);
19+
// Last node starts at (9/10)*0.4 = 0.36, ramps over 0.6 → fully in at 0.96.
20+
expect(nodeEnterProgress(1, total - 1, total)).toBe(1);
21+
// ...and is not yet fully in mid-timeline.
22+
expect(nodeEnterProgress(0.6, total - 1, total)).toBeLessThan(1);
23+
});
24+
25+
it('later nodes start later than earlier nodes', () => {
26+
const total = 100;
27+
const early = nodeEnterProgress(0.4, 0, total);
28+
const late = nodeEnterProgress(0.4, 99, total);
29+
expect(early).toBeGreaterThan(late);
30+
});
31+
32+
it('quantizes to at most `buckets` distinct levels', () => {
33+
const total = 1000;
34+
const values = new Set<number>();
35+
for (let i = 0; i < total; i++) {
36+
values.add(nodeEnterProgress(0.5, i, total, 8));
37+
}
38+
// ≤ 9 levels (0, 1/8, ..., 1) preserves fill batching.
39+
expect(values.size).toBeLessThanOrEqual(9);
40+
});
41+
42+
it('a smaller bucket count yields fewer levels', () => {
43+
const total = 500;
44+
const values = new Set<number>();
45+
for (let i = 0; i < total; i++) values.add(nodeEnterProgress(0.5, i, total, 4));
46+
expect(values.size).toBeLessThanOrEqual(5);
47+
});
48+
49+
it('handles total=0 without dividing by zero', () => {
50+
expect(() => nodeEnterProgress(0.5, 0, 0)).not.toThrow();
51+
});
52+
});
53+
54+
describe('ENTRANCE_STAGGER_MAX_NODES', () => {
55+
it('is the documented 3000 threshold', () => {
56+
expect(ENTRANCE_STAGGER_MAX_NODES).toBe(3000);
57+
});
58+
});

0 commit comments

Comments
 (0)