commit
c862f04ebc
@ -0,0 +1,33 @@
|
||||
# Pixel-Perfect ToolKit
|
||||
|
||||
This library aims to solve two problems in web-development, that are notoriously hard to both solve at once:
|
||||
- support for mobile devices (high-DPI, multi-touch, etc.)
|
||||
- pixel-perfect control, notably for [pixel fonts](https://github.com/adri326/online-pixel-font-creator)
|
||||
|
||||
## Limitations
|
||||
|
||||
### Canvases must be contained
|
||||
|
||||
Because canvases must have an integer width and height, and because `window.devicePixelRatio` may be a floating-point value.
|
||||
|
||||
For instance, if `dpr = 1.5` and the desired with is `81` CSS pixels, then the desired canvas width would be `81 * 1.5 = 121.5`.
|
||||
Naively setting `canvas.width = 121.5` results in the browser truncating it to `121`, which only cover `121 / 1.5 ≈ 80.66` CSS pixels, resulting in blurred edges as the element has to be stretched horizontally by `0.4%`.
|
||||
|
||||
Instead, we set `canvas.width = Math.ceil(121.5) = 122`, then set `canvas.style.width` to `122 / 1.5 ≈ 81.33px`.
|
||||
This requires a parent "container" element to read the desired width from and to hide the overflowing `0.33` CSS pixel.
|
||||
|
||||
This is what the container element should have as style:
|
||||
|
||||
```css
|
||||
.container {
|
||||
/* Necessary properties */
|
||||
overflow: hidden;
|
||||
|
||||
/* Optionally set a different background color to reduce flicker */
|
||||
background-color: /* ... */;
|
||||
|
||||
/* Width and height should be set to anything besides `fit-content` */
|
||||
width: /* ... */;
|
||||
height: /* ... */;
|
||||
}
|
||||
```
|
@ -0,0 +1,453 @@
|
||||
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);
|
||||
}
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PPTK test page</title>
|
||||
<script type="module" defer>
|
||||
import * as pptk from "./pptk.js";
|
||||
|
||||
let canvas = document.getElementById("canvas");
|
||||
canvas = pptk.canvas(canvas, {
|
||||
update: draw
|
||||
});
|
||||
|
||||
let pannable = pptk.pannable(canvas, {
|
||||
preventDefault: true,
|
||||
dpr: true,
|
||||
center: () => [canvas.width / 2, canvas.height / 2],
|
||||
update: () => draw(canvas),
|
||||
});
|
||||
|
||||
let ctx = pptk.ctx_2d(canvas.getContext("2d"));
|
||||
|
||||
function draw(canvas) {
|
||||
ctx.fillStyle = "gray";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(
|
||||
...pannable.get(0, 0),
|
||||
5 * pannable.zoom_factor,
|
||||
5 * pannable.zoom_factor,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
0
|
||||
);
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fill();
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100vw;
|
||||
width: 100dvw;
|
||||
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
overflow: hidden;
|
||||
|
||||
width: 80vw;
|
||||
height: 80vh;
|
||||
background: black;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<canvas id="canvas">
|
||||
This test requires canvas support
|
||||
</canvas>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in new issue