// view.js — pan and zoom. // // The whole scene lives in one transformed
, so panning and zooming cost a // single CSS transform: no relayout, no re-render of node content, nothing that // touches the DOM per node. That is what keeps a few hundred nodes usable on // the mobile/VPN path. // // The dot grid is painted on the (untransformed) surface and its size/offset // follow the transform, which keeps it crisp at every zoom level instead of // scaling a bitmap. window.Canvas = window.Canvas || {}; Canvas.View = (function () { var MIN_SCALE = 0.05; var MAX_SCALE = 4; var GRID = 20; var surface = null; var world = null; var tx = 0, ty = 0, scale = 1; var listeners = []; function apply() { world.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')'; var step = GRID * scale; surface.style.backgroundSize = step + 'px ' + step + 'px'; surface.style.backgroundPosition = tx + 'px ' + ty + 'px'; // Below a few pixels apart the dots stop reading as a grid and start // reading as noise, so zooming far out drops them entirely. surface.classList.toggle('is-grid-hidden', step < 6); for (var i = 0; i < listeners.length; i++) listeners[i](); } function clampScale(s) { return Math.max(MIN_SCALE, Math.min(MAX_SCALE, s)); } // zoomAt scales about a screen point, so the world coordinate under the // cursor stays put — the behaviour that makes wheel-zoom feel anchored // rather than lurching toward the origin. function zoomAt(clientX, clientY, factor) { var next = clampScale(scale * factor); if (next === scale) return; var r = surface.getBoundingClientRect(); var px = clientX - r.left; var py = clientY - r.top; var ratio = next / scale; tx = px - (px - tx) * ratio; ty = py - (py - ty) * ratio; scale = next; apply(); } function bounds(nodes) { if (!nodes.length) return null; var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; nodes.forEach(function (n) { minX = Math.min(minX, n.x); minY = Math.min(minY, n.y); maxX = Math.max(maxX, n.x + n.width); maxY = Math.max(maxY, n.y + n.height); }); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } return { GRID: GRID, init: function (surfaceEl, worldEl) { surface = surfaceEl; world = worldEl; apply(); }, get: function () { return { x: tx, y: ty, scale: scale }; }, scale: function () { return scale; }, toWorld: function (clientX, clientY) { var r = surface.getBoundingClientRect(); return { x: (clientX - r.left - tx) / scale, y: (clientY - r.top - ty) / scale }; }, toScreen: function (x, y) { var r = surface.getBoundingClientRect(); return { x: x * scale + tx + r.left, y: y * scale + ty + r.top }; }, pan: function (dx, dy) { tx += dx; ty += dy; apply(); }, zoomAt: zoomAt, // zoomBy scales about the viewport centre, for the toolbar buttons and // the +/- shortcuts where there is no cursor to anchor to. zoomBy: function (factor) { var r = surface.getBoundingClientRect(); zoomAt(r.left + r.width / 2, r.top + r.height / 2, factor); }, resetZoom: function () { var r = surface.getBoundingClientRect(); zoomAt(r.left + r.width / 2, r.top + r.height / 2, 1 / scale); }, // fit frames the whole document with a margin, never zooming past 1:1 — // a canvas with three small nodes should not open comically magnified. fit: function (nodes) { var b = bounds(nodes); var r = surface.getBoundingClientRect(); if (!b || !r.width || !r.height) { tx = 0; ty = 0; scale = 1; apply(); return; } var pad = 60; var s = clampScale(Math.min( (r.width - pad * 2) / Math.max(b.width, 1), (r.height - pad * 2) / Math.max(b.height, 1), 1 )); scale = s; tx = r.width / 2 - (b.x + b.width / 2) * s; ty = r.height / 2 - (b.y + b.height / 2) * s; apply(); }, // centreOfViewport is where a newly added node goes: wherever the user // is currently looking. centreOfViewport: function () { var r = surface.getBoundingClientRect(); return { x: (r.width / 2 - tx) / scale, y: (r.height / 2 - ty) / scale }; }, bounds: bounds, onChange: function (fn) { listeners.push(fn); }, refresh: apply }; })();