Skip to content

Instantly share code, notes, and snippets.

@pfgithub
Created August 16, 2023 00:28
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pfgithub/afee093bb8a0ba2476fc29ac5657af78 to your computer and use it in GitHub Desktop.
Save pfgithub/afee093bb8a0ba2476fc29ac5657af78 to your computer and use it in GitHub Desktop.
canvas pan & zoom
import { Accessor, createEffect, createRoot, JSX, onCleanup, untrack } from "solid-js";
import { vec2, Vec2 } from "../util/vec2";
// polyfill roundRect, does not support radius arrays
CanvasRenderingContext2D.prototype.roundRect ??= function (this: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
if (width < 2 * radius) radius = width / 2;
if (height < 2 * radius) radius = height / 2;
this.beginPath();
this.moveTo(x + radius, y);
this.arcTo(x + width, y, x + width, y + height, radius);
this.arcTo(x + width, y + height, x, y + height, radius);
this.arcTo(x, y + height, x, y, radius);
this.arcTo(x, y, x + width, y, radius);
this.closePath();
return this;
};
export type World = {
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
center_transform: DOMMatrixReadOnly,
setstate: () => void,
el: HTMLElement,
canvas_w: number,
canvas_h: number,
};
function setupCanvas<T>(mount: HTMLElement, state: Accessor<T>, setState: () => void, renderChild: RenderChild<T>) {
const canvas = document.createElement("canvas");
canvas.style.width = "100%";
canvas.style.height = "100%";
canvas.style.display = "block";
// canvas.style.objectFit = "none"; // breaks when changing pixel ratio because it needs to stretch
canvas.style.objectPosition = "top left";
canvas.classList.toggle("maincanvas");
const subdiv = document.createElement("div");
subdiv.tabIndex = 0;
canvas.appendChild(subdiv);
mount.appendChild(canvas);
onCleanup(() => {canvas.remove()});
const err_el = document.createElement("div");
err_el.style.display = "none";
err_el.style.backgroundColor = "rgba(0,0,0,0.3)";
err_el.style.color = "#ffd3e0";
err_el.style.height = "100%";
err_el.style.pointerEvents = "none";
err_el.style.position = "absolute";
err_el.style.inset = "0";
err_el.style.zIndex = "100000";
const eech = document.createElement("div");
eech.style.padding = "1rem";
eech.style.position = "absolute";
eech.style.inset = "0";
eech.style.transformOrigin = "top left";
eech.style.willChange = "transform";
eech.style.display = "flex";
eech.style.alignItems = "center";
eech.style.justifyContent = "center";
eech.style.fontSize = "3vw";
eech.innerHTML = "<div class='shadow' style='background-color: rgb(41,85,244); padding: 3vw 2vw; border-radius: 1vw'>The page is zoomed. Zoom out to continue.</div>";
err_el.appendChild(eech);
mount.appendChild(err_el);
onCleanup(() => err_el.remove());
const stylel = document.createElement("style");
stylel.textContent = `
html:not(.e-zoomed), html:not(.e-zoomed) > .maincanvas {
touch-action: none;
}
html, body {
overflow: hidden;
}
`;
document.head.appendChild(stylel);
onCleanup(() => stylel.remove());
let disable_ev_lsn = false;
function onVisualViewportChange() {
if(visualViewport == null) return;
const value = visualViewport.scale < 0.999 || visualViewport.scale > 1.001;
document.documentElement.classList.toggle("e-zoomed", value);
// canvas.style.display = value ? "none" : "";
err_el.style.display = value ? "" : "none";
disable_ev_lsn = value;
const offset_left = visualViewport.offsetLeft;
const offset_top = visualViewport.offsetTop;
eech.style.transform = "translate(" + offset_left + "px," + offset_top + "px) " + "scale(" + 1/visualViewport.scale + ")";
}
if(visualViewport != null) {
visualViewport.addEventListener("resize", onVisualViewportChange);
visualViewport.addEventListener("scroll", onVisualViewportChange);
onCleanup(() => {
if(visualViewport == null) return;
visualViewport.removeEventListener("resize", onVisualViewportChange);
visualViewport.removeEventListener("scroll", onVisualViewportChange);
document.documentElement.classList.remove("e-zoomed");
// cleanupGesRec();
});
}
onVisualViewportChange();
onCleanup(() => {
disable_ev_lsn = true;
})
// window.udm = (cb) => {transform = cb(transform)};
const onwheel = (e: WheelEvent) => {
if(disable_ev_lsn) return;
e.preventDefault();
recvScrollEvent(world, e);
};
const onpointerdown = (e: PointerEvent) => {
if(disable_ev_lsn) return;
};
let end_capture: null | ((e: MouseEvent) => void) = null;
let mouse_pos: null | Vec2 = null;
let clickaction_override: null | ClickCallback = null;
const onmousemove = (e: MouseEvent) => {
if(disable_ev_lsn) return;
if(end_capture != null) {
mouse_pos = null;
return;
}
const target_pos = screenToWorldPos(world, ...offsets(world, e));
mouse_pos = [target_pos.x, target_pos.y];
rench.onmove(target_pos);
};
const dragview: ClickCallback = (pos, ev): ClickCallbackRet => {
if(ev == null) throw new Error("bad");
const start_pos = offsets(world, ev);
const start_matrix = world.center_transform;
const transform = (pos: Vec2): Vec2 => {
const res = start_matrix.inverse().transformPoint({x: pos[0], y: pos[1]});
return [res.x, res.y];
};
const update = (end_pos: Vec2) => {
const start_transform = transform(start_pos);
const end_transform = transform(end_pos);
const offset = vec2.sub(end_transform, start_transform);
world.center_transform = start_matrix.translate(...offset);
};
return {
onmove(pos, ev) {
if(ev == null) throw new Error("bad");
const end_pos = offsets(world, ev);
update(end_pos);
},
onrelease(pos, ev) {
if(ev == null) throw new Error("bad");
const end_pos = offsets(world, ev);
update(end_pos);
},
};
};
const scaleview: ClickCallback = (pos, ev): ClickCallbackRet => {
if(ev == null) throw new Error("bad");
const start_pos = offsets(world, ev);
const start_matrix = world.center_transform;
const update = (end_pos: Vec2) => {
const offset = vec2.sub(end_pos, start_pos);
const wheel = offset[1] / 60;
const zoom = Math.pow(1 + Math.abs(wheel)/2 , wheel > 0 ? 1 : -1);
const cpos = screenToWorldPos(world, ...start_pos);
world.center_transform = new DOMMatrixReadOnly().scale(zoom).multiply(start_matrix);
const fpos = screenToWorldPos(world, ...start_pos);
world.center_transform = world.center_transform.translate(fpos.x - cpos.x, fpos.y - cpos.y);
};
return {
onmove(pos, ev) {
if(ev == null) throw new Error("bad");
const end_pos = offsets(world, ev);
update(end_pos);
},
onrelease(pos, ev) {
if(ev == null) throw new Error("bad");
const end_pos = offsets(world, ev);
update(end_pos);
},
};
};
const onmousedown = (e: MouseEvent) => createRoot(cleanup => {
if(disable_ev_lsn) return;
const should_drag = e.button !== 0 || e.ctrlKey || e.metaKey;
const should_scale = e.altKey;
const target_handler: ClickCallback = (should_scale ? scaleview : should_drag ? dragview : clickaction_override ?? rench.onclick);
const clkhl = target_handler(screenToWorldPos(world, ...offsets(world, e)), e);
const onmousemove = (e: MouseEvent) => {
if(disable_ev_lsn) return;
clkhl.onmove(screenToWorldPos(world, ...offsets(world, e)), e);
};
const onmouseup = (e: MouseEvent) => {
if(disable_ev_lsn) return;
clkhl.onrelease(screenToWorldPos(world, ...offsets(world, e)), e);
cleanup();
};
document.addEventListener("mousemove", onmousemove, {capture: true});
onCleanup(() => document.removeEventListener("mousemove", onmousemove, {capture: true}));
document.addEventListener("mouseup", onmouseup, {once: true, capture: true});
onCleanup(() => document.removeEventListener("mouseup", onmouseup, {capture: true}));
if(end_capture != null) {
end_capture(e);
end_capture = null;
}
end_capture = onmouseup;
onCleanup(() => {
if(end_capture === onmouseup) end_capture = null;
});
});
let touchData: {initialTouches: TouchList, initialCenterTransform: DOMMatrixReadOnly};
const ontouchstart = (ev: TouchEvent) => {
if(disable_ev_lsn) return;
ev.preventDefault();
touchData = initTouchData(world, ev);
};
const ontouchmove = (ev: TouchEvent) => {
if(disable_ev_lsn) return;
ev.preventDefault();
recvTouchEvent(world, ev, touchData);
};
const ontouchend = (ev: TouchEvent) => {
if(disable_ev_lsn) return;
ev.preventDefault();
};
const oncontextmenu = (ev: Event) => {
if(disable_ev_lsn) return;
ev.preventDefault();
return false;
};
canvas.addEventListener("wheel", onwheel, {passive: false});
onCleanup(() => canvas.removeEventListener("wheel", onwheel));
canvas.addEventListener("pointerdown", onpointerdown);
onCleanup(() => canvas.removeEventListener("pointerdown", onpointerdown));
canvas.addEventListener("mousedown", onmousedown);
onCleanup(() => canvas.removeEventListener("mousedown", onmousedown));
canvas.addEventListener("mousemove", onmousemove);
onCleanup(() => canvas.removeEventListener("mousemove", onmousemove));
canvas.addEventListener("touchstart", ontouchstart, {passive: false});
onCleanup(() => canvas.removeEventListener("touchstart", ontouchstart));
canvas.addEventListener("touchmove", ontouchmove, {passive: false});
onCleanup(() => canvas.removeEventListener("touchmove", ontouchmove));
canvas.addEventListener("touchend", ontouchend, {passive: false});
onCleanup(() => canvas.removeEventListener("touchend", ontouchend));
canvas.addEventListener("contextmenu", oncontextmenu, {passive: false});
onCleanup(() => canvas.removeEventListener("contextmenu", oncontextmenu));
const root_ctx = canvas.getContext("2d")!;
let running = true;
onCleanup(() => running = false);
let new_frame_requested: null | number = null;
onCleanup(() => {
if(new_frame_requested != null) cancelAnimationFrame(new_frame_requested);
});
function rerender() {
if(new_frame_requested != null) return;
if(!running) return;
new_frame_requested = requestAnimationFrame(() => {
new_frame_requested = null;
rerender();
});
root_ctx.save();
const w = canvas.clientWidth;
const h = canvas.clientHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
root_ctx.scale(dpr, dpr);
render();
root_ctx.restore();
}
const world: World = {
ctx: root_ctx,
canvas,
center_transform: new DOMMatrixReadOnly(),
setstate: setState,
el: subdiv,
get canvas_w() {
return canvas.width / (window.devicePixelRatio || 1);
},
get canvas_h() {
return canvas.height / (window.devicePixelRatio || 1);
},
};
const rench = renderChild(world, untrack(() => state()));
const render = () => {
const {ctx} = world;
const imev: ImEv = {
mouse: {
pos: mouse_pos ?? undefined,
captured: !!end_capture,
},
ctx: ctx,
};
rench.render(imev);
if(imev.mouse.action != null) {
canvas.style.cursor = imev.mouse.action.cursor;
clickaction_override = imev.mouse.action.callback;
}else{
if(end_capture == null) canvas.style.cursor = "";
else if(canvas.style.cursor === "grab") canvas.style.cursor = "grabbing";
clickaction_override = null;
}
// ctx.fillStyle = "white";
// const pos = screenToWorldPos(10, 10);
// ctx.fillRect(pos.x, pos.y, 10, 10);
};
if(!('requestIdleCallback' in window) || (false as true)) {
window.requestIdleCallback = cb => (cb({
didTimeout: false,
timeRemaining: () => 0,
}), 0);
window.cancelIdleCallback = () => {/**/};
}
let rsic: undefined | number;
new ResizeObserver((itms) => {
itms.forEach(itm => {
if(itm.target === canvas) {
if(rsic != null) cancelIdleCallback(rsic);
rsic = requestIdleCallback(() => {
rerender();
}, {
timeout: 500,
});
}
});
}).observe(canvas);
rerender();
}
function offsets(world: World, e: {clientX: number, clientY: number}): [number, number] {
const canv_pos = world.canvas.getBoundingClientRect();
return [e.clientX - canv_pos.left, e.clientY - canv_pos.top];
};
function recvScrollEvent(world: World, ev: WheelEvent): void {
if(ev.ctrlKey || ev.metaKey || ev.altKey) {
// scale or rotate
const wheel = -ev.deltaY / 60;
const zoom = Math.pow(1 + Math.abs(wheel)/2 , wheel > 0 ? 1 : -1);
const [fsetx, fsety] = offsets(world, ev);
const cpos = screenToWorldPos(world, fsetx, fsety);
if(ev.altKey) {
// rotate
world.center_transform = world.center_transform.rotate(-ev.deltaY / 10);
}else{
// scale
world.center_transform = world.center_transform.scale(zoom);
}
const fpos = screenToWorldPos(world, fsetx, fsety);
world.center_transform = world.center_transform.translate(fpos.x - cpos.x, fpos.y - cpos.y);
}else if(ev.shiftKey) {
// pan horizontal
world.center_transform = new DOMMatrixReadOnly().translate(-ev.deltaY + -ev.deltaX, 0).multiply(world.center_transform);
}else{
// pan
world.center_transform = new DOMMatrixReadOnly().translate(-ev.deltaX, -ev.deltaY).multiply(world.center_transform);
}
};
/**
* Initialize touch event data
* @param ev The touch event to process
*/
function initTouchData(world: World, ev: TouchEvent): { initialTouches: TouchList, initialCenterTransform: DOMMatrixReadOnly } {
const initialTouches = ev.touches;
const initialCenterTransform = world.center_transform;
return { initialTouches, initialCenterTransform };
}
/**
* Receives a touch event and updates the transform matrix of a 2D world
* to implement zooming, panning, and rotating behavior.
* @param world The 2D world to update
* @param ev The touch event to process
* @param touchData The initial touch event data
*/
function recvTouchEvent(world: World, ev: TouchEvent, touchData: { initialTouches: TouchList, initialCenterTransform: DOMMatrixReadOnly }): void {
if (ev.touches.length === touchData.initialTouches.length) {
/*if (ev.touches.length === 1) {
// Single touch: pan view
const dx = ev.touches[0].clientX - touchData.initialTouches[0].clientX;
const dy = ev.touches[0].clientY - touchData.initialTouches[0].clientY;
world.center_transform = new DOMMatrixReadOnly().translate(dx, dy).multiply(touchData.initialCenterTransform);
} else */
if (ev.touches.length === 2) {
// Two fingers touch: pan, zoom, and rotate view
const touch1 = ev.touches[0];
const touch2 = ev.touches[1];
const initialTouch1 = touchData.initialTouches[0];
const initialTouch2 = touchData.initialTouches[1];
const [initialTouch1_X, initialTouch1_Y] = offsets(world, initialTouch1);
const [initialTouch2_X, initialTouch2_Y] = offsets(world, initialTouch2);
const [touch1_X, touch1_Y] = offsets(world, touch1);
const [touch2_X, touch2_Y] = offsets(world, touch2);
// Calculate the initial and current midpoint of the two fingers
const initialMidX = (initialTouch1_X + initialTouch2_X) / 2;
const initialMidY = (initialTouch1_Y + initialTouch2_Y) / 2;
const currentMidX = (touch1_X + touch2_X) / 2;
const currentMidY = (touch1_Y + touch2_Y) / 2;
// Calculate the initial and current distance between the two fingers
const initialDist = Math.hypot(initialTouch1_X - initialTouch2_X, initialTouch1_Y - initialTouch2_Y);
const currentDist = Math.hypot(touch1_X - touch2_X, touch1_Y - touch2_Y);
// Calculate the zoom scale
const zoom = currentDist / initialDist;
// Calculate the rotation angle in degrees
const initialAngle = Math.atan2(initialTouch1_Y - initialTouch2_Y, initialTouch1_X - initialTouch2_X);
const currentAngle = Math.atan2(touch1_Y - touch2_Y, touch1_X - touch2_X);
const rotation = (currentAngle - initialAngle) * 180 / Math.PI;
// Calculate the pan translation
const dx = currentMidX - initialMidX;
const dy = currentMidY - initialMidY;
// Combine pan, zoom, and rotation to update the transform matrix
world.center_transform = new DOMMatrixReadOnly()
.translate(dx, dy)
.translate(initialMidX, initialMidY)
.translate(-world.canvas_w / 2, -world.canvas_h / 2)
.rotate(0, 0, rotation)
.scale(zoom)
.translate(world.canvas_w / 2, world.canvas_h / 2)
.translate(-initialMidX, -initialMidY)
.multiply(touchData.initialCenterTransform);
} else {
// Ignore events with more than two fingers
return;
}
}
};
export function getTransform(world: World): DOMMatrixReadOnly {
return new DOMMatrixReadOnly().translate(world.canvas_w / 2, world.canvas_h / 2).multiply(world.center_transform);
}
export function screenToWorldPos(world: World, spx: number, spy: number): {x: number, y: number} {
const res = getTransform(world).inverse().transformPoint({x: spx, y: spy});
return {x: res.x, y: res.y};
}
export function deltaTransformPoint(matrix: DOMMatrixReadOnly, point: {x: number, y: number}) {
var dx = point.x * matrix.a + point.y * matrix.c + 0;
var dy = point.x * matrix.b + point.y * matrix.d + 0;
return { x: dx, y: dy };
}
export function decomposeMatrix(matrix: DOMMatrixReadOnly) {
// calculate delta transform point
var px = deltaTransformPoint(matrix, { x: 0, y: 1 });
var py = deltaTransformPoint(matrix, { x: 1, y: 0 });
// calculate skew
var skewX = ((180 / Math.PI) * Math.atan2(px.y, px.x) - 90);
var skewY = ((180 / Math.PI) * Math.atan2(py.y, py.x));
return {
translateX: matrix.e,
translateY: matrix.f,
scaleX: Math.sqrt(matrix.a * matrix.a + matrix.b * matrix.b),
scaleY: Math.sqrt(matrix.c * matrix.c + matrix.d * matrix.d),
skewX: skewX,
skewY: skewY,
rotation: skewX // rotation is the same as skew x
};
}
export type Point = [number, number];
export type RenderChild<T> = (world: World, state: T) => RenderChildResult;
export type RenderChildResult = {
render: (imev: ImEv) => void,
onclick: ClickCallback,
onmove: (pos: {x: number, y: number}) => void,
};
export type ClickCallback = (pos: {x: number, y: number}, _ev?: MouseEvent | null) => ClickCallbackRet;
export type ClickCallbackRet = {
onmove: (pos: {x: number, y: number}, _ev?: MouseEvent) => void,
onrelease: (pos: {x: number, y: number}, _ev?: MouseEvent | null) => void,
};
export type ImEv = {
mouse: {
pos?: undefined | Vec2,
captured: boolean,
action?: undefined | {
cursor: string,
callback: ClickCallback,
},
},
ctx: CanvasRenderingContext2D,
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment