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
454 lines
12 KiB
2 years ago
|
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);
|
||
|
}
|
||
|
}
|