148 lines
5.0 KiB
JavaScript
148 lines
5.0 KiB
JavaScript
// view.js — pan and zoom.
|
|
//
|
|
// The whole scene lives in one transformed <div>, 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
|
|
};
|
|
})();
|