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.
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();
}
}
