Skip to content

Commit 8d017e8

Browse files
committed
feat(graph): physics feel — springy drag + cursor repulsion (Phase 8)
Springy drag rides the existing pin/unpin message types with a new optional alphaTarget field (stale-worker safe: an old cached worker ignores the unknown field and runs the exact legacy behavior; the message is byte-identical when springy is off). Pin sends alphaTarget 0.3 to hold the sim warm so neighbors follow; unpin sends 0 to cool it back down. Cursor repulsion adds a new pointer message ({ x, y, active }) driving a custom forceCursor registered as .force('cursor', ...) in BOTH the worker and the sync fallback (source-parity honored). Nodes within radius are pushed away with linear falloff; outside-radius nodes are untouched. Degrades to nothing on a stale worker (no case, no default), which is fine for an ambient effect. Gates: springy off above 5000 nodes; cursor off above 2000 nodes (mirrors the glow gate) and under prefers-reduced-motion. Both fully off by default — a plain graph emits zero new fields or messages. Drag and cursor track alphaTarget intents separately (drag 0.3, cursor 0.03) so neither stomps the other; the higher intent wins. Claude-Session: https://claude.ai/code/session_016rS7hi2g4bSzRBfnD1p2ji
1 parent 06c0932 commit 8d017e8

7 files changed

Lines changed: 840 additions & 17 deletions

File tree

packages/vanilla/src/graph-mount.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,17 @@ function resolveDarkMode(mode?: DarkMode): boolean {
167167
return false;
168168
}
169169

170+
// ---------------------------------------------------------------------------
171+
// Physics-feel gates (Phase 8)
172+
// ---------------------------------------------------------------------------
173+
174+
/** Springy drag disables above this node count (warm-sim cost at scale). */
175+
const SPRINGY_DRAG_MAX_NODES = 5000;
176+
/** Cursor-repulsion disables above this node count (mirrors the glow gate). */
177+
const CURSOR_FORCE_MAX_NODES = 2000;
178+
/** Cursor pointer-feed throttle (~30Hz) so we don't post on every mousemove. */
179+
const CURSOR_POINTER_THROTTLE_MS = 33;
180+
170181
// ---------------------------------------------------------------------------
171182
// Main API
172183
// ---------------------------------------------------------------------------
@@ -223,6 +234,8 @@ export function createGraph(
223234
const scheduler = new AnimationScheduler(() => scheduleRender());
224235
let gestureTimeout: ReturnType<typeof setTimeout> | null = null;
225236
let lastEdgeHitTime = 0;
237+
// Cursor-repulsion pointer-feed throttle timestamp (Phase 8).
238+
let lastPointerFeedTime = 0;
226239
// Camera flight state.
227240
let activeFlight: GraphAnimation | null = null;
228241
let cameraChangePending = false;
@@ -523,6 +536,33 @@ export function createGraph(
523536
if (legendController) legendController.update(legendViewData());
524537
}
525538

539+
// ---------------------------------------------------------------------------
540+
// Physics-feel gates (Phase 8)
541+
// ---------------------------------------------------------------------------
542+
543+
/**
544+
* Springy drag on: config opts in AND the graph is small enough that holding
545+
* the sim warm during a drag is cheap. Off/above threshold → legacy pin/unpin.
546+
*/
547+
function springyDragEnabled(): boolean {
548+
return (
549+
compilation.interaction.springyDrag && compilation.nodes.length <= SPRINGY_DRAG_MAX_NODES
550+
);
551+
}
552+
553+
/**
554+
* Cursor repulsion on: config opts in, the graph is small enough (mirrors the
555+
* glow gate), and reduced motion is off (ambient pointer-driven motion is
556+
* exactly what reduced-motion suppresses).
557+
*/
558+
function cursorForceEnabled(): boolean {
559+
return (
560+
compilation.interaction.cursorRepulsion !== null &&
561+
compilation.nodes.length <= CURSOR_FORCE_MAX_NODES &&
562+
!prefersReducedMotion()
563+
);
564+
}
565+
526566
// ---------------------------------------------------------------------------
527567
// Simulation and animation
528568
// ---------------------------------------------------------------------------
@@ -583,6 +623,9 @@ export function createGraph(
583623
warmupTicks: opts?.skipWarmup ? 0 : config.warmupTicks,
584624
warmupBudgetMs: config.warmupBudgetMs,
585625
initialAlpha: opts?.initialAlpha ?? config.initialAlpha,
626+
// Cursor force radius/strength (null when disabled or gated off by node
627+
// count). The mount only feeds pointer positions when the same gate holds.
628+
cursorRepulsion: cursorForceEnabled() ? compilation.interaction.cursorRepulsion : null,
586629
});
587630

