export const MAX_CANVAS_DIMS = 10000; // export default function pptk(element, options = {}) { // if (element instanceof Element && element.nodeName === "CANVAS") { // return pptk.canvas(element, options); // } else if (element instanceof CanvasRenderingContext2D) { // return pptk.ctx_2d(element, options); // } else { // if (!options.silent) { // console.warn("Unrecognized element: " + element); // } // return element; // } // } export function canvas(canvas, options = {}) { canvas.style.imageRendering = "pixelated"; let container = options.container ?? canvas.parentNode; let update = options.update ?? (() => {}); let old_width = null; let old_height = null; let old_dpr = null; function resize(dpr) { // Get dimensions of container let {width, height} = container.getBoundingClientRect?.() ?? { width: container.clientWidth, height: container.clientHeight }; // Computer dimensions of canvas width = Math.ceil(width * dpr); height = Math.ceil(height * dpr); if (width === old_width && height === old_height && dpr === old_dpr) { // No update needed return; } old_width = width; old_height = height; old_dpr = dpr; // Safety measure for when the container size depends on the canvas' size if (width > MAX_CANVAS_DIMS || height > MAX_CANVAS_DIMS) { throw new Error( `Prevented resizing canvas to ${width}x${height}; ` + `is the container missing "overflow: hidden" or "width"/"height"?` ); } // Set image width and height canvas.width = width; canvas.height = height; // Set style width to guarantee perfect scaling canvas.style.width = `${width / dpr}px`; canvas.style.height = `${height / dpr}px`; update(canvas, width, height); } // Bind listeners let unbind_dpr = bind_pixel_ratio(canvas, (dpr) => { resize(dpr); }); let resize_observer = new ResizeObserver((entries) => { resize(window.devicePixelRatio ?? 1); }); resize_observer.observe(container, { box: "border-box" }); setTimeout(() => { resize(window.devicePixelRatio ?? 1); }, 0); canvas.unbind = function unbind() { if (resize_observer) { resize_observer.disconnect(); // Drop resize_observer resize_observer = null; } if (unbind_dpr) { unbind_dpr(); // Drop unbind_dpr unbind_dpr = null; } }; return canvas; } export function touch(element, options = {}) { let noop = () => {}; let start = options.start ?? noop; let move = options.move ?? noop; let end = options.end ?? noop; let touches = new Map(); let queued = null; let queued_touches = []; function get_dpr() { if (options.dpr) { return window.devicePixelRatio ?? 1; } else { return 1; } } function ondown(event) { if (options.preventDefault) event.preventDefault(); let dpr = get_dpr(); let bounding = element.getBoundingClientRect(); let x = (event.clientX - bounding.x) * dpr; let y = (event.clientY - bounding.y) * dpr; let touch = { x, y, click_x: x, click_y: y, dx: 0, dy: 0, type: event.pointerType, id: event.pointerId, }; touches.set(event.pointerId, touch); start(touches, touch); } function onmove(event) { if (options.preventDefault) event.preventDefault(); let dpr = get_dpr(); let bounding = element.getBoundingClientRect(); let x = (event.clientX - bounding.x) * dpr; let y = (event.clientY - bounding.y) * dpr; if (touches.has(event.pointerId)) { let touch = touches.get(event.pointerId); touch.dx = x - touch.x; touch.dy = y - touch.y; touch.x = x; touch.y = y; if (queued === null) { queued = setTimeout(() => { try { move(touches, queued_touches); } finally { queued_touches = []; queued = null; } }, 0); } queued_touches.push(touch); } } function onup(event) { if (options.preventDefault) event.preventDefault(); let dpr = get_dpr(); let bounding = element.getBoundingClientRect(); let x = (event.clientX - bounding.x) * dpr; let y = (event.clientY - bounding.y) * dpr; let touch = touches.get(event.pointerId); touches.delete(event.pointerId); end(touches, touch); } function oncancel(event) { touches.delete(event.pointerId); // TODO: trigger end()? } element.addEventListener("pointerdown", ondown); element.addEventListener("pointermove", onmove); element.addEventListener("pointerup", onup); element.addEventListener("pointercancel", oncancel); function ontouchmove(event) { event.preventDefault(); } // preventDefault() on pointer events does not work as expected (eg. scrolling still happens), so this is needed instead if (options.preventDefault) { element.addEventListener("touchmove", ontouchmove, {passive: false}); } return function unbind() { element.removeEventListener("pointerdown", ondown); element.removeEventListener("pointermove", onmove); element.removeEventListener("pointup", onup); element.removeEventListener("pointercancel", oncancel); if (options.preventDefault) { element.removeEventListener("touchmove", ontouchmove, {passive: false}); } } } export function pannable(element, options = {}) { return new Pannable(element, options); } export class Pannable { constructor(element, options) { this.update = options.update ?? (() => {}); this.filter = options.filter ?? (() => true); this.dpr = options.dpr ?? false; this.zoom_min = options.zoom_min ?? 0; this.zoom_max = options.zoom_max ?? 10; this.wheel_zoom = options.wheel_zoom ?? -0.002; this.preventDefault = options.preventDefault; this.center = options.center ?? (() => [0, 0]); this.element = element; this.cx = 0; this.cy = 0; this.zoom = 0; this.last = [0, 0, 0]; this.unbind_touch = touch(element, { preventDefault: options.preventDefault, dpr: options.dpr, start: this._change.bind(this), move: this._move.bind(this), end: this._change.bind(this), }); this.element.addEventListener("wheel", this._onwheel.bind(this)); } get(x, y) { let center = (this.center)() ?? [0, 0]; return [ this.cx + center[0] + this.zoom_factor * x, this.cy + center[1] + this.zoom_factor * y ]; } get_dpr() { if (this.dpr) { return window.devicePixelRatio ?? 1; } else { return 1; } } _calc(touches) { let filtered = []; for (let touch of touches.values()) { if (this.filter(touch)) { filtered.push(touch); } } if (filtered.length === 0) return [0, 0, 0]; let mx = 0; let my = 0; for (let touch of filtered) { mx += touch.x; my += touch.y; } mx /= filtered.length; my /= filtered.length; let sigma = 0; for (let touch of filtered) { let dx = (touch.x - mx); let dy = (touch.y - my); sigma += dx * dx + dy * dy; } if (filtered.length > 1) sigma = Math.sqrt(sigma / (filtered.length - 1)); return [mx, my, sigma]; } _change(touches) { // TODO: make numerically stable this.last = this._calc(touches); } _move(touches) { let [mx, my, sigma] = this._calc(touches); let dx = mx - this.last[0]; let dy = my - this.last[1]; let delta = (sigma === 0 || this.last[2] === 0) ? 1 : sigma / this.last[2]; let rect = this.element.getBoundingClientRect(); let old_zoom = this.zoom; // let old_cx = this.cx; // let old_cy = this.cy; this.zoom += Math.log2(delta); if (this.zoom < this.zoom_min) this.zoom = this.zoom_min; if (this.zoom > this.zoom_max) this.zoom = this.zoom_max; delta = Math.pow(2, this.zoom) / Math.pow(2, old_zoom); let center = (this.center)() ?? [0, 0]; this.cx = (this.cx - (this.last[0] - center[0])) * delta + (mx - center[0]); this.cy = (this.cy - (this.last[1] - center[1])) * delta + (my - center[1]); this.last = [mx, my, sigma]; (this.update)(this.element, this); } _onwheel(event) { if (this.preventDefault) event.preventDefault(); let rect = this.element.getBoundingClientRect(); let x = (event.clientX - rect.x) * this.get_dpr(); let y = (event.clientY - rect.y) * this.get_dpr(); let old_zoom = this.zoom; this.zoom += event.deltaY * this.wheel_zoom; if (this.zoom < this.zoom_min) this.zoom = this.zoom_min; if (this.zoom > this.zoom_max) this.zoom = this.zoom_max; let delta = Math.pow(2, this.zoom) / Math.pow(2, old_zoom); let center = (this.center)() ?? [0, 0]; this.cx = (this.cx - (x - center[0])) * delta + (x - center[0]); this.cy = (this.cy - (y - center[1])) * delta + (y - center[1]); (this.update)(this.element, this); } unbind() { this.unbind_touch(); } get zoom_factor() { return Math.pow(2, this.zoom); } } export function ctx_2d(ctx, options) { ctx.imageSmoothingEnabled = false; return ctx; // return new Context2D(ctx); } export class Context2D { constructor(raw_ctx) { raw_ctx.imageSmoothingEnabled = false; this.raw = raw_ctx; } get fillStyle() { return this.raw.fillStyle; } set fillStyle(value) { this.raw.fillStyle = value; } get strokeStyle() { return this.raw.strokeStyle; } set strokeStyle(value) { this.raw.strokeStyle = value; } get lineWidth() { return this.raw.lineWidth; } set lineWidth(value) { this.raw.lineWidth = Math.round(value); } beginPath() { this.raw.beginPath(); } fill() { this.raw.fill(); } fillRect(x, y, w, h) { let lw = this.raw.lineWidth; this.raw.fillRect( x - lw / 2, y - lw / 2, w + lw, h + lw, ); } ellipse(rx, ry, cx, cy, _change, end, offset) { this.raw.ellipse(rx, ry, cx, cy, start, end, offset); } } export function bind_pixel_ratio(element, callback, fire_first = false) { let first = true; let media; function update() { let dpr = window.devicePixelRatio ?? 1; let dpr_str = (dpr * 100).toFixed(0); if (!first && fire_first) { callback(dpr, element); } first = false; media = matchMedia(`(resolution: ${dpr}dppx)`); media.addEventListener("change", update, {once: true}); } update(); return function unbind_pixel_ratio() { media.removeEventListener("change", update); } }