Add Pannable and attachPannable

main
Shad Amethyst 2 years ago
parent 8c49083ebb
commit 96a0847e2c

120
package-lock.json generated

@ -1,15 +1,14 @@
{
"name": "pptk",
"version": "0.1.0",
"name": "@shadryx/pptk",
"version": "0.1.6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "pptk",
"version": "0.1.0",
"name": "@shadryx/pptk",
"version": "0.1.6",
"license": "MIT",
"devDependencies": {
"@rollup/plugin-typescript": "^11.0.0",
"solid-js": "^1.6.2",
"tslib": "^2.4.1",
"typescript": "^4.9.0",
@ -575,60 +574,6 @@
"@jridgewell/sourcemap-codec": "1.4.14"
}
},
"node_modules/@rollup/plugin-typescript": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.0.0.tgz",
"integrity": "sha512-goPyCWBiimk1iJgSTgsehFD5OOFHiAknrRJjqFCudcW8JtWiBlK284Xnn4flqMqg6YAjVG/EE+3aVzrL5qNSzQ==",
"dev": true,
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"resolve": "^1.22.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^2.14.0||^3.0.0",
"tslib": "*",
"typescript": ">=3.7.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
},
"tslib": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz",
"integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==",
"dev": true,
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@types/estree": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz",
"integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==",
"dev": true
},
"node_modules/@types/node": {
"version": "18.11.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
@ -1160,12 +1105,6 @@
"node": ">=0.8.0"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@ -1345,18 +1284,6 @@
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
"dev": true
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.4.21",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",
@ -2030,33 +1957,6 @@
"@jridgewell/sourcemap-codec": "1.4.14"
}
},
"@rollup/plugin-typescript": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.0.0.tgz",
"integrity": "sha512-goPyCWBiimk1iJgSTgsehFD5OOFHiAknrRJjqFCudcW8JtWiBlK284Xnn4flqMqg6YAjVG/EE+3aVzrL5qNSzQ==",
"dev": true,
"requires": {
"@rollup/pluginutils": "^5.0.1",
"resolve": "^1.22.1"
}
},
"@rollup/pluginutils": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz",
"integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==",
"dev": true,
"requires": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^2.3.1"
}
},
"@types/estree": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz",
"integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==",
"dev": true
},
"@types/node": {
"version": "18.11.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
@ -2349,12 +2249,6 @@
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true
},
"estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@ -2482,12 +2376,6 @@
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
"dev": true
},
"picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true
},
"postcss": {
"version": "8.4.21",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",

@ -1,6 +1,6 @@
{
"name": "@shadryx/pptk",
"version": "0.1.4",
"version": "0.1.6",
"description": "Pixel-Perfect ToolKit, a library to help make pixel-perfect applications on high-DPI devices",
"keywords": [
"pixel-perfect",
@ -44,7 +44,8 @@
"dev:solid": "vite dev test/solid",
"dev:vanilla": "vite dev test/vanilla",
"build": "npm run prepare-dev && vite build && tsc && cp src/index.d.ts dist/types/index.d.ts",
"clean": "rm -rf dist/ src/index.d.ts"
"clean": "rm -rf dist/ src/index.d.ts",
"publish": "npm i && npm run build && npm publish --access public"
},
"repository": {
"type": "git",

@ -20,6 +20,7 @@ export const MAX_CANVAS_SIZE = 10000;
/**
* A function to call to unbind the event listeners attached to a canvas through `attachCanvas`.
* @deprecated This type will be removed in the next major release
* @callback AttachCanvasUnbind
* @returns {void}
**/
@ -32,7 +33,7 @@ export const MAX_CANVAS_SIZE = 10000;
*
* @param {HTMLCanvasElement} canvas
* @param {AttachCanvasOptions} options
* @returns {AttachCanvasUnbind}
* @returns {() => void}
**/
export function attachCanvas(canvas, options = {}) {
const EPSILON = 0.001;
@ -93,15 +94,15 @@ export function attachCanvas(canvas, options = {}) {
// Bind listeners
let unbind_dpr = listenPixelRatio(canvas, (dpr) => {
let unbindDpr = listenPixelRatio(canvas, (dpr) => {
resize(dpr);
});
let resize_observer = new ResizeObserver((entries) => {
let resizeObserver = new ResizeObserver((entries) => {
resize(window.devicePixelRatio ?? 1);
});
resize_observer.observe(container, {
resizeObserver.observe(container, {
box: "border-box"
});
@ -109,22 +110,22 @@ export function attachCanvas(canvas, options = {}) {
resize(window.devicePixelRatio ?? 1);
}, 0);
return function unbind() {
if (resize_observer) {
resize_observer.disconnect();
// Drop resize_observer
resize_observer = null;
return function detachCanvas() {
if (resizeObserver) {
resizeObserver.disconnect();
// Drop resizeObserver
resizeObserver = null;
}
if (unbind_dpr) {
unbind_dpr();
// Drop unbind_dpr
unbind_dpr = null;
if (unbindDpr) {
unbindDpr();
// Drop unbindDpr
unbindDpr = null;
}
};
}
export function listenPixelRatio(element, callback, fire_first = false) {
export function listenPixelRatio(element, callback, fireFirst = false) {
let first = true;
let media;
@ -132,7 +133,7 @@ export function listenPixelRatio(element, callback, fire_first = false) {
let dpr = window.devicePixelRatio ?? 1;
let dpr_str = (dpr * 100).toFixed(0);
if (!first && fire_first) {
if (!first && fireFirst) {
callback(dpr, element);
}
first = false;
@ -144,7 +145,7 @@ export function listenPixelRatio(element, callback, fire_first = false) {
update();
return function unbind_pixel_ratio() {
return function detachPixelRatio() {
media.removeEventListener("change", update);
}
}
@ -171,9 +172,9 @@ export function getContext2D(canvas) {
/**
* @typedef {object} AttachTouchOptions
* @prop {((touch: Touch, touches: Map<number, Touch>) => void)=} onDown
* @prop {((touch: Touch[], touches: Map<number, Touch>) => void)=} onMove
* @prop {((touch: Touch | undefined, touches: Map<number, Touch>) => void)=} onUp
* @prop {((touch: Touch, touchMap: Map<number, Touch>) => void)=} onDown
* @prop {((affectedTouches: Touch[], touchMap: Map<number, Touch>) => void)=} onMove
* @prop {((touch: Touch | undefined, touchMap: Map<number, Touch>) => void)=} onUp
* @prop {number=} downscale
* @prop {boolean=} preventDefault
**/
@ -189,7 +190,7 @@ export function attachTouch(element, options = {}) {
const downscale = options.downscale ?? 1;
let queued = null;
let queued_touches = [];
let queuedTouches = [];
function get_dpr() {
return window.devicePixelRatio ?? 1;
@ -241,14 +242,14 @@ export function attachTouch(element, options = {}) {
if (queued === null) {
queued = setTimeout(() => {
try {
options.onMove?.(queued_touches, touches);
options.onMove?.(queuedTouches, touches);
} finally {
queued_touches = [];
queuedTouches = [];
queued = null;
}
}, 0);
}
queued_touches.push(touch);
queuedTouches.push(touch);
}
}
@ -286,29 +287,226 @@ export function attachTouch(element, options = {}) {
element.addEventListener("pointerdown", onDown);
element.addEventListener("pointermove", onMove);
element.addEventListener("pointerup", onUp);
element.addEventListener("pointerout", onUp);
element.addEventListener("pointercancel", onCancel);
function ontouchmove(event) {
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});
element.addEventListener("touchmove", onTouchMove, {passive: false});
}
return function unbind() {
return function detachTouch() {
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});
element.removeEventListener("touchmove", onTouchMove, {passive: false});
}
}
}
/**
* @typedef {object} PannableState
* An immutable object returned by `Pannable::getState()`
* @prop {number} dx
* @prop {number} dy
* @prop {number} scale
* @prop {number} logScale
* @prop {(x: number, y: number) => [x: number, y: number]} get
* Returns the transformed coordinates
**/
/**
* @typedef {object} PannableOptions
* @prop {number=} downscale
* @prop {number=} dx Defaults to 0
* @prop {number=} dy Defaults to 0
* @prop {number=} minScale
* @prop {number=} maxScale
**/
/**
* Handles panning and zooming
**/
export class Pannable {
/**
* @param {PannableOptions} options
**/
constructor(options) {
/** @type {number} **/
this.dx = options?.dx || 0;
/** @type {number} **/
this.dy = options?.dy || 0;
/** @type {number} **/
this.logScale = 0.0;
/** @type {number} **/
this.logMinScale = options?.minScale ? Math.log2(options.minScale) : -Infinity;
/** @type {number} **/
this.logMaxScale = options?.maxScale ? Math.log2(options.maxScale) : Infinity;
/** @private **/
this.lastValues = [0, 0, 1];
}
/** @private **/
_getValues(touches) {
// First-order moments of touches.x and touches.y
let mx = 0;
let my = 0;
// Second-order moments of touches.x and touches.y
let mx2 = 0;
let my2 = 0;
for (const touch of touches.values()) {
mx += touch.x;
my += touch.y;
mx2 += touch.x * touch.x;
my2 += touch.y * touch.y;
}
const n = touches.size;
if (n <= 1) {
return [mx, my, 1];
}
// Unbiased sample variance, from https://en.wikipedia.org/wiki/Bessel%27s_correction#Formula
const sx = mx2 / (n - 1) - (mx * mx) / (n * (n - 1));
const sy = my2 / (n - 1) - (my * my) / (n * (n - 1));
return [mx / n, my / n, Math.sqrt(sx + sy)];
}
/** @returns {number} **/
get scale() {
return Math.pow(2, this.logScale);
}
/** @returns {PannableState} **/
getState() {
const scale = this.scale;
const dx = this.dx;
const dy = this.dy;
return {
dx,
dy,
logScale: this.logScale,
scale,
get: (x, y) => {
return [x * scale + dx, y * scale + dy];
}
};
}
/**
* @param {Map<number, Touch>} touches
**/
move(touches) {
const currentValues = this._getValues(touches);
let deltaLogScale = Math.log2(currentValues[2] / this.lastValues[2]);
if (Number.isNaN(deltaLogScale) || !Number.isFinite(deltaLogScale)) {
deltaLogScale = 0;
}
const oldLogScale = this.logScale;
this.logScale += deltaLogScale;
if (this.logScale <= this.logMinScale) this.logScale = this.logMinScale;
if (this.logScale >= this.logMaxScale) this.logScale = this.logMaxScale;
const deltaScale = Math.pow(2, this.logScale - oldLogScale);
this.dx = (this.dx - this.lastValues[0]) * deltaScale + currentValues[0];
this.dy = (this.dy - this.lastValues[1]) * deltaScale + currentValues[1];
this.lastValues = currentValues;
}
zoom(amount, cx = 0, cy = 0) {
const oldLogScale = this.logScale;
this.logScale += Math.log2(amount);
if (this.logScale <= this.logMinScale) this.logScale = this.logMinScale;
if (this.logScale >= this.logMaxScale) this.logScale = this.logMaxScale;
const deltaScale = Math.pow(2, this.logScale - oldLogScale);
this.dx = (this.dx - cx) * deltaScale + cx;
this.dy = (this.dy - cy) * deltaScale + cy;
}
/**
* @param {Map<number, Touch>} touches
**/
update(touches) {
this.lastValues = this._getValues(touches);
}
}
/**
* @typedef {PannableOptions & {
* onUpdate?: (state: PannableState) => void,
* wheelSensitivity?: number,
* emSize?: number,
* pageSize?: number,
* }} AttachPannableOptions
**/
/**
* A simple wrapper around `Pannable`, which handles touch input and scrollwheel input
* @param {HTMLElement} element
* @param {AttachPannableOptions} options
**/
export function attachPannable(element, options) {
const pannable = new Pannable(options);
const wheelSensitivity = options?.wheelSensitivity ?? 0.005;
const emSize = options?.emSize ?? 12;
const pageSize = options?.pageSize ?? options?.emSize ?? 12;
setTimeout(() => options?.onUpdate?.(pannable.getState()), 0);
const detachTouch = attachTouch(element, {
downscale: options.downscale,
preventDefault: true,
onDown(_, touches) {
pannable.update(touches);
},
onUp(_, touches) {
pannable.update(touches);
},
onMove(_, touches) {
pannable.move(touches);
options?.onUpdate?.(pannable.getState());
}
});
const wheelListener = element.addEventListener("wheel", (event) => {
const dpr = window.devicePixelRatio || 1;
const bounding = element.getBoundingClientRect();
let deltaY = -event.deltaY;
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
deltaY *= emSize;
} else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
deltaY *= pageSize;
}
pannable.zoom(
Math.pow(2, deltaY * wheelSensitivity * dpr),
(event.clientX - bounding.x) * dpr,
(event.clientY - bounding.y) * dpr
);
options?.onUpdate?.(pannable.getState());
});
return function detachPannable() {
element.removeEventListener(wheelListener);
detachTouch();
};
}
export const StrokeMode = Object.freeze({
DEFAULT: 0,
INSIDE: 1,

@ -6,30 +6,35 @@
<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";
import * as pptk from "../../src";
let canvas = document.getElementById("canvas");
canvas = pptk.canvas(canvas, {
update: draw
let zoomState = null;
pptk.attachCanvas(canvas, {
onResize: draw
});
let pannable = pptk.pannable(canvas, {
pptk.attachPannable(canvas, {
preventDefault: true,
dpr: true,
center: () => [canvas.width / 2, canvas.height / 2],
update: () => draw(canvas),
onUpdate: (state) => {
zoomState = state;
draw(canvas);
},
});
let ctx = pptk.ctx_2d(canvas.getContext("2d"));
let ctx = canvas.getContext("2d");
function draw(canvas) {
if (!zoomState) return;
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,
...zoomState.get(0, 0),
5 * zoomState.scale,
5 * zoomState.scale,
0,
Math.PI * 2,
0

Loading…
Cancel
Save