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