SOURCE / PINNED RELEASE
Made of little things.
Atlas map viewer
- Release
- 1d2807e80b44…
- Author-recorded commit
- 43810ed2b368…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-4757dbabc86
Archive hash verified: 8db6876d96c460ac…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { afterEach, expect, test } from 'bun:test';
import { MapInput, type InputActions } from '../../packages/renderer/src/input';
import { groundDelta, screenToWorld, type Camera } from '../../packages/renderer/src/camera';
class Canvas extends EventTarget {
captures = new Set<number>();
released: number[] = [];
clientHeight = 300;
ownerDocument = { defaultView: new EventTarget() };
focus() {}
getBoundingClientRect() {
return { left: 40, top: 30, width: 400, height: 300 };
}
setPointerCapture(id: number) {
this.captures.add(id);
}
hasPointerCapture(id: number) {
return this.captures.has(id);
}
releasePointerCapture(id: number) {
if (!this.captures.delete(id)) throw new Error('Releasing an unowned pointer');
this.released.push(id);
this.emit('lostpointercapture', { pointerId: id });
}
emit(type: string, properties: Record<string, unknown> = {}) {
const event = Object.assign(new Event(type, { cancelable: true }), properties);
this.dispatchEvent(event);
return event;
}
pointer(type: string, id: number, x: number, y: number, properties = {}) {
return this.emit(type, {
pointerId: id,
pointerType: 'touch',
button: 0,
clientX: x + 40,
clientY: y + 30,
...properties,
});
}
}
const cleanup: (() => void)[] = [];
afterEach(() => {
for (const close of cleanup.splice(0)) close();
});
function setup(pitch = 0, bearing = 0) {
const canvas = new Canvas();
const camera: Camera = { center: [0.5, 0.5], zoom: 5, width: 400, height: 300, pitch, bearing };
const calls: { action: keyof InputActions; args: unknown[] }[] = [];
const input = new MapInput(canvas as unknown as HTMLCanvasElement, {
pan(dx, dy) {
calls.push({ action: 'pan', args: [dx, dy] });
const d = groundDelta(dx, dy, camera.pitch, camera.bearing),
world = 256 * 2 ** camera.zoom;
camera.center = [camera.center[0] - d[0] / world, camera.center[1] - d[1] / world];
},
zoom(delta, x, y) {
calls.push({ action: 'zoom', args: [delta, x, y] });
const before = 2 ** camera.zoom;
camera.zoom = Math.max(0, Math.min(20, camera.zoom + delta));
const d = groundDelta(x, y, camera.pitch, camera.bearing);
const change = 1 / (256 * before) - 1 / (256 * 2 ** camera.zoom);
camera.center = [camera.center[0] + d[0] * change, camera.center[1] + d[1] * change];
},
orbit: (...args) => {
calls.push({ action: 'orbit', args });
},
click: (...args) => {
calls.push({ action: 'click', args });
},
command: (...args) => {
calls.push({ action: 'command', args });
},
});
cleanup.push(() => input.destroy());
return { canvas, camera, calls, input };
}
function near(actual: number[], expected: number[]) {
actual.forEach((value, i) => expect(value).toBeCloseTo(expected[i]!, 12));
}
test('pinch zooms in and out around the moving midpoint on a tilted, rotated map', () => {
const { canvas, camera, calls } = setup(45, 33);
canvas.pointer('pointerdown', 1, 100, 100);
canvas.pointer('pointerdown', 2, 200, 100);
const anchor = screenToWorld([150, 100], camera);
canvas.pointer('pointermove', 2, 300, 100);
expect(camera.zoom).toBe(6);
near(screenToWorld([200, 100], camera), anchor);
expect(calls).toEqual([
{ action: 'pan', args: [50, 0] },
{ action: 'zoom', args: [1, 0, -50] },
]);
canvas.pointer('pointermove', 2, 200, 100);
expect(camera.zoom).toBe(5);
near(screenToWorld([150, 100], camera), anchor);
});
test('two fingers can pan together without changing final zoom', () => {
const { canvas, camera } = setup();
canvas.pointer('pointerdown', 1, 100, 100);
canvas.pointer('pointerdown', 2, 200, 100);
const anchor = screenToWorld([150, 100], camera);
canvas.pointer('pointermove', 1, 120, 130);
canvas.pointer('pointermove', 2, 220, 130);
expect(camera.zoom).toBeCloseTo(5, 12);
near(screenToWorld([170, 130], camera), anchor);
});
test('one to two to one fingers rebase without jumps or a false click', () => {
const { canvas, calls } = setup();
canvas.pointer('pointerdown', 1, 100, 100);
canvas.pointer('pointermove', 1, 120, 100);
expect(calls).toEqual([{ action: 'pan', args: [20, 0] }]);
canvas.pointer('pointerdown', 2, 220, 100);
expect(calls).toHaveLength(1);
canvas.pointer('pointerup', 1, 120, 100);
expect(calls).toHaveLength(1);
canvas.pointer('pointermove', 2, 223, 104);
expect(calls.at(-1)).toEqual({ action: 'pan', args: [3, 4] });
canvas.pointer('pointerup', 2, 223, 104);
expect(calls.some((c) => c.action === 'click')).toBe(false);
expect(canvas.captures.size).toBe(0);
canvas.pointer('pointerdown', 3, 20, 30);
canvas.pointer('pointerup', 3, 20, 30);
expect(calls.at(-1)).toEqual({ action: 'click', args: [20, 30] });
});
test('a third finger does not disturb the pair and can replace a lifted finger', () => {
const { canvas, camera, calls } = setup();
canvas.pointer('pointerdown', 1, 100, 100);
canvas.pointer('pointerdown', 2, 200, 100);
canvas.pointer('pointerdown', 3, 400, 100);
canvas.pointer('pointermove', 3, 500, 100);
expect(calls).toEqual([]);
canvas.pointer('pointerup', 1, 100, 100);
expect(calls).toEqual([]);
const anchor = screenToWorld([350, 100], camera);
canvas.pointer('pointermove', 3, 800, 100);
expect(camera.zoom).toBe(6);
near(screenToWorld([500, 100], camera), anchor);
});
for (const reason of ['pointercancel', 'lostpointercapture']) {
test(`${reason} only ends the affected pointer and never clicks`, () => {
const { canvas, calls } = setup();
canvas.pointer('pointerdown', 1, 100, 100);
canvas.pointer('pointerdown', 2, 200, 100);
canvas.pointer(reason, 99, 0, 0);
canvas.pointer('pointermove', 2, 300, 100);
expect(calls.some((c) => c.action === 'zoom')).toBe(true);
canvas.pointer(reason, 1, 100, 100);
canvas.pointer('pointermove', 2, 310, 105);
expect(calls.at(-1)).toEqual({ action: 'pan', args: [10, 5] });
canvas.pointer('pointerup', 1, 100, 100);
canvas.pointer('pointerup', 2, 310, 105);
expect(calls.some((c) => c.action === 'click')).toBe(false);
expect(canvas.captures.size).toBe(0);
});
}
test('coincident touches and invalid positions cannot produce invalid zoom', () => {
const { canvas, camera, calls } = setup();
canvas.pointer('pointerdown', 1, 100, 100);
canvas.pointer('pointerdown', 2, 100, 100);
canvas.pointer('pointermove', 2, NaN, 100);
canvas.pointer('pointermove', 2, 200, 100);
expect(calls.filter((c) => c.action === 'zoom')).toEqual([]);
canvas.pointer('pointermove', 2, 300, 100);
expect(camera.zoom).toBe(6);
canvas.pointer('pointermove', 2, 100, 100);
expect(calls.every((c) => c.args.every(Number.isFinite))).toBe(true);
});
for (const end of ['blur', 'window blur', 'destroy']) {
test(`${end} releases all captures and discards the gesture`, () => {
const { canvas, calls, input } = setup();
canvas.pointer('pointerdown', 1, 100, 100);
canvas.pointer('pointerdown', 2, 200, 100);
if (end === 'destroy') input.destroy();
else if (end === 'window blur')
canvas.ownerDocument.defaultView.dispatchEvent(new Event('blur'));
else canvas.emit('blur');
expect(canvas.captures.size).toBe(0);
expect(canvas.released.sort()).toEqual([1, 2]);
canvas.pointer('pointermove', 1, 200, 100);
canvas.pointer('pointerup', 2, 200, 100);
expect(calls).toEqual([]);
if (end === 'destroy') {
canvas.pointer('pointerdown', 3, 100, 100);
canvas.pointer('pointermove', 3, 200, 100);
expect(calls).toEqual([]);
expect(canvas.captures.size).toBe(0);
}
});
}
test('mouse drag, orbit, taps, wheel and keyboard retain their existing actions', () => {
const { canvas, calls } = setup();
const mouse = { pointerType: 'mouse' };
canvas.pointer('pointerdown', 1, 20, 30, mouse);
canvas.pointer('pointermove', 1, 23, 30, mouse);
expect(calls).toEqual([]);
canvas.pointer('pointerup', 1, 23, 30, mouse);
expect(calls.pop()).toEqual({ action: 'click', args: [23, 30] });
canvas.pointer('pointerdown', 1, 20, 30, mouse);
canvas.pointer('pointercancel', 99, 20, 30, mouse);
canvas.pointer('pointermove', 1, 40, 35, mouse);
expect(calls.pop()).toEqual({ action: 'pan', args: [20, 5] });
canvas.pointer('pointerup', 1, 40, 35, mouse);
canvas.pointer('pointerdown', 2, 20, 30, { ...mouse, button: 2 });
canvas.pointer('pointermove', 2, 40, 35, mouse);
expect(calls.pop()).toEqual({ action: 'orbit', args: [20, 5] });
canvas.pointer('pointerup', 2, 40, 35, mouse);
canvas.emit('wheel', { deltaX: 0, deltaY: 10, deltaMode: 1, clientX: 140, clientY: 130 });
expect(calls.pop()).toEqual({ action: 'zoom', args: [-0.32, -100, -50] });
canvas.emit('wheel', { deltaX: 2, deltaY: 3, deltaMode: 0, metaKey: true });
expect(calls.pop()).toEqual({ action: 'orbit', args: [2, 3] });
canvas.emit('keydown', { key: 'ArrowLeft' });
expect(calls.pop()).toEqual({ action: 'pan', args: [80, -0] });
canvas.emit('keydown', { key: 'ArrowRight', shiftKey: true });
expect(calls.pop()).toEqual({ action: 'orbit', args: [20, 0] });
canvas.emit('keydown', { key: '+' });
expect(calls.pop()).toEqual({ action: 'zoom', args: [1, 0, 0] });
canvas.emit('keydown', { key: 'Escape' });
expect(calls.pop()).toEqual({ action: 'command', args: ['cancel'] });
expect(canvas.captures.size).toBe(0);
});
