Add a panZoomGestures action: one-finger/left-button drag pans, two-finger pinch and Ctrl+wheel zoom around the centroid, plain wheel pans. Pan is edge-clamped via clampPan (no infinite scroll), a real drag suppresses the trailing node click, and inertia decays after release unless prefers-reduced- motion. Canvas container switches from native scroll to overflow-hidden. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
clampZoom,
|
|
parsePanZoomParams,
|
|
serializePanZoomParams,
|
|
screenDeltaToSvg,
|
|
zoomAtPoint,
|
|
recentreOn,
|
|
clampPan,
|
|
DEFAULT_VIEW,
|
|
DEFAULT_ZOOM,
|
|
LEGIBLE_ZOOM,
|
|
MIN_ZOOM,
|
|
MAX_ZOOM
|
|
} from './panZoom';
|
|
|
|
describe('clampZoom', () => {
|
|
it('returns the value unchanged when within range', () => {
|
|
expect(clampZoom(1)).toBe(1);
|
|
expect(clampZoom(0.5)).toBe(0.5);
|
|
expect(clampZoom(2.75)).toBe(2.75);
|
|
});
|
|
|
|
it('clamps below MIN_ZOOM up to MIN_ZOOM', () => {
|
|
expect(clampZoom(0.1)).toBe(MIN_ZOOM);
|
|
expect(clampZoom(0)).toBe(MIN_ZOOM);
|
|
expect(clampZoom(-5)).toBe(MIN_ZOOM);
|
|
});
|
|
|
|
it('clamps above MAX_ZOOM down to MAX_ZOOM', () => {
|
|
expect(clampZoom(5)).toBe(MAX_ZOOM);
|
|
expect(clampZoom(3.0001)).toBe(MAX_ZOOM);
|
|
});
|
|
|
|
it('exposes the resolved zoom bounds', () => {
|
|
expect(MIN_ZOOM).toBe(0.25);
|
|
expect(MAX_ZOOM).toBe(3.0);
|
|
});
|
|
});
|
|
|
|
describe('parsePanZoomParams', () => {
|
|
it('parses well-formed cx/cy/z params', () => {
|
|
expect(parsePanZoomParams({ cx: '120', cy: '-40', z: '1.5' })).toEqual({
|
|
x: 120,
|
|
y: -40,
|
|
z: 1.5
|
|
});
|
|
});
|
|
|
|
it('falls back to DEFAULT_VIEW when params are absent', () => {
|
|
expect(parsePanZoomParams({})).toEqual(DEFAULT_VIEW);
|
|
expect(DEFAULT_VIEW).toEqual({ x: 0, y: 0, z: DEFAULT_ZOOM });
|
|
});
|
|
|
|
it('rejects Infinity and NaN, degrading each axis to its default (Nora #692)', () => {
|
|
expect(parsePanZoomParams({ z: 'Infinity' }).z).toBe(DEFAULT_ZOOM);
|
|
expect(parsePanZoomParams({ z: 'NaN' }).z).toBe(DEFAULT_ZOOM);
|
|
expect(parsePanZoomParams({ cx: 'NaN', cy: 'Infinity' })).toEqual(DEFAULT_VIEW);
|
|
expect(parsePanZoomParams({ cx: '1e500' }).x).toBe(0);
|
|
});
|
|
|
|
it('clamps an out-of-range zoom into the supported bounds', () => {
|
|
expect(parsePanZoomParams({ z: '99' }).z).toBe(MAX_ZOOM);
|
|
expect(parsePanZoomParams({ z: '0.01' }).z).toBe(MIN_ZOOM);
|
|
expect(parsePanZoomParams({ z: '-3' }).z).toBe(MIN_ZOOM);
|
|
});
|
|
});
|
|
|
|
describe('serializePanZoomParams', () => {
|
|
it('produces string cx/cy/z keys', () => {
|
|
expect(serializePanZoomParams({ x: 120, y: -40, z: 1.5 })).toEqual({
|
|
cx: '120',
|
|
cy: '-40',
|
|
z: '1.5'
|
|
});
|
|
});
|
|
|
|
it('round-trips through parsePanZoomParams', () => {
|
|
const state = { x: 87.5, y: -12.25, z: 2.4 };
|
|
expect(parsePanZoomParams(serializePanZoomParams(state))).toEqual(state);
|
|
});
|
|
});
|
|
|
|
describe('screenDeltaToSvg', () => {
|
|
it('scales a pixel delta by the viewBox-to-element ratio per axis', () => {
|
|
// viewBox is 2x the element in width, 2x in height → 1px == 2 SVG units.
|
|
expect(screenDeltaToSvg(10, 5, 1000, 800, 500, 400)).toEqual({ dx: 20, dy: 10 });
|
|
});
|
|
|
|
it('is identity when the viewBox matches the element pixel size', () => {
|
|
expect(screenDeltaToSvg(7, -3, 600, 600, 600, 600)).toEqual({ dx: 7, dy: -3 });
|
|
});
|
|
|
|
it('returns zero when the element has no measured size', () => {
|
|
expect(screenDeltaToSvg(10, 10, 1000, 800, 0, 0)).toEqual({ dx: 0, dy: 0 });
|
|
});
|
|
});
|
|
|
|
describe('zoomAtPoint', () => {
|
|
// The anchor is expressed as an offset (in SVG units) from the base viewBox
|
|
// centre. The fraction of the anchor across the viewBox must not change.
|
|
const anchorScreenFraction = (state: { x: number; z: number }, anchorOffsetX: number) => {
|
|
const baseW = 1000;
|
|
const w = baseW / state.z;
|
|
const centreOffset = anchorOffsetX - state.x; // anchor relative to viewBox centre
|
|
return centreOffset / w + 0.5;
|
|
};
|
|
|
|
it('keeps the canvas centre fixed when the anchor is the centre', () => {
|
|
const next = zoomAtPoint({ x: 0, y: 0, z: 1 }, 2, 0, 0);
|
|
expect(next).toEqual({ x: 0, y: 0, z: 2 });
|
|
});
|
|
|
|
it('keeps an off-centre anchor at the same screen position across a zoom-in', () => {
|
|
const before = { x: 0, y: 0, z: 1 };
|
|
const after = zoomAtPoint(before, 2, 100, 50);
|
|
expect(after.z).toBe(2);
|
|
expect(anchorScreenFraction(after, 100)).toBeCloseTo(anchorScreenFraction(before, 100), 10);
|
|
});
|
|
|
|
it('clamps the target zoom and makes no move when already at the bound', () => {
|
|
const next = zoomAtPoint({ x: 30, y: 10, z: MAX_ZOOM }, 99, 200, 200);
|
|
expect(next).toEqual({ x: 30, y: 10, z: MAX_ZOOM });
|
|
});
|
|
});
|
|
|
|
describe('recentreOn', () => {
|
|
const node = { x: 300, y: 200 };
|
|
const base = { x: 100, y: 100 };
|
|
|
|
it('pans so the node sits at the viewBox centre, keeping the current zoom', () => {
|
|
expect(recentreOn(node, base, { x: 0, y: 0, z: 1 }, false)).toEqual({ x: 200, y: 100, z: 1 });
|
|
});
|
|
|
|
it('auto-zooms up to LEGIBLE_ZOOM when zoomed out (OQ-005)', () => {
|
|
const next = recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, true);
|
|
expect(next.z).toBe(LEGIBLE_ZOOM);
|
|
expect({ x: next.x, y: next.y }).toEqual({ x: 200, y: 100 });
|
|
});
|
|
|
|
it('does not reduce an already-legible zoom when auto-zooming', () => {
|
|
expect(recentreOn(node, base, { x: 0, y: 0, z: 2 }, true).z).toBe(2);
|
|
});
|
|
|
|
it('leaves zoom untouched when auto-zoom is off', () => {
|
|
expect(recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, false).z).toBe(0.4);
|
|
});
|
|
});
|
|
|
|
describe('clampPan', () => {
|
|
// Base frame is 1000 x 800.
|
|
it('forbids panning when the whole tree fits (z <= 1)', () => {
|
|
expect(clampPan({ x: 200, y: -100, z: 1 }, 1000, 800)).toEqual({ x: 0, y: 0, z: 1 });
|
|
expect(clampPan({ x: 50, y: 50, z: 0.5 }, 1000, 800)).toEqual({ x: 0, y: 0, z: 0.5 });
|
|
});
|
|
|
|
it('allows panning up to the edge when zoomed in (no infinite scroll)', () => {
|
|
// At z=2 the viewBox is 500 wide → limit = (1000 - 500) / 2 = 250.
|
|
expect(clampPan({ x: 1000, y: 0, z: 2 }, 1000, 800).x).toBe(250);
|
|
expect(clampPan({ x: -1000, y: 0, z: 2 }, 1000, 800).x).toBe(-250);
|
|
// Vertical limit at z=2: (800 - 400) / 2 = 200.
|
|
expect(clampPan({ x: 0, y: 999, z: 2 }, 1000, 800).y).toBe(200);
|
|
});
|
|
|
|
it('leaves an in-range pan untouched', () => {
|
|
expect(clampPan({ x: 100, y: -50, z: 2 }, 1000, 800)).toEqual({ x: 100, y: -50, z: 2 });
|
|
});
|
|
});
|