Back to Atlas map viewer
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.

app/tests/input.test.ts
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);
});