588631
let initialSettleDone = false;
@@ -1183,16 +1226,32 @@ export function createGraph(
11831226
const node = positionedNodes.find((n) => n.id === nodeId);
11841227
const x = node?.x ?? 0;
11851228
const y = node?.y ?? 0;
1186-
simulation?.pinNode(nodeId, x, y);
1229+
// Springy: hold the sim warm (alphaTarget 0.3) so neighbors follow. Off
1230+
// → legacy pin with no alphaTarget field (byte-identical message).
1231+
simulation?.pinNode(nodeId, x, y, springyDragEnabled() ? 0.3 : undefined);
11871232
canvas?.classList.add('oc-graph-canvas--dragging');
11881233
},
11891234
onNodeDrag(nodeId, x, y) {
11901235
simulation?.dragNode(nodeId, x, y);
11911236
},
11921237
onNodeDragEnd(nodeId) {
1193-
simulation?.unpinNode(nodeId);
1238+
// Springy: cool the sim back down (alphaTarget 0). Off → legacy unpin
1239+
// with no alphaTarget field (preserves the legacy reheat behavior).
1240+
simulation?.unpinNode(nodeId, springyDragEnabled() ? 0 : undefined);
11941241
canvas?.classList.remove('oc-graph-canvas--dragging');
11951242
},
1243+
onPointerMove(graphX, graphY) {
1244+
if (!cursorForceEnabled()) return;
1245+
// Throttle the pointer feed to ~30Hz so we don't post on every mousemove.
1246+
const now = performance.now();
1247+
if (now - lastPointerFeedTime < CURSOR_POINTER_THROTTLE_MS) return;
1248+
lastPointerFeedTime = now;
1249+
simulation?.setPointer(graphX, graphY, true);
1250+
},
1251+
onPointerLeave() {
1252+
if (!cursorForceEnabled()) return;
1253+
simulation?.setPointer(0, 0, false);
1254+
},
11961255
onDoubleClick(nodeId) {
11971256
options?.onNodeDoubleClick?.(nodeDataById(nodeId));
11981257
},

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

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { GraphSpec } from '@opendata-ai/openchart-core';
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
33
import { createGraph } from '../../graph-mount';
4+
import { SimulationManager } from '../simulation';
5+
import { SpatialIndex } from '../spatial-index';
46

