diff --git a/docs/pr-pinch/pinch-after.png b/docs/pr-pinch/pinch-after.png new file mode 100644 index 0000000..cbc4c83 Binary files /dev/null and b/docs/pr-pinch/pinch-after.png differ diff --git a/docs/pr-pinch/pinch-before.png b/docs/pr-pinch/pinch-before.png new file mode 100644 index 0000000..9331618 Binary files /dev/null and b/docs/pr-pinch/pinch-before.png differ diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index 2e7529b..9a84063 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -763,6 +763,45 @@ await scenario( }, ); +// 23. A pinch on the map (ctrl+wheel in Chromium/Firefox, gesture events in Safari) zooms the +// map and is kept from zooming the page; a plain wheel still scrolls the page. +await scenario( + "map: a pinch zooms the map, not the page", + `${BASE}?mode=od&from=${enc(P)}&to=${enc(T)}&date=${DATE}`, + async (page) => { + assert(await until(async () => (await $count(page, ".leaflet-container .leaflet-tile")) > 0), "the map never drew"); + // Tiles fail to load offline, but their src still names the zoom level. + const zoom = () => + page.$$eval(".leaflet-tile", (ts) => Math.max(...ts.map((t) => +(t.src.match(/\/(\d+)\/\d+\/\d+\.png/) || [])[1]))); + const fire = (type, init) => + page.$eval(".leaflet-container", (el, type, init) => { + const r = el.getBoundingClientRect(); + const opts = { bubbles: true, cancelable: true }; + const at = { clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }; + const e = + type === "wheel" + ? new WheelEvent(type, { ...opts, ...at, ...init }) + : Object.assign(new Event(type, opts), at, init); // Safari's GestureEvent, which Chromium lacks + el.dispatchEvent(e); + return e.defaultPrevented; + }, type, init); + // Let the results' fit-to-route settle first. + let last = -1; + await until(async () => { const z = await zoom(); const same = z === last; last = z; await sleep(400); return same; }); + const z0 = await zoom(); + assert(!(await fire("wheel", { deltaY: 100 })), "a plain wheel over the map no longer scrolls the page"); + let kept = true; + for (let i = 0; i < 10; i++) kept = (await fire("wheel", { deltaY: -10, ctrlKey: true })) && kept; + assert(kept, "a trackpad pinch on the map was left to zoom the page"); + assert(await until(async () => (await zoom()) > z0), "a trackpad pinch did not zoom the map in"); + const z1 = await zoom(); + assert(await fire("gesturestart", { scale: 1 }), "a Safari pinch on the map was left to zoom the page"); + await fire("gesturechange", { scale: 0.4 }); + await fire("gestureend", { scale: 0.4 }); + assert(await until(async () => (await zoom()) < z1), "a Safari pinch did not zoom the map out"); + }, +); + // 12. PWA manifest is served and parseable, icon reference resolves. await scenario("pwa: manifest is served and valid JSON", BASE, async (page) => { const manifestHref = await page.$eval('link[rel="manifest"]', (el) => el.getAttribute("href")); diff --git a/src/ui/map.ts b/src/ui/map.ts index cc0bd5e..3597c02 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -26,6 +26,70 @@ export interface MarkerInfo { connections?: number; } +/** Wheel px of a pinch that doubles the scale (Chrome reports e^(-deltaY/100)). */ +const PX_PER_ZOOM_LEVEL = 100 * Math.LN2; + +/** + * Make pinches on the map zoom the map rather than the whole page. + * + * A trackpad pinch (and ctrl+wheel) arrives as a `wheel` event with `ctrlKey`, + * and as `gesture*` events in desktop Safari; left alone, both zoom the page. + * Touch pinches are Leaflet's `touchZoom`, and plain wheels still scroll the page. + */ +function zoomOnPinch(map: L.Map, el: HTMLElement): void { + let levels = 0; // pinched but not yet applied, + = zoom in + let at = map.getSize().divideBy(2); + let timer = 0; + let zooming = false; // Leaflet drops a zoom asked for mid-animation, so wait for it + let touches = 0; + let scale = 1; + + const apply = (): void => { + timer = 0; + const step = Math.round(levels); + if (zooming || !step) return; + levels = 0; + map.setZoomAround(at, map.getZoom() + step); + }; + map.on("zoomstart", () => (zooming = true)); + map.on("zoomend", () => { + zooming = false; + apply(); + }); + const pinch = (by: number, e: MouseEvent): void => { + levels += by; + at = map.mouseEventToContainerPoint(e); + timer ||= window.setTimeout(apply, 40); + }; + + el.addEventListener( + "wheel", + (e) => { + if (!e.ctrlKey) return; + e.preventDefault(); + pinch(-(e.deltaMode ? e.deltaY * 33 : e.deltaY) / PX_PER_ZOOM_LEVEL, e); + }, + { passive: false }, + ); + + // Safari: `scale` is relative to the gesture's start. A touch pinch also fires + // these, but touchZoom already handles it. + for (const t of ["touchstart", "touchend", "touchcancel"] as const) { + el.addEventListener(t, (e) => (touches = e.touches.length), { passive: true }); + } + el.addEventListener("gesturestart", (e) => { + e.preventDefault(); + scale = 1; + }); + el.addEventListener("gesturechange", (e) => { + e.preventDefault(); + const g = e as MouseEvent & { scale: number }; + if (!touches && g.scale > 0) pinch(Math.log2(g.scale / scale), g); + scale = g.scale; + }); + el.addEventListener("gestureend", (e) => e.preventDefault()); +} + /** Small Leaflet wrapper that draws routes between stations on a map of France. */ export class RouteMap { private map: L.Map | null = null; @@ -61,6 +125,9 @@ export class RouteMap { // phones. Canvas draws them all in one paint and pans smoothly. preferCanvas: true, dragging: true, + // A plain wheel or two-finger scroll keeps scrolling the page past the + // map; pinches (trackpad, Safari gestures, ctrl+wheel) zoom it instead, + // see zoomOnPinch. scrollWheelZoom: false, // Pinch-zoom and double-tap zoom stay on: on phones the full-bleed map is // the centrepiece and the +/- control is hidden under 860px (styles.css), @@ -71,6 +138,7 @@ export class RouteMap { touchZoom: true, zoomControl: true, }).setView([46.6, 2.4], 5); + zoomOnPinch(this.map, this.container); this.map.attributionControl.setPrefix(false); // drop the default "Leaflet" + flag prefix L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "© OpenStreetMap",