diff --git a/src/flowchart/flowchart-viewport.js b/src/flowchart/flowchart-viewport.js new file mode 100644 index 0000000..dc84f90 --- /dev/null +++ b/src/flowchart/flowchart-viewport.js @@ -0,0 +1,61 @@ +/** + * Pure viewport math for the flowchart canvas (v4.13.0). + * + * The SVG canvas keeps a fixed viewBox (1000×700). All content lives + * inside a single so we + * can zoom and pan without re-rendering. + * + * Pure module — no DOM, no globals — so the math is unit-testable. + * + * @module flowchart-viewport + */ + +const MIN_SCALE = 0.25; +const MAX_SCALE = 4; +const SCALE_STEP = 1.1; // multiplicative per Ctrl+wheel notch + +/** + * Zoom centred on a point in *screen* coordinates (the cursor position + * inside the SVG viewport). The point under the cursor stays fixed on + * screen as the scale changes. + */ +function zoomAt(view, screenX, screenY, factor) { + const newScale = clamp(view.scale * factor, MIN_SCALE, MAX_SCALE); + const actualFactor = newScale / view.scale; + // Derivation: world under cursor is ((sx-tx)/scale, ...). After zoom, + // we want the same world to render at the same screen position. + // Solving for tx' = sx - (sx - tx) * actualFactor. + return { + scale: newScale, + tx: screenX - (screenX - view.tx) * actualFactor, + ty: screenY - (screenY - view.ty) * actualFactor, + }; +} + +function panBy(view, dx, dy) { + return { scale: view.scale, tx: view.tx + dx, ty: view.ty + dy }; +} + +function reset() { + return { tx: 0, ty: 0, scale: 1 }; +} + +function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)); +} + +function wheelFactor(deltaY) { + // Standard "zoom in on scroll up" — positive deltaY zooms out. + return deltaY < 0 ? SCALE_STEP : 1 / SCALE_STEP; +} + +/** + * Snap a value to the nearest multiple of gridSize. + * Returns the value unchanged when gridSize is 0 (snap disabled). + */ +function snap(value, gridSize) { + if (!gridSize || gridSize <= 0) return value; + return Math.round(value / gridSize) * gridSize; +} + +module.exports = { zoomAt, panBy, reset, wheelFactor, snap, MIN_SCALE, MAX_SCALE }; diff --git a/tests/flowchart-viewport.test.js b/tests/flowchart-viewport.test.js new file mode 100644 index 0000000..59cfde9 --- /dev/null +++ b/tests/flowchart-viewport.test.js @@ -0,0 +1,107 @@ +/** + * @jest-environment node + * + * Viewport math — zoom/pan/snap pure functions. + */ + +const { + zoomAt, + panBy, + reset, + wheelFactor, + snap, + MIN_SCALE, + MAX_SCALE, +} = require('../src/flowchart/flowchart-viewport'); + +describe('reset', () => { + test('returns identity transform', () => { + expect(reset()).toEqual({ tx: 0, ty: 0, scale: 1 }); + }); +}); + +describe('zoomAt', () => { + test('zoom in by SCALE_STEP, keeping the world point under cursor fixed on screen', () => { + // World coords: transform="translate(tx,ty) scale(scale)" maps a world + // point (wx, wy) to screen (wx*scale + tx, wy*scale + ty). The + // world point under cursor is ((sx-tx)/scale, (sy-ty)/scale). After + // zoom that same world point should render at the same screen coords. + const v = { tx: 100, ty: 50, scale: 1 }; + // Cursor at screen (300, 200) → world point (200, 150). + const screenX = 300; + const screenY = 200; + const factor = wheelFactor(-100); // wheel up = zoom in + const v2 = zoomAt(v, screenX, screenY, factor); + expect(v2.scale).toBeCloseTo(factor, 5); + // The world point under the cursor before zoom: + const wx = (screenX - v.tx) / v.scale; + const wy = (screenY - v.ty) / v.scale; + // And after zoom, it must render at the same screen position. + const screenAfter = { x: wx * v2.scale + v2.tx, y: wy * v2.scale + v2.ty }; + expect(screenAfter.x).toBeCloseTo(screenX, 5); + expect(screenAfter.y).toBeCloseTo(screenY, 5); + }); + + test('clamps to MIN_SCALE', () => { + const v = { tx: 0, ty: 0, scale: MIN_SCALE }; + const v2 = zoomAt(v, 0, 0, 0.1); // try to zoom way out + expect(v2.scale).toBe(MIN_SCALE); + }); + + test('clamps to MAX_SCALE', () => { + const v = { tx: 0, ty: 0, scale: MAX_SCALE }; + const v2 = zoomAt(v, 0, 0, 100); // try to zoom way in + expect(v2.scale).toBe(MAX_SCALE); + }); +}); + +describe('panBy', () => { + test('shifts translation by dx/dy', () => { + const v = { tx: 10, ty: 20, scale: 1 }; + expect(panBy(v, 5, -3)).toEqual({ scale: 1, tx: 15, ty: 17 }); + }); + + test('does not change scale', () => { + const v = { tx: 0, ty: 0, scale: 2 }; + const v2 = panBy(v, 10, 10); + expect(v2.scale).toBe(2); + }); +}); + +describe('wheelFactor', () => { + test('wheel up (negative deltaY) zooms in', () => { + expect(wheelFactor(-100)).toBeGreaterThan(1); + }); + test('wheel down (positive deltaY) zooms out', () => { + expect(wheelFactor(100)).toBeLessThan(1); + }); + test('reciprocal relationship', () => { + expect(wheelFactor(-100) * wheelFactor(100)).toBeCloseTo(1, 5); + }); +}); + +describe('snap', () => { + test('snaps to nearest gridSize', () => { + expect(snap(103, 10)).toBe(100); + expect(snap(107, 10)).toBe(110); + expect(snap(105, 10)).toBe(110); // ties round up + }); + + test('returns value unchanged when gridSize is 0', () => { + expect(snap(42, 0)).toBe(42); + }); + + test('returns value unchanged when gridSize is negative', () => { + expect(snap(42, -5)).toBe(42); + }); + + test('works with negative values', () => { + expect(snap(-103, 10)).toBe(-100); + }); + + test('works with custom grid sizes', () => { + expect(snap(23, 20)).toBe(20); + expect(snap(27, 20)).toBe(20); + expect(snap(31, 20)).toBe(40); + }); +});