57
// ---------------------------------------------------------------------------
68
// Test data
@@ -398,3 +400,228 @@ describe('createGraph entrance', () => {
398400
graph.destroy();
399401
});
400402
});
403+
404+
// ---------------------------------------------------------------------------
405+
// Phase 8 — physics-feel gates (springy drag + cursor repulsion)
406+
//
407+
// happy-dom has no Worker, so the mount drives the SYNC SimulationManager. We
408+
// spy on its setPointer/pinNode/unpinNode to observe exactly what the mount
409+
// emits under each gate, and dispatch canvas mouse events to trigger the flow.
410+
// ---------------------------------------------------------------------------
411+
412+
describe('createGraph physics-feel gates', () => {
413+
let setPointerSpy: ReturnType<typeof vi.spyOn>;
414+
let pinSpy: ReturnType<typeof vi.spyOn>;
415+
let unpinSpy: ReturnType<typeof vi.spyOn>;
416+
let nowValue: number;
417+
let restoreGetContext: (() => void) | null = null;
418+
419+
function stubCanvas2D(): void {
420+
const noop = () => {};
421+
const ctx = new Proxy({} as Record<string, unknown>, {
422+
get: (_t, prop) =>
423+
prop === 'measureText' ? () => ({ width: 0 }) : prop === 'setLineDash' ? noop : noop,
424+
set: () => true,
425+
});
426+
const proto = HTMLCanvasElement.prototype as unknown as { getContext: (id: string) => unknown };
427+
const original = proto.getContext;
428+
proto.getContext = () => ctx;
429+
restoreGetContext = () => {
430+
proto.getContext = original;
431+
};
432+
}
433+
434+
function stubMatchMedia(reduced: boolean): void {
435+
const impl = (query: string) => ({
436+
matches: query.includes('reduce') ? reduced : false,
437+
media: query,
438+
addEventListener() {},
439+
removeEventListener() {},
440+
addListener() {},
441+
removeListener() {},
442+
dispatchEvent() {
443+
return false;
444+
},
445+
});
446+
vi.stubGlobal('matchMedia', impl);
447+
window.matchMedia = globalThis.matchMedia;
448+
}
449+
450+
/** A canvas mousemove at screen (cx, cy). */
451+
function moveMouse(canvas: Element, cx: number, cy: number): void {
452+
canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: cx, clientY: cy, bubbles: true }));
453+
}
454+
455+
/** A specific number of nodes in a loose chain (keeps compile cheap). */
456+
function bigSpec(n: number, interaction: GraphSpec['interaction']): GraphSpec {
457+
const nodes = Array.from({ length: n }, (_, i) => ({ id: `n${i}`, label: `N${i}` }));
458+
const edges = Array.from({ length: n - 1 }, (_, i) => ({
459+
source: `n${i}`,
460+
target: `n${i + 1}`,
461+
}));
462+
// warmupTicks 0 keeps these large-graph tests fast; positions don't matter
463+
// for the gate assertions (we spy on the emitted physics calls).
464+
return { type: 'graph', nodes, edges, interaction, layout: { warmup: false } };
465+
}
466+
467+
beforeEach(() => {
468+
nowValue = 0;
469+
stubCanvas2D();
470+
stubMatchMedia(false);
471+
vi.stubGlobal('performance', { now: () => nowValue });
472+
setPointerSpy = vi.spyOn(SimulationManager.prototype, 'setPointer');
473+
pinSpy = vi.spyOn(SimulationManager.prototype, 'pinNode');
474+
unpinSpy = vi.spyOn(SimulationManager.prototype, 'unpinNode');
475+
});
476+
477+
afterEach(() => {
478+
restoreGetContext?.();
479+
restoreGetContext = null;
480+
vi.restoreAllMocks();
481+
vi.unstubAllGlobals();
482+
});
483+
484+
it('default graph: no pointer feed emitted on mousemove', async () => {
485+
container = makeContainer();
486+
const graph = createGraph(container, bigSpec(4, undefined));
487+
await Promise.resolve();
488+
const canvas = container.querySelector('.oc-graph-canvas')!;
489+
490+
nowValue = 100;
491+
moveMouse(canvas, 400, 300);
492+
moveMouse(canvas, 410, 310);
493+
494+
// Cursor repulsion off by default → the mount never feeds pointer positions.
495+
expect(setPointerSpy).not.toHaveBeenCalled();
496+
graph.destroy();
497+
});
498+
499+
it('cursorRepulsion on + small graph: mousemove feeds the pointer (throttled)', async () => {
500+
container = makeContainer();
501+
const graph = createGraph(container, bigSpec(4, { cursorRepulsion: true }));
502+
await Promise.resolve();
503+
const canvas = container.querySelector('.oc-graph-canvas')!;
504+
505+
// First move at t=100 posts; a move within the ~33ms window is throttled.
506+
nowValue = 100;
507+
moveMouse(canvas, 400, 300);
508+
nowValue = 110;
509+
moveMouse(canvas, 405, 305);
510+
// A move past the throttle window posts again.
511+
nowValue = 200;
512+
moveMouse(canvas, 420, 320);
513+
514+
const activeCalls = setPointerSpy.mock.calls.filter((c) => c[2] === true);
515+
expect(activeCalls.length).toBe(2);
516+
graph.destroy();
517+
});
518+
519+
it('cursorRepulsion on but reduced motion: no pointer feed', async () => {
520+
stubMatchMedia(true);
521+
container = makeContainer();
522+
const graph = createGraph(container, bigSpec(4, { cursorRepulsion: true }));
523+
await Promise.resolve();
524+
const canvas = container.querySelector('.oc-graph-canvas')!;
525+
526+
nowValue = 100;
527+
moveMouse(canvas, 400, 300);
528+
nowValue = 200;
529+
moveMouse(canvas, 420, 320);
530+
531+
// Ambient pointer-driven motion is exactly what reduced-motion suppresses.
532+
expect(setPointerSpy).not.toHaveBeenCalled();
533+
graph.destroy();
534+
});
535+
536+
it('cursorRepulsion on but graph > 2000 nodes: no pointer feed', async () => {
537+
container = makeContainer();
538+
const graph = createGraph(container, bigSpec(2001, { cursorRepulsion: true }));
539+
await Promise.resolve();
540+
const canvas = container.querySelector('.oc-graph-canvas')!;
541+
542+
nowValue = 100;
543+
moveMouse(canvas, 400, 300);
544+
nowValue = 200;
545+
moveMouse(canvas, 420, 320);
546+
547+
expect(setPointerSpy).not.toHaveBeenCalled();
548+
graph.destroy();
549+
});
550+
551+
it('mouseleave deactivates the pointer feed when cursor repulsion is on', async () => {
552+
container = makeContainer();
553+
const graph = createGraph(container, bigSpec(4, { cursorRepulsion: true }));
554+
await Promise.resolve();
555+
const canvas = container.querySelector('.oc-graph-canvas')!;
556+
557+
nowValue = 100;
558+
moveMouse(canvas, 400, 300);
559+
canvas.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
560+
561+
const deactivate = setPointerSpy.mock.calls.filter((c) => c[2] === false);
562+
expect(deactivate.length).toBe(1);
563+
graph.destroy();
564+
});
565+
566+
/**
567+
* Drive one full node drag (down → move → up). Hit-testing is made
568+
* deterministic by forcing SpatialIndex.findNearest to return a stub node, so
569+
* the drag reliably commits regardless of happy-dom's zero-size canvas rect.
570+
*/
571+
function dragSomeNode(canvas: Element): void {
572+
const hit = vi
573+
.spyOn(SpatialIndex.prototype, 'findNearest')
574+
.mockReturnValue({ id: 'n0', x: 0, y: 0, index: 0, radius: 5 } as never);
575+
canvas.dispatchEvent(
576+
new MouseEvent('mousedown', { clientX: 400, clientY: 300, bubbles: true }),
577+
);
578+
// First move commits the drag (onNodeDragStart → pinNode).
579+
canvas.dispatchEvent(
580+
new MouseEvent('mousemove', { clientX: 410, clientY: 310, bubbles: true }),
581+
);
582+
canvas.dispatchEvent(new MouseEvent('mouseup', { clientX: 410, clientY: 310, bubbles: true }));
583+
hit.mockRestore();
584+
}
585+
586+
it('springyDrag on + small graph: drag pins with alphaTarget 0.3, releases with 0', async () => {
587+
container = makeContainer();
588+
const graph = createGraph(container, bigSpec(6, { springyDrag: true }));
589+
await Promise.resolve();
590+
const canvas = container.querySelector('.oc-graph-canvas')!;
591+
592+
dragSomeNode(canvas);
593+
594+
// Springy: pin carries alphaTarget 0.3, unpin carries 0.
595+
expect(pinSpy.mock.calls[0][3]).toBe(0.3);
596+
expect(unpinSpy.mock.calls[0][1]).toBe(0);
597+
graph.destroy();
598+
});
599+
600+
it('springyDrag off (default): drag pins with NO alphaTarget (legacy)', async () => {
601+
container = makeContainer();
602+
const graph = createGraph(container, bigSpec(6, undefined));
603+
await Promise.resolve();
604+
const canvas = container.querySelector('.oc-graph-canvas')!;
605+
606+
dragSomeNode(canvas);
607+
608+
// Legacy: the springy arg is absent (undefined) on both pin and unpin.
609+
expect(pinSpy.mock.calls[0][3]).toBeUndefined();
610+
expect(unpinSpy.mock.calls[0][1]).toBeUndefined();
611+
graph.destroy();
612+
});
613+
614+
it('springyDrag on but graph > 5000 nodes: drag stays legacy (no alphaTarget)', async () => {
615+
container = makeContainer();
616+
const graph = createGraph(container, bigSpec(5001, { springyDrag: true }));
617+
await Promise.resolve();
618+
const canvas = container.querySelector('.oc-graph-canvas')!;
619+
620+
dragSomeNode(canvas);
621+
622+
// Above the gate, springy is off → legacy pin/unpin (no alphaTarget field).
623+
expect(pinSpy.mock.calls[0][3]).toBeUndefined();
624+
expect(unpinSpy.mock.calls[0][1]).toBeUndefined();
625+
graph.destroy();
626+
});
627+
});

0 commit comments

Comments
 (0)