feat(flowchart): pure viewport math for zoom/pan/snap (C4a)

New pure functions for the canvas viewport (v4.13.0):
  - zoomAt(view, screenX, screenY, factor) — zoom centred on a screen
    point, keeping the world point under cursor fixed. Clamped to
    [0.25, 4] scale range.
  - panBy(view, dx, dy) — additive pan
  - reset() — identity transform
  - wheelFactor(deltaY) — multiplicative factor per Ctrl+wheel notch
  - snap(value, gridSize) — round to nearest grid (0 disables)

The actual SVG transform wrapper + Ctrl+wheel handler + drag-to-pan
+ snap-on-move is the next canvas-layer commit; this commit lands
the testable math.

14 new tests cover: identity reset, cursor-stable zoom, MIN/MAX
clamping, additive pan, wheel factor reciprocity, snap-to-grid
(positive/negative/zero grid sizes).

Amit Haridas
This commit is contained in:
Amit Haridas
2026-09-30 21:28:36 +05:30
parent 4228ab78d8
commit 98979a2cd2
2 changed files with 168 additions and 0 deletions
+61
View File
@@ -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 <g transform="translate(tx,ty) scale(scale)"> 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 };