You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

454 lines
12 KiB

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);
}
}