feat(site-map): tablet touch navigation

One finger slides the map, pinch zooms smoothly towards the fingers, two-finger
drag turns and tilts, double-tap zooms in, tap selects (10 px tolerance for fingers).
On touch screens the arrow pad is hidden and the hint shows the gestures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-15 08:14:53 +02:00
parent d21303f3af
commit 03394c8005
6 changed files with 116 additions and 16 deletions

View file

@ -4,8 +4,13 @@ const K = ({ children }: { children: React.ReactNode }) => (
export function NavHint() {
return (
<div className="pointer-events-none absolute bottom-2.5 right-3 z-10 hidden rounded-md border bg-background/95 px-2 py-1 text-[11px] text-muted-foreground md:block">
Move: <K>←</K><K>↑</K><K>↓</K><K>→</K> or <K>W</K><K>A</K><K>S</K><K>D</K> (hold <K>Shift</K> = faster) · Zoom: <K>+</K><K>−</K> or scroll · Rotate: <K>Q</K><K>E</K> or drag
</div>
<>
<div className="pointer-events-none absolute bottom-2.5 right-3 z-10 hidden rounded-md border bg-background/95 px-2 py-1 text-[11px] text-muted-foreground md:block pointer-coarse:hidden">
Move: <K>←</K><K>↑</K><K>↓</K><K>→</K> or <K>W</K><K>A</K><K>S</K><K>D</K> (hold <K>Shift</K> = faster) · Zoom: <K>+</K><K>−</K> or scroll · Rotate: <K>Q</K><K>E</K> or drag
</div>
<div className="pointer-events-none absolute bottom-2.5 left-1/2 z-10 hidden w-max max-w-[calc(100%-1.5rem)] -translate-x-1/2 rounded-md text-center border bg-background/95 px-2 py-1 text-[11px] text-muted-foreground pointer-coarse:block">
Drag to move · pinch to zoom · two fingers to turn and tilt · tap to select · double-tap to zoom in
</div>
</>
);
}

View file

@ -12,7 +12,7 @@ const isDir = (a: string): a is NavDir => DIRS.includes(a);
export function NavPad({ handle }: { handle: SiteMapHandle | null }) {
return (
<div className="absolute bottom-11 right-3 z-10 grid grid-cols-3 gap-1" aria-label="Map navigation">
<div className="absolute bottom-11 right-3 z-10 grid grid-cols-3 gap-1 pointer-coarse:hidden" aria-label="Map navigation">
{PAD.map(([glyph, title, action]) => (
<button key={action} type="button" title={title} aria-label={title}
className="h-9 w-9 touch-none select-none rounded-lg border bg-background/95 text-[15px] shadow-sm active:bg-primary active:text-primary-foreground"

View file

@ -104,7 +104,8 @@ export function createSiteMap(container: HTMLElement, model: SiteModelData, stat
let down: [number, number] | null = null, hoverT = 0;
const onDown = (e: PointerEvent) => { down = [e.clientX, e.clientY]; };
const onUp = (e: PointerEvent) => {
if (!down || e.button !== 0 || Math.hypot(e.clientX - down[0], e.clientY - down[1]) > 5) { down = null; return; }
const slop = e.pointerType === "touch" ? 10 : 5; // fingers wobble more than a mouse
if (!down || e.button !== 0 || Math.hypot(e.clientX - down[0], e.clientY - down[1]) > slop) { down = null; return; }
down = null;
opts.onPick((hitAt(e)?.object.userData.pick as Selection | undefined) ?? null);
};

View file

@ -1,9 +1,12 @@
// Smooth zoom towards the point under the mouse, plus keyboard / arrow-pad movement.
// OrbitControls applies a full zoom step per wheel event and trackpads fire dozens per gesture,
// so wheel events only add to a pending zoom (log-distance units) that the camera eases towards.
// Tablets: one finger slides the map, pinch zooms towards the fingers, two-finger drag turns/tilts,
// double-tap zooms in.
import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { NavDir } from "./types";
import { isDoubleTap, pinchStep, type Tap } from "./touch-gestures";
const KEYMAP: Record<string, NavDir> = {
ArrowUp: "f", KeyW: "f", ArrowDown: "b", KeyS: "b", ArrowLeft: "l", KeyA: "l", ArrowRight: "r", KeyD: "r", KeyQ: "rotL", KeyE: "rotR",
@ -16,31 +19,79 @@ export function attachNavigation(o: {
pick(ray: THREE.Raycaster): THREE.Vector3 | undefined; minDistance: number; maxDistance: number;
}) {
const { camera, controls, dom } = o;
controls.enableZoom = false;
let pending = 0;
controls.enableZoom = false; // all zooming goes through `pending` below
controls.touches = { ONE: THREE.TOUCH.PAN, TWO: THREE.TOUCH.DOLLY_ROTATE }; // dolly part is off: pinch is ours
let pending = 0, fast = false;
const addZoom = (step: number) => { pending = Math.max(-2.5, Math.min(2.5, pending + step)); };
const point = new THREE.Vector3(), tmp = new THREE.Vector3();
let lastPick = 0, lastXY = [0, 0];
const ray = new THREE.Raycaster(), ndc = new THREE.Vector2();
/** Zoom towards what is under this screen point (ground, road, or the target's level). */
function aimAt(clientX: number, clientY: number) {
const r = dom.getBoundingClientRect();
ndc.set(((clientX - r.left) / r.width) * 2 - 1, -((clientY - r.top) / r.height) * 2 + 1);
ray.setFromCamera(ndc, camera);
const hit = o.pick(ray);
if (hit) point.copy(hit);
else if (!ray.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 1, 0), -controls.target.y), point)) point.copy(controls.target);
}
const onWheel = (e: WheelEvent) => {
e.preventDefault();
let dy = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1);
if (e.ctrlKey) dy *= 4; // trackpad pinch sends small deltas
pending = Math.max(-2.5, Math.min(2.5, pending + Math.max(-0.5, Math.min(0.5, dy * 0.0012))));
addZoom(Math.max(-0.5, Math.min(0.5, dy * 0.0012)));
fast = false;
const now = performance.now();
if (now - lastPick > 250 || Math.hypot(e.clientX - lastXY[0], e.clientY - lastXY[1]) > 4) {
const r = dom.getBoundingClientRect();
ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
ray.setFromCamera(ndc, camera);
const hit = o.pick(ray);
if (hit) point.copy(hit);
else if (!ray.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 1, 0), -controls.target.y), point)) point.copy(controls.target);
aimAt(e.clientX, e.clientY);
lastXY = [e.clientX, e.clientY];
}
lastPick = now;
};
dom.addEventListener("wheel", onWheel, { passive: false });
// ---- touch: pinch + double-tap (one-finger pan and two-finger turn/tilt are OrbitControls) ----
const fingers = new Map<number, { x: number; y: number }>();
let pinchDist = 0, tapStart: Tap | null = null, lastTap: Tap | null = null;
const spread = () => { const [a, b] = [...fingers.values()]; return Math.hypot(a.x - b.x, a.y - b.y); };
const onTouchDown = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
fingers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (fingers.size === 1) tapStart = { t: performance.now(), x: e.clientX, y: e.clientY };
else tapStart = null;
if (fingers.size === 2) {
pinchDist = spread();
const [a, b] = [...fingers.values()];
aimAt((a.x + b.x) / 2, (a.y + b.y) / 2);
}
};
const onTouchMove = (e: PointerEvent) => {
if (e.pointerType !== "touch" || !fingers.has(e.pointerId)) return;
fingers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (fingers.size !== 2) return;
const d = spread();
addZoom(pinchStep(pinchDist, d));
pinchDist = d; fast = true; // follow the fingers closely
};
const onTouchUp = (e: PointerEvent) => {
if (e.pointerType !== "touch" || !fingers.has(e.pointerId)) return;
fingers.delete(e.pointerId);
if (fingers.size < 2) pinchDist = 0;
if (!tapStart || fingers.size) return;
const tap = { t: performance.now(), x: e.clientX, y: e.clientY };
const isTap = tap.t - tapStart.t < 300 && Math.hypot(tap.x - tapStart.x, tap.y - tapStart.y) < 10;
tapStart = null;
if (!isTap) { lastTap = null; return; }
if (isDoubleTap(lastTap, tap)) { aimAt(tap.x, tap.y); addZoom(-0.7); fast = false; lastTap = null; }
else lastTap = tap;
};
dom.addEventListener("pointerdown", onTouchDown);
dom.addEventListener("pointermove", onTouchMove);
dom.addEventListener("pointerup", onTouchUp);
dom.addEventListener("pointercancel", onTouchUp);
const held = new Set<NavDir>();
let shift = false;
const onKeyDown = (e: KeyboardEvent) => {
@ -59,7 +110,7 @@ export function attachNavigation(o: {
function zoom(amount: number) {
point.copy(controls.target);
pending = Math.max(-2.5, Math.min(2.5, pending + amount));
addZoom(amount); fast = false;
}
const fwd = new THREE.Vector3(), right = new THREE.Vector3(), move = new THREE.Vector3(), up = new THREE.Vector3(0, 1, 0);
@ -80,7 +131,7 @@ export function attachNavigation(o: {
if (rot) camera.position.copy(controls.target).add(tmp.copy(camera.position).sub(controls.target).applyAxisAngle(up, rot * 1.4 * dt));
}
if (Math.abs(pending) < 1e-4) { pending = 0; return; }
let step = pending * (1 - Math.exp(-dt * 10));
let step = pending * (1 - Math.exp(-dt * (fast ? 30 : 10)));
const dist = camera.position.distanceTo(controls.target), next = dist * Math.exp(step);
if (next < o.minDistance) step = Math.log(o.minDistance / dist);
if (next > o.maxDistance) step = Math.log(o.maxDistance / dist);
@ -96,6 +147,10 @@ export function attachNavigation(o: {
zoom,
dispose() {
dom.removeEventListener("wheel", onWheel);
dom.removeEventListener("pointerdown", onTouchDown);
dom.removeEventListener("pointermove", onTouchMove);
dom.removeEventListener("pointerup", onTouchUp);
dom.removeEventListener("pointercancel", onTouchUp);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { isDoubleTap, pinchStep } from "./touch-gestures";
describe("pinchStep", () => {
it("zooms in (negative step) when the fingers spread apart", () => {
expect(pinchStep(100, 200)).toBeCloseTo(Math.log(0.5));
});
it("zooms out (positive step) when the fingers pinch together", () => {
expect(pinchStep(200, 100)).toBeCloseTo(Math.log(2));
});
it("does nothing for a zero or tiny finger distance", () => {
expect(pinchStep(0, 100)).toBe(0);
expect(pinchStep(100, 0)).toBe(0);
});
});
describe("isDoubleTap", () => {
it("is a double tap when the second tap is quick and close", () => {
expect(isDoubleTap({ t: 1000, x: 50, y: 50 }, { t: 1250, x: 60, y: 55 })).toBe(true);
});
it("is not a double tap when too slow, too far, or without a first tap", () => {
expect(isDoubleTap({ t: 1000, x: 50, y: 50 }, { t: 1400, x: 50, y: 50 })).toBe(false);
expect(isDoubleTap({ t: 1000, x: 50, y: 50 }, { t: 1100, x: 120, y: 50 })).toBe(false);
expect(isDoubleTap(null, { t: 1100, x: 50, y: 50 })).toBe(false);
});
});

View file

@ -0,0 +1,13 @@
// Pure maths for tablet gestures (kept out of navigation.ts so it can be tested without a browser).
/** Zoom step in log-distance units for a pinch from `prev` to `cur` finger distance (px): spreading = negative = zoom in. */
export function pinchStep(prev: number, cur: number) {
if (prev < 1 || cur < 1) return 0;
return Math.log(prev / cur);
}
export interface Tap { t: number; x: number; y: number }
/** A second tap within 300 ms and 30 px of the first. */
export function isDoubleTap(first: Tap | null, second: Tap) {
return !!first && second.t - first.t <= 300 && Math.hypot(second.x - first.x, second.y - first.y) <= 30;
}