Skip to content

Commit 2024728

Browse files
committed
feat(graph): organic entrance pop + chrome collision fix
Entrance redesign: nodes pop in with an easeOutBack scale overshoot, ripple outward from the layout centroid, and converge from a 16px radial drift while the camera flies in from a deeper 0.85 pullback. Default enter duration drops 600ms -> 500ms. Unstaggered (3k+ node) graphs keep the batched global fade. Chrome collision fix: the chrome overlay reserves the legend's width (dynamic style.right) and fitBounds gains an insetTop reserve so titles no longer run under the legend and nodes no longer render behind chrome text. Claude-Session: https://claude.ai/code/session_016rS7hi2g4bSzRBfnD1p2ji
1 parent 4f4ae02 commit 2024728

11 files changed

Lines changed: 338 additions & 47 deletions

File tree

packages/core/schema/vizspec.schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17133,7 +17133,7 @@
1713317133
"type": "boolean"
1713417134
}
1713517135
],
17136-
"description": "Node/edge reveal on first render. Default `{ duration: 600, ease: 'smooth', stagger: true }`."
17136+
"description": "Node/edge reveal on first render. Default `{ duration: 500, ease: 'smooth', stagger: true }`."
1713717137
},
1713817138
"update": {
1713917139
"anyOf": [

packages/core/src/types/spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2137,7 +2137,7 @@ export interface NodeOverride {
21372137
* already exists on load and choreography reduces the chaos.
21382138
*/
21392139
export interface GraphAnimationConfig {
2140-
/** Node/edge reveal on first render. Default `{ duration: 600, ease: 'smooth', stagger: true }`. */
2140+
/** Node/edge reveal on first render. Default `{ duration: 500, ease: 'smooth', stagger: true }`. */
21412141
enter?: AnimationPhaseConfig | boolean;
21422142
/** Data-update enter-fade for newly added marks. Default `{ duration: 300, ease: 'smooth' }`. */
21432143
update?: AnimationPhaseConfig | boolean;

packages/engine/src/graphs/__tests__/graph-resolvers.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ describe('resolveGraphAnimation', () => {
2424
it('returns full defaults when omitted (default-ON)', () => {
2525
const r = resolveGraphAnimation(undefined);
2626
expect(r).toBeDefined();
27-
expect(r?.enter).toEqual({ duration: 600, ease: 'smooth', stagger: true, cameraFit: true });
27+
expect(r?.enter).toEqual({ duration: 500, ease: 'smooth', stagger: true, cameraFit: true });
2828
expect(r?.update).toEqual({ duration: 300, ease: 'smooth' });
2929
expect(r?.exit).toEqual({ duration: 300, ease: 'smooth' });
3030
expect(r?.camera).toEqual({ duration: 'auto', ease: 'smooth' });
@@ -36,7 +36,7 @@ describe('resolveGraphAnimation', () => {
3636
});
3737

3838
it('returns full defaults for true', () => {
39-
expect(resolveGraphAnimation(true)?.enter?.duration).toBe(600);
39+
expect(resolveGraphAnimation(true)?.enter?.duration).toBe(500);
4040
});
4141

4242
it('disables just the named phase when set false', () => {
@@ -270,7 +270,7 @@ describe('layout presets + seed + highlight', () => {
270270

271271
it('resolves animation (default-ON) and interaction on the compilation', () => {
272272
const r = compileGraph(base, compileOptions);
273-
expect(r.animation?.enter?.duration).toBe(600);
273+
expect(r.animation?.enter?.duration).toBe(500);
274274
expect(r.interaction.hoverMode).toBe('neighbors');
275275
});
276276

packages/engine/src/graphs/animation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export interface ResolvedGraphAnimation {
3636
}
3737

3838
const ENTER_DEFAULT = {
39-
duration: 600,
39+
duration: 500,
4040
ease: 'smooth' as AnimationEase,
4141
stagger: true,
4242
cameraFit: true,

packages/vanilla/src/graph-mount.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
createCameraFollow,
2828
} from './graph/camera';
2929
import { GraphCanvasRenderer } from './graph/canvas-renderer';
30-
import { ENTRANCE_STAGGER_MAX_NODES } from './graph/entrance';
30+
import { ENTRANCE_STAGGER_MAX_NODES, entranceOffsets, entranceOrder } from './graph/entrance';
3131
import {
3232
composeStandingFocus,
3333
type FocusSnapshot,
@@ -278,6 +278,10 @@ export function createGraph(
278278
let entranceProgress = 1;
279279
let entranceActive = false;
280280
let entranceStagger = false;
281+
// Pop choreography inputs (staggered entrances only): centroid-radial stagger
282+
// rank and per-node convergence drift vectors, built once at entrance start.
283+
let entranceOrderMap: Map<string, number> | null = null;
284+
let entranceOffsetMap: Map<string, { x: number; y: number }> | null = null;
281285
let entranceFitInFlight = false;
282286
let entranceReveal: GraphAnimation | null = null;
283287
// Mount-level opt-out (Phase 9): when set, the FIRST entrance takes the instant
@@ -489,6 +493,7 @@ export function createGraph(
489493
}
490494

491495
container.appendChild(wrapper);
496+
syncChromeInset();
492497

493498
// Canvas uses the full container height; chrome overlays on top
494499
const canvasHeight = Math.max(height, 200);
@@ -553,6 +558,28 @@ export function createGraph(
553558
/** Re-render the legend to reflect the current active-category state. */
554559
function syncLegendActiveState(): void {
555560
if (legendController) legendController.update(legendViewData());
561+
syncChromeInset();
562+
}
563+
564+
/**
565+
* Keep the chrome block out of the legend's column: the title/subtitle wrap
566+
* before they reach the legend box instead of running underneath it. No-op
567+
* when there's no legend (or it has no measurable width, e.g. in happy-dom).
568+
*/
569+
function syncChromeInset(): void {
570+
if (!chromeEl) return;
571+
const legendW = legendEl?.offsetWidth ?? 0;
572+
chromeEl.style.right = legendW > 0 ? `${legendW + 24}px` : '';
573+
}
574+
575+
/**
576+
* Height of the chrome overlay band (title + subtitle) the camera fit should
577+
* reserve, so nodes never settle underneath the text. 0 when chrome is empty
578+
* or unmeasurable (happy-dom).
579+
*/
580+
function chromeInsetTop(): number {
581+
if (!chromeEl || chromeEl.style.display === 'none') return 0;
582+
return chromeEl.offsetHeight;
556583
}
557584

558585
// ---------------------------------------------------------------------------
@@ -723,6 +750,7 @@ export function createGraph(
723750
const warmed = (compilation.simulationConfig.warmupTicks ?? 0) > 0;
724751
const { transform } = ZoomTransform.fitBounds(positionedNodes, cw, ch, undefined, {
725752
spread: !warmed,
753+
insetTop: chromeInsetTop(),
726754
});
727755
return transform;
728756
}
@@ -753,7 +781,7 @@ export function createGraph(
753781

754782
// Start pulled back so the reveal has somewhere to fly in from.
755783
const { width: cw, height: ch } = getCanvasDimensions();
756-
const pulledBack = fit.zoomAt(fit.k * 0.92, cw / 2, ch / 2);
784+
const pulledBack = fit.zoomAt(fit.k * 0.85, cw / 2, ch / 2);
757785
interactionManager.setTransform(pulledBack);
758786
cameraChangePending = true;
759787

@@ -762,6 +790,15 @@ export function createGraph(
762790
// Stagger only when the spec asks for it AND the graph is small enough that
763791
// per-node start times still batch. Above the cap: a single global fade.
764792
entranceStagger = enter.stagger && positionedNodes.length <= ENTRANCE_STAGGER_MAX_NODES;
793+
// Pop choreography inputs, computed once against the warmed (near-final)
794+
// positions: centroid-radial stagger order + per-node convergence drift.
795+
if (entranceStagger) {
796+
entranceOrderMap = entranceOrder(positionedNodes);
797+
entranceOffsetMap = entranceOffsets(positionedNodes);
798+
} else {
799+
entranceOrderMap = null;
800+
entranceOffsetMap = null;
801+
}
765802

766803
// Optional camera flight from the pulled-back framing to the true fit.
767804
if (enter.cameraFit) {
@@ -1048,7 +1085,12 @@ export function createGraph(
10481085
dimOpacity: highlightDimOpacity ?? compilation.interaction.dimOpacity,
10491086
entrance:
10501087
entranceActive && entranceProgress < 1
1051-
? { t: entranceProgress, stagger: entranceStagger }
1088+
? {
1089+
t: entranceProgress,
1090+
stagger: entranceStagger,
1091+
order: entranceOrderMap ?? undefined,
1092+
offsets: entranceOffsetMap ?? undefined,
1093+
}
10521094
: undefined,
10531095
enterAlpha: enterAlphaMap ?? undefined,
10541096
exiting: exitingGhosts ?? undefined,
@@ -1372,6 +1414,9 @@ export function createGraph(
13721414
cw,
13731415
ch,
13741416
opts?.padding,
1417+
{
1418+
insetTop: chromeInsetTop(),
1419+
},
13751420
);
13761421
flyCamera(fitTransform, opts);
13771422
}
@@ -1494,6 +1539,9 @@ export function createGraph(
14941539
const { width, height } = getContainerDimensions();
14951540
const canvasHeight = Math.max(height, 200);
14961541
renderer.resize(width, canvasHeight);
1542+
// A width change can rewrap the title or move the legend; re-derive the
1543+
// chrome/legend separation before any fit below measures the chrome band.
1544+
syncChromeInset();
14971545

14981546
// Mid-entrance: the in-flight camera fit targets the OLD viewport, so cancel
14991547
// just that flight and snap to the new-viewport fit. The reveal tween (node

packages/vanilla/src/graph/__tests__/entrance.test.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,16 @@
44
*/
55

66
import { describe, expect, it } from 'vitest';
7-
import { ENTRANCE_STAGGER_MAX_NODES, nodeEnterProgress } from '../entrance';
7+
import {
8+
driftFactor,
9+
ENTRANCE_DRIFT_PX,
10+
ENTRANCE_STAGGER_MAX_NODES,
11+
entranceOffsets,
12+
entranceOrder,
13+
nodeEnterProgress,
14+
popAlpha,
15+
popScale,
16+
} from '../entrance';
817

918
describe('nodeEnterProgress', () => {
1019
it('clamps global progress to [0, 1]', () => {
@@ -56,3 +65,91 @@ describe('ENTRANCE_STAGGER_MAX_NODES', () => {
5665
expect(ENTRANCE_STAGGER_MAX_NODES).toBe(3000);
5766
});
5867
});
68+
69+
describe('entranceOrder', () => {
70+
it('ranks nodes by distance from the centroid, ascending', () => {
71+
// Centroid of these four is (0, 0); 'near' is closest, 'far' farthest.
72+
const nodes = [
73+
{ id: 'far', x: 100, y: 0 },
74+
{ id: 'near', x: 2, y: 0 },
75+
{ id: 'mid', x: -30, y: 0 },
76+
{ id: 'balance', x: -72, y: 0 },
77+
];
78+
const order = entranceOrder(nodes);
79+
expect(order.get('near')).toBe(0);
80+
expect(order.get('mid')).toBe(1);
81+
expect(order.get('balance')).toBe(2);
82+
expect(order.get('far')).toBe(3);
83+
});
84+
85+
it('returns an empty map for zero nodes', () => {
86+
expect(entranceOrder([]).size).toBe(0);
87+
});
88+
});
89+
90+
describe('entranceOffsets', () => {
91+
it('points away from the centroid with magnitude ENTRANCE_DRIFT_PX', () => {
92+
const nodes = [
93+
{ id: 'a', x: 10, y: 0 },
94+
{ id: 'b', x: -10, y: 0 },
95+
];
96+
const offsets = entranceOffsets(nodes);
97+
expect(offsets.get('a')).toEqual({ x: ENTRANCE_DRIFT_PX, y: 0 });
98+
expect(offsets.get('b')).toEqual({ x: -ENTRANCE_DRIFT_PX, y: 0 });
99+
});
100+
101+
it('honors a custom distance', () => {
102+
const offsets = entranceOffsets(
103+
[
104+
{ id: 'a', x: 5, y: 0 },
105+
{ id: 'b', x: -5, y: 0 },
106+
],
107+
4,
108+
);
109+
expect(offsets.get('a')).toEqual({ x: 4, y: 0 });
110+
});
111+
112+
it('falls back to straight-up for a node sitting on the centroid', () => {
113+
// A single node IS the centroid — zero-length direction vector.
114+
const offsets = entranceOffsets([{ id: 'solo', x: 42, y: 42 }]);
115+
expect(offsets.get('solo')).toEqual({ x: 0, y: -ENTRANCE_DRIFT_PX });
116+
});
117+
118+
it('returns an empty map for zero nodes', () => {
119+
expect(entranceOffsets([]).size).toBe(0);
120+
});
121+
});
122+
123+
describe('popScale', () => {
124+
it('is 0 at t≤0 and 1 at t≥1', () => {
125+
expect(popScale(0)).toBe(0);
126+
expect(popScale(-1)).toBe(0);
127+
expect(popScale(1)).toBe(1);
128+
expect(popScale(2)).toBe(1);
129+
});
130+
131+
it('overshoots past 1 mid-curve (the pop)', () => {
132+
const peak = Math.max(...Array.from({ length: 99 }, (_, i) => popScale((i + 1) / 100)));
133+
expect(peak).toBeGreaterThan(1.05);
134+
expect(peak).toBeLessThan(1.15);
135+
});
136+
});
137+
138+
describe('popAlpha', () => {
139+
it('reaches full opacity by 60% of the window', () => {
140+
expect(popAlpha(0)).toBe(0);
141+
expect(popAlpha(0.3)).toBeCloseTo(0.5, 5);
142+
expect(popAlpha(0.6)).toBe(1);
143+
expect(popAlpha(1)).toBe(1);
144+
});
145+
});
146+
147+
describe('driftFactor', () => {
148+
it('eases quadratically from 1 (full offset) to 0 (at rest)', () => {
149+
expect(driftFactor(0)).toBe(1);
150+
expect(driftFactor(0.5)).toBeCloseTo(0.25, 5);
151+
expect(driftFactor(1)).toBe(0);
152+
expect(driftFactor(-1)).toBe(1);
153+
expect(driftFactor(2)).toBe(0);
154+
});
155+
});

packages/vanilla/src/graph/__tests__/graph-mount.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -321,14 +321,14 @@ describe('createGraph entrance', () => {
321321
pumpRaf(0);
322322

323323
const pulledBack = graph.getCamera();
324-
// The entrance starts at 0.92× the fit zoom — strictly less than the final.
324+
// The entrance starts at 0.85× the fit zoom — strictly less than the final.
325325
// Drive far past the reveal (duration 600) + the fit flight (+100) to settle.
326326
for (let t = 50; t <= 1400; t += 50) pumpRaf(t);
327327
const settled = graph.getCamera();
328328

329-
// Pulled-back zoom is ~0.92 of the settled fit zoom.
329+
// Pulled-back zoom is ~0.85 of the settled fit zoom.
330330
expect(pulledBack.k).toBeLessThan(settled.k);
331-
expect(pulledBack.k).toBeCloseTo(settled.k * 0.92, 5);
331+
expect(pulledBack.k).toBeCloseTo(settled.k * 0.85, 5);
332332

333333
graph.destroy();
334334
});
@@ -350,7 +350,7 @@ describe('createGraph entrance', () => {
350350
expect(later.x).toBeCloseTo(initial.x, 6);
351351
expect(later.y).toBeCloseTo(initial.y, 6);
352352
// The instant fit is a real fit (finite, positive zoom), not the pulled-back
353-
// framing — i.e. reduced motion skipped the 0.92 pullback.
353+
// framing — i.e. reduced motion skipped the 0.85 pullback.
354354
expect(initial.k).toBeGreaterThan(0);
355355

356356
graph.destroy();
@@ -445,7 +445,7 @@ describe('createGraph entrance', () => {
445445
graph.destroy();
446446
});
447447

448-
it('suppressEntrance: first camera is the instant fit (no 0.92 pullback, no reveal tween)', async () => {
448+
it('suppressEntrance: first camera is the instant fit (no 0.85 pullback, no reveal tween)', async () => {
449449
container = makeContainer();
450450
// Same warmed spec that DOES animate an entrance without suppressEntrance —
451451
// the only difference here is the mount option.
@@ -462,7 +462,7 @@ describe('createGraph entrance', () => {
462462
expect(later.k).toBeCloseTo(initial.k, 6);
463463
expect(later.x).toBeCloseTo(initial.x, 6);
464464
expect(later.y).toBeCloseTo(initial.y, 6);
465-
// A real fit (finite, positive zoom), not the pulled-back 0.92 framing.
465+
// A real fit (finite, positive zoom), not the pulled-back 0.85 framing.
466466
expect(initial.k).toBeGreaterThan(0);
467467

468468
graph.destroy();

0 commit comments

Comments
 (0)