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.

packages/renderer/src/input.ts
export interface InputActions {
  pan(dx: number, dy: number): void;
  orbit(dx: number, dy: number): void;
  zoom(delta: number, x: number, y: number): void;
  click(x: number, y: number): void;
  command(command: "finish" | "choose" | "cancel" | "reset"): void;
}
/** Normalizes pointer, trackpad and keyboard gestures into camera/picking actions. */
export class MapInput {
  private abort = new AbortController();
  private touches = new Map<number, { x: number; y: number }>();
  private drag?: {
    mode: "pan" | "orbit";
    id: number;
    x: number;
    y: number;
    startX: number;
    startY: number;
    moved: boolean;
  };
  constructor(
    private canvas: HTMLCanvasElement,
    private actions: InputActions,
  ) {
    const options = { signal: this.abort.signal };
    canvas.addEventListener(
      "pointerdown",
      (event) => {
        if (
          ![0, 2].includes(event.button) ||
          !Number.isFinite(event.clientX) ||
          !Number.isFinite(event.clientY)
        )
          return;
        const touch = event.pointerType === "touch";
        if (
          touch
            ? this.drag && !this.touches.has(this.drag.id)
            : this.drag || this.touches.size
        )
          return;
        event.preventDefault();
        canvas.focus({ preventScroll: true });
        if (touch)
          this.touches.set(event.pointerId, {
            x: event.clientX,
            y: event.clientY,
          });
        this.drag =
          this.touches.size > 1
            ? undefined
            : {
                mode:
                  !touch &&
                  (event.button === 2 || event.metaKey || event.ctrlKey)
                    ? "orbit"
                    : "pan",
                id: event.pointerId,
                x: event.clientX,
                y: event.clientY,
                startX: event.clientX,
                startY: event.clientY,
                moved: false,
              };
        canvas.setPointerCapture(event.pointerId);
      },
      options,
    );
    canvas.addEventListener(
      "pointermove",
      (event) => {
        if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY))
          return;
        if (this.moveTouch(event)) return;
        const d = this.drag;
        if (!d || d.id !== event.pointerId) return;
        if (Math.hypot(event.clientX - d.startX, event.clientY - d.startY) > 5)
          d.moved = true;
        if (!d.moved) return;
        actions[d.mode](event.clientX - d.x, event.clientY - d.y);
        d.x = event.clientX;
        d.y = event.clientY;
      },
      options,
    );
    canvas.addEventListener(
      "pointerup",
      (event) => this.endPointer(event, true),
      options,
    );
    for (const name of ["pointercancel", "lostpointercapture"] as const)
      canvas.addEventListener(
        name,
        (event) => this.endPointer(event, false),
        options,
      );
    canvas.addEventListener("blur", () => this.clearPointers(), options);
    canvas.ownerDocument.defaultView?.addEventListener(
      "blur",
      () => this.clearPointers(),
      options,
    );
    canvas.addEventListener("contextmenu", (e) => e.preventDefault(), options);
    canvas.addEventListener(
      "wheel",
      (event) => {
        event.preventDefault();
        const unit =
          event.deltaMode === 1
            ? 16
            : event.deltaMode === 2
              ? canvas.clientHeight
              : 1;
        if (event.metaKey) {
          actions.orbit(event.deltaX * unit, event.deltaY * unit);
          return;
        }
        const box = canvas.getBoundingClientRect();
        actions.zoom(
          -event.deltaY * unit * 0.002,
          event.clientX - box.left - box.width / 2,
          event.clientY - box.top - box.height / 2,
        );
      },
      { ...options, passive: false },
    );
    canvas.addEventListener(
      "keydown",
      (event) => {
        const shifts: Record<string, [number, number]> = {
          ArrowLeft: [-80, 0],
          ArrowRight: [80, 0],
          ArrowUp: [0, -80],
          ArrowDown: [0, 80],
        };
        const commands: Record<
          string,
          "finish" | "choose" | "cancel" | "reset"
        > = {
          Enter: "finish",
          " ": "choose",
          Escape: "cancel",
          r: "reset",
          R: "reset",
        };
        const shift = shifts[event.key],
          command = commands[event.key];
        if (command) {
          event.preventDefault();
          actions.command(command);
        } else if (shift) {
          event.preventDefault();
          if (event.metaKey || event.ctrlKey || event.shiftKey)
            actions.orbit(shift[0] / 4, shift[1] / 4);
          else actions.pan(-shift[0], -shift[1]);
        } else if (["+", "=", "-"].includes(event.key)) {
          event.preventDefault();
          actions.zoom(event.key === "-" ? -1 : 1, 0, 0);
        }
      },
      options,
    );
  }
  private touchPair() {
    const [a, b] = this.touches.values();
    if (!a || !b) return;
    return {
      x: (a.x + b.x) / 2,
      y: (a.y + b.y) / 2,
      distance: Math.hypot(b.x - a.x, b.y - a.y),
    };
  }
  private moveTouch(event: PointerEvent) {
    if (!this.touches.has(event.pointerId)) return false;
    const before = this.touchPair();
    this.touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
    const after = this.touchPair();
    if (!before || !after) return false;
    // Pan first, then zoom at the new midpoint: the map point between the
    // fingers stays anchored even when the camera is tilted or rotated.
    const dx = after.x - before.x,
      dy = after.y - before.y;
    if (dx || dy) this.actions.pan(dx, dy);
    const delta = Math.log2(after.distance / before.distance);
    if (
      before.distance > 0 &&
      after.distance > 0 &&
      Number.isFinite(delta) &&
      delta !== 0
    ) {
      const box = this.canvas.getBoundingClientRect();
      this.actions.zoom(
        delta,
        after.x - box.left - box.width / 2,
        after.y - box.top - box.height / 2,
      );
    }
    return true;
  }
  private endPointer(event: PointerEvent, click: boolean) {
    const d = this.drag?.id === event.pointerId ? this.drag : undefined;
    const touch = this.touches.delete(event.pointerId);
    if (!d && !touch) return;
    if (d) this.drag = undefined;
    if (touch && this.touches.size === 1) {
      const [id, point] = this.touches.entries().next().value!;
      // A remaining finger resumes pan from its current position, never a tap.
      this.drag = {
        mode: "pan",
        id,
        ...point,
        startX: point.x,
        startY: point.y,
        moved: true,
      };
    }
    this.releasePointer(event.pointerId);
    if (click && d?.mode === "pan" && !d.moved) {
      const box = this.canvas.getBoundingClientRect();
      this.actions.click(event.clientX - box.left, event.clientY - box.top);
    }
  }
  private releasePointer(id: number) {
    if (this.canvas.hasPointerCapture(id))
      this.canvas.releasePointerCapture(id);
  }
  private clearPointers() {
    const ids = new Set(this.touches.keys());
    if (this.drag) ids.add(this.drag.id);
    this.touches.clear();
    this.drag = undefined;
    for (const id of ids) this.releasePointer(id);
  }
  destroy() {
    this.abort.abort();
    this.clearPointers();
  }
}