dom-2d-camera
Version:
A wrapper for attaching a 2D camera to a DOM element
582 lines (487 loc) • 16 kB
JavaScript
import { mat4, vec4, vec2 } from 'gl-matrix';
const createCamera = (
initTarget = [0, 0],
initDistance = 1,
initRotation = 0,
initViewCenter = [0, 0],
initScaleBounds = [
[0, Infinity],
[0, Infinity],
],
initTranslationBounds = [
[-Infinity, Infinity],
[-Infinity, Infinity],
]
) => {
// Scratch variables
const scratch0 = new Float32Array(16);
const scratch1 = new Float32Array(16);
const scratch2 = new Float32Array(16);
let view = mat4.create();
let viewCenter = [...initViewCenter.slice(0, 2), 0, 1];
const scaleXBounds = Array.isArray(initScaleBounds[0])
? [...initScaleBounds[0]]
: [...initScaleBounds];
const scaleYBounds = Array.isArray(initScaleBounds[0])
? [...initScaleBounds[1]]
: [...initScaleBounds];
const translationXBounds = Array.isArray(initTranslationBounds[0])
? [...initTranslationBounds[0]]
: [...initTranslationBounds];
const translationYBounds = Array.isArray(initTranslationBounds[0])
? [...initTranslationBounds[1]]
: [...initTranslationBounds];
const getScaling = () => mat4.getScaling(scratch0, view).slice(0, 2);
const getMinScaling = () => {
const scaling = getScaling();
return Math.min(scaling[0], scaling[1]);
};
const getMaxScaling = () => {
const scaling = getScaling();
return Math.max(scaling[0], scaling[1]);
};
const getRotation = () => Math.acos(view[0] / getMaxScaling());
const getScaleBounds = () => [[...scaleXBounds], [...scaleYBounds]];
const getTranslationBounds = () => [
[...translationXBounds],
[...translationYBounds],
];
const getDistance = () => {
const scaling = getScaling();
return [1 / scaling[0], 1 / scaling[1]];
};
const getMinDistance = () => 1 / getMinScaling();
const getMaxDistance = () => 1 / getMaxScaling();
const getTranslation = () => mat4.getTranslation(scratch0, view).slice(0, 2);
const getTarget = () =>
vec4
.transformMat4(scratch0, viewCenter, mat4.invert(scratch2, view))
.slice(0, 2);
const getView = () => view;
const getViewCenter = () => viewCenter.slice(0, 2);
const lookAt = ([x = 0, y = 0] = [], newDistance = 1, newRotation = 0) => {
// Reset the view
view = mat4.create();
translate([-x, -y]);
rotate(newRotation);
scale(1 / newDistance);
};
const translate = ([x = 0, y = 0] = []) => {
scratch0[0] = x;
scratch0[1] = y;
scratch0[2] = 0;
const t = mat4.fromTranslation(scratch1, scratch0);
// Translate about the viewport center
// This is identical to `i * t * i * view` where `i` is the identity matrix
mat4.multiply(view, t, view);
};
const scale = (d, mousePos) => {
const isArray = Array.isArray(d);
let dx = isArray ? d[0] : d;
let dy = isArray ? d[1] : d;
if (dx <= 0 || dy <= 0 || (dx === 1 && dy === 1)) return;
const scaling = getScaling();
const newXScale = scaling[0] * dx;
const newYScale = scaling[1] * dy;
dx =
Math.max(scaleXBounds[0], Math.min(newXScale, scaleXBounds[1])) /
scaling[0];
dy =
Math.max(scaleYBounds[0], Math.min(newYScale, scaleYBounds[1])) /
scaling[1];
if (dx === 1 && dy === 1) return; // There is nothing to do
scratch0[0] = dx;
scratch0[1] = dy;
scratch0[2] = 1;
const s = mat4.fromScaling(scratch1, scratch0);
const scaleCenter = mousePos ? [...mousePos, 0] : viewCenter;
const a = mat4.fromTranslation(scratch0, scaleCenter);
// Translate about the scale center
// I.e., the mouse position or the view center
mat4.multiply(
view,
a,
mat4.multiply(
view,
s,
mat4.multiply(view, mat4.invert(scratch2, a), view)
)
);
};
const rotate = (rad) => {
const r = mat4.create();
mat4.fromRotation(r, rad, [0, 0, 1]);
// Rotate about the viewport center
// This is identical to `i * r * i * view` where `i` is the identity matrix
mat4.multiply(view, r, view);
};
const setScaleBounds = (newBounds) => {
const isArray = Array.isArray(newBounds[0]);
scaleXBounds[0] = isArray ? newBounds[0][0] : newBounds[0];
scaleXBounds[1] = isArray ? newBounds[0][1] : newBounds[1];
scaleYBounds[0] = isArray ? newBounds[1][0] : newBounds[0];
scaleYBounds[1] = isArray ? newBounds[1][1] : newBounds[1];
};
const setTranslationBounds = (newBounds) => {
const isArray = Array.isArray(newBounds[0]);
translationXBounds[0] = isArray ? newBounds[0][0] : newBounds[0];
translationXBounds[1] = isArray ? newBounds[0][1] : newBounds[1];
translationYBounds[0] = isArray ? newBounds[1][0] : newBounds[0];
translationYBounds[1] = isArray ? newBounds[1][1] : newBounds[1];
};
const setView = (newView) => {
if (!newView || newView.length < 16) return;
view = newView;
};
const setViewCenter = (newViewCenter) => {
viewCenter = [...newViewCenter.slice(0, 2), 0, 1];
};
const reset = () => {
lookAt(initTarget, initDistance, initRotation);
};
// Init
lookAt(initTarget, initDistance, initRotation);
return {
get translation() {
return getTranslation();
},
get target() {
return getTarget();
},
get scaling() {
return getScaling();
},
get minScaling() {
return getMinScaling();
},
get maxScaling() {
return getMaxScaling();
},
get scaleBounds() {
return getScaleBounds();
},
get translationBounds() {
return getTranslationBounds();
},
get distance() {
return getDistance();
},
get minDistance() {
return getMinDistance();
},
get maxDistance() {
return getMaxDistance();
},
get rotation() {
return getRotation();
},
get view() {
return getView();
},
get viewCenter() {
return getViewCenter();
},
lookAt,
translate,
pan: translate,
rotate,
scale,
zoom: scale,
reset,
set: (...args) => {
console.warn('`set()` is deprecated. Please use `setView()` instead.');
return setView(...args);
},
setScaleBounds,
setTranslationBounds,
setView,
setViewCenter,
};
};
const MOUSE_DOWN_MOVE_ACTIONS = ["pan", "rotate"];
const KEY_MAP = {
alt: "altKey",
cmd: "metaKey",
ctrl: "ctrlKey",
meta: "metaKey",
shift: "shiftKey"
};
const dom2dCamera = (
element,
{
distance = 1.0,
target = [0, 0],
rotation = 0,
isNdc = true,
isFixed = false,
isPan = true,
isPanInverted = [false, true],
panSpeed = 1,
isRotate = true,
rotateSpeed = 1,
defaultMouseDownMoveAction = "pan",
mouseDownMoveModKey = "alt",
isZoom = true,
zoomSpeed = 1,
viewCenter,
scaleBounds,
translationBounds,
onKeyDown = () => {},
onKeyUp = () => {},
onMouseDown = () => {},
onMouseUp = () => {},
onMouseMove = () => {},
onWheel = () => {}
} = {}
) => {
let camera = createCamera(
target,
distance,
rotation,
viewCenter,
scaleBounds,
translationBounds
);
let mouseX = 0;
let mouseY = 0;
let mouseRelX = 0;
let mouseRelY = 0;
let prevMouseX = 0;
let prevMouseY = 0;
let isLeftMousePressed = false;
let scrollDist = 0;
let width = 1;
let height = 1;
let aspectRatio = 1;
let isInteractivelyChanged = false;
let isProgrammaticallyChanged = false;
let isMouseDownMoveModActive = false;
let panOnMouseDownMove = defaultMouseDownMoveAction === "pan";
let isPanX = isPan;
let isPanY = isPan;
let isPanXInverted = isPanInverted;
let isPanYInverted = isPanInverted;
let isZoomX = isZoom;
let isZoomY = isZoom;
const spreadXYSettings = () => {
isPanX = Array.isArray(isPan) ? Boolean(isPan[0]) : isPan;
isPanY = Array.isArray(isPan) ? Boolean(isPan[1]) : isPan;
isPanXInverted = Array.isArray(isPanInverted)
? Boolean(isPanInverted[0])
: isPanInverted;
isPanYInverted = Array.isArray(isPanInverted)
? Boolean(isPanInverted[1])
: isPanInverted;
isZoomX = Array.isArray(isZoom) ? Boolean(isZoom[0]) : isZoom;
isZoomY = Array.isArray(isZoom) ? Boolean(isZoom[1]) : isZoom;
};
spreadXYSettings();
const transformPanX = isNdc
? dX => (dX / width) * 2 * aspectRatio // to normalized device coords
: dX => dX;
const transformPanY = isNdc
? dY => (dY / height) * 2 // to normalized device coords
: dY => -dY;
const transformScaleX = isNdc
? x => (-1 + (x / width) * 2) * aspectRatio // to normalized device coords
: x => x;
const transformScaleY = isNdc
? y => 1 - (y / height) * 2 // to normalized device coords
: y => y;
const tick = () => {
if (isFixed) {
const isChanged = isProgrammaticallyChanged;
isProgrammaticallyChanged = false;
return isChanged;
}
isInteractivelyChanged = false;
const currentMouseX = mouseX;
const currentMouseY = mouseY;
if (
(isPanX || isPanY) &&
isLeftMousePressed &&
((panOnMouseDownMove && !isMouseDownMoveModActive) ||
(!panOnMouseDownMove && isMouseDownMoveModActive))
) {
const dX = isPanXInverted
? prevMouseX - currentMouseX
: currentMouseX - prevMouseX;
const transformedPanX = isPanX ? transformPanX(panSpeed * dX) : 0;
const dY = isPanYInverted
? prevMouseY - currentMouseY
: currentMouseY - prevMouseY;
const transformedPanY = isPanY ? transformPanY(panSpeed * dY) : 0;
if (transformedPanX !== 0 || transformedPanY !== 0) {
camera.pan([transformedPanX, transformedPanY]);
isInteractivelyChanged = true;
}
}
if ((isZoomX || isZoomY) && scrollDist) {
const dZ = zoomSpeed * Math.exp(scrollDist / height);
const transformedX = transformScaleX(mouseRelX);
const transformedY = transformScaleY(mouseRelY);
camera.scale(
[isZoomX ? 1 / dZ : 1, isZoomY ? 1 / dZ : 1],
[transformedX, transformedY]
);
isInteractivelyChanged = true;
}
if (
isRotate &&
isLeftMousePressed &&
((panOnMouseDownMove && isMouseDownMoveModActive) ||
(!panOnMouseDownMove && !isMouseDownMoveModActive)) &&
Math.abs(prevMouseX - currentMouseX) +
Math.abs(prevMouseY - currentMouseY) >
0
) {
const wh = width / 2;
const hh = height / 2;
const x1 = prevMouseX - wh;
const y1 = hh - prevMouseY;
const x2 = currentMouseX - wh;
const y2 = hh - currentMouseY;
// Angle between the start and end mouse position with respect to the
// viewport center
const radians = vec2.angle([x1, y1], [x2, y2]);
// Determine the orientation
const cross = x1 * y2 - x2 * y1;
camera.rotate(rotateSpeed * radians * Math.sign(cross));
isInteractivelyChanged = true;
}
// Reset scroll delta and mouse position
scrollDist = 0;
prevMouseX = currentMouseX;
prevMouseY = currentMouseY;
const isChanged = isInteractivelyChanged || isProgrammaticallyChanged;
isProgrammaticallyChanged = false;
return isChanged;
};
const config = ({
defaultMouseDownMoveAction: newDefaultMouseDownMoveAction = null,
isFixed: newIsFixed = null,
isPan: newIsPan = null,
isPanInverted: newIsPanInverted = null,
isRotate: newIsRotate = null,
isZoom: newIsZoom = null,
panSpeed: newPanSpeed = null,
rotateSpeed: newRotateSpeed = null,
zoomSpeed: newZoomSpeed = null,
mouseDownMoveModKey: newMouseDownMoveModKey = null
} = {}) => {
defaultMouseDownMoveAction =
newDefaultMouseDownMoveAction !== null &&
MOUSE_DOWN_MOVE_ACTIONS.includes(newDefaultMouseDownMoveAction)
? newDefaultMouseDownMoveAction
: defaultMouseDownMoveAction;
panOnMouseDownMove = defaultMouseDownMoveAction === "pan";
isFixed = newIsFixed !== null ? newIsFixed : isFixed;
isPan = newIsPan !== null ? newIsPan : isPan;
isPanInverted =
newIsPanInverted !== null ? newIsPanInverted : isPanInverted;
isRotate = newIsRotate !== null ? newIsRotate : isRotate;
isZoom = newIsZoom !== null ? newIsZoom : isZoom;
panSpeed = +newPanSpeed > 0 ? newPanSpeed : panSpeed;
rotateSpeed = +newRotateSpeed > 0 ? newRotateSpeed : rotateSpeed;
zoomSpeed = +newZoomSpeed > 0 ? newZoomSpeed : zoomSpeed;
spreadXYSettings();
mouseDownMoveModKey =
newMouseDownMoveModKey !== null &&
Object.keys(KEY_MAP).includes(newMouseDownMoveModKey)
? newMouseDownMoveModKey
: mouseDownMoveModKey;
};
const refresh = () => {
const bBox = element.getBoundingClientRect();
width = bBox.width;
height = bBox.height;
aspectRatio = width / height;
};
const keyUpHandler = event => {
isMouseDownMoveModActive = false;
onKeyUp(event);
};
const keyDownHandler = event => {
isMouseDownMoveModActive = event[KEY_MAP[mouseDownMoveModKey]];
onKeyDown(event);
};
const mouseUpHandler = event => {
isLeftMousePressed = false;
onMouseUp(event);
};
const mouseDownHandler = event => {
isLeftMousePressed = event.buttons === 1;
onMouseDown(event);
};
const offsetXSupport =
document.createEvent("MouseEvent").offsetX !== undefined;
const updateMouseRelXY = offsetXSupport
? event => {
mouseRelX = event.offsetX;
mouseRelY = event.offsetY;
}
: event => {
const bBox = element.getBoundingClientRect();
mouseRelX = event.clientX - bBox.left;
mouseRelY = event.clientY - bBox.top;
};
const updateMouseXY = event => {
mouseX = event.clientX;
mouseY = event.clientY;
};
const mouseMoveHandler = event => {
updateMouseXY(event);
onMouseMove(event);
};
const wheelHandler = event => {
if ((isZoomX || isZoomY) && !isFixed) {
event.preventDefault();
updateMouseXY(event);
updateMouseRelXY(event);
const scale = event.deltaMode === 1 ? 12 : 1;
scrollDist += scale * (event.deltaY || event.deltaX || 0);
}
onWheel(event);
};
const dispose = () => {
camera = undefined;
window.removeEventListener("keydown", keyDownHandler);
window.removeEventListener("keyup", keyUpHandler);
element.removeEventListener("mousedown", mouseDownHandler);
window.removeEventListener("mouseup", mouseUpHandler);
window.removeEventListener("mousemove", mouseMoveHandler);
element.removeEventListener("wheel", wheelHandler);
};
window.addEventListener("keydown", keyDownHandler, { passive: true });
window.addEventListener("keyup", keyUpHandler, { passive: true });
element.addEventListener("mousedown", mouseDownHandler, { passive: true });
window.addEventListener("mouseup", mouseUpHandler, { passive: true });
window.addEventListener("mousemove", mouseMoveHandler, { passive: true });
element.addEventListener("wheel", wheelHandler, { passive: false });
camera.config = config;
camera.dispose = dispose;
camera.refresh = refresh;
camera.tick = tick;
const withProgrammaticChange = fn =>
function() {
fn.apply(null, arguments);
isProgrammaticallyChanged = true;
};
camera.lookAt = withProgrammaticChange(camera.lookAt);
camera.translate = withProgrammaticChange(camera.translate);
camera.pan = withProgrammaticChange(camera.pan);
camera.rotate = withProgrammaticChange(camera.rotate);
camera.scale = withProgrammaticChange(camera.scale);
camera.zoom = withProgrammaticChange(camera.zoom);
camera.reset = withProgrammaticChange(camera.reset);
camera.set = withProgrammaticChange(camera.set);
camera.setScaleBounds = withProgrammaticChange(camera.setScaleBounds);
camera.setTranslationBounds = withProgrammaticChange(
camera.setTranslationBounds
);
camera.setView = withProgrammaticChange(camera.setView);
camera.setViewCenter = withProgrammaticChange(camera.setViewCenter);
refresh();
return camera;
};
export default dom2dCamera;