From 4e7ce2937f03337dc9a61bb8c057f9f2cbc5c2e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:07:09 +0000 Subject: [PATCH 1/2] Map: pinch to zoom the map instead of the page A trackpad pinch (ctrl+wheel in Chromium and Firefox, gesture events in Safari) over the map zoomed the whole page, because the map only listens to touch pinches. Catch those on the map, zoom the map around the pointer and keep the page still. A plain wheel still scrolls the page past the map. Adds an end-to-end scenario for both pinch kinds. --- scripts/e2e.mjs | 39 ++++++++++++++++++++++ src/ui/map.ts | 88 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) 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..1ae8115 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -26,6 +26,90 @@ export interface MarkerInfo { connections?: number; } +/** Wheel delta, in 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. + * + * Browsers report a trackpad pinch (and ctrl+wheel) as a `wheel` event with + * `ctrlKey` set, and desktop Safari as `gesture*` events; left alone, both zoom + * the page. Touch pinches are already Leaflet's `touchZoom`. Plain wheels are + * not touched, so scrolling over the map still scrolls the page. + */ +function zoomOnPinch(map: L.Map, el: HTMLElement): void { + let delta = 0; // accumulated px, positive = zoom in + let at: L.Point | null = null; + let timer = 0; + // Leaflet drops a zoom asked for mid-animation (its own or a fitBounds), so + // hold the pinch until the running one ends. + let busy = false; + let lastEvent = 0; + let touches = 0; + let gestureScale = 1; + + const apply = (): void => { + timer = 0; + if (busy) return; + const levels = delta / PX_PER_ZOOM_LEVEL; + if (Math.abs(levels) < 0.5) return; + const step = Math.round(levels); + delta -= step * PX_PER_ZOOM_LEVEL; + const zoom = map.getZoom(); + const target = Math.min(Math.max(zoom + step, map.getMinZoom()), map.getMaxZoom()); + if (target === zoom) { + delta = 0; + return; + } + if (at) map.setZoomAround(at, target); + else map.setZoom(target); + }; + + map.on("zoomstart", () => (busy = true)); + map.on("zoomend", () => { + busy = false; + if (!timer) apply(); + }); + + const add = (px: number, e: MouseEvent): void => { + const now = Date.now(); + if (now - lastEvent > 250) delta = 0; // a new pinch: drop the last one's remainder + lastEvent = now; + delta += px; + at = map.mouseEventToContainerPoint(e); + if (!timer) timer = window.setTimeout(apply, 40); + }; + + el.addEventListener( + "wheel", + (e) => { + if (!e.ctrlKey) return; + e.preventDefault(); + const unit = e.deltaMode === 1 ? 33 : e.deltaMode === 2 ? 800 : 1; + add(-e.deltaY * unit, e); + }, + { passive: false }, + ); + + // Safari: GestureEvent.scale is relative to the gesture's start. + type GestureEvent = MouseEvent & { scale: number }; + el.addEventListener("touchstart", (e) => (touches = e.touches.length), { passive: true }); + el.addEventListener("touchend", (e) => (touches = e.touches.length), { passive: true }); + el.addEventListener("touchcancel", (e) => (touches = e.touches.length), { passive: true }); + el.addEventListener("gesturestart", (e) => { + e.preventDefault(); + gestureScale = 1; + }); + el.addEventListener("gesturechange", (e) => { + e.preventDefault(); + const g = e as GestureEvent; + // A touch pinch (iOS, touch-screen Macs) is already zoomed by touchZoom. + if (touches === 0 && g.scale > 0) add(Math.log2(g.scale / gestureScale) * PX_PER_ZOOM_LEVEL, g); + gestureScale = 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 +145,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 +158,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", From 9fc8cb70b0aa039e56aa54d53940c819dcdefa1f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:22:50 +0000 Subject: [PATCH 2/2] Map: simplify the pinch handler, add PR screenshots --- docs/pr-pinch/pinch-after.png | Bin 0 -> 9188 bytes docs/pr-pinch/pinch-before.png | Bin 0 -> 8541 bytes src/ui/map.ts | 74 ++++++++++++--------------------- 3 files changed, 27 insertions(+), 47 deletions(-) create mode 100644 docs/pr-pinch/pinch-after.png create mode 100644 docs/pr-pinch/pinch-before.png diff --git a/docs/pr-pinch/pinch-after.png b/docs/pr-pinch/pinch-after.png new file mode 100644 index 0000000000000000000000000000000000000000..cbc4c83f9cf7b69b6ceccae57bba183967b9f90c GIT binary patch literal 9188 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 aYS7LLN`1s-7bOUMG=4gNDofq`*Z%;Px#{`< literal 0 HcmV?d00001 diff --git a/src/ui/map.ts b/src/ui/map.ts index 1ae8115..3597c02 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -26,58 +26,40 @@ export interface MarkerInfo { connections?: number; } -/** Wheel delta, in px, of a pinch that doubles the scale (Chrome reports e^(-deltaY/100)). */ +/** 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. * - * Browsers report a trackpad pinch (and ctrl+wheel) as a `wheel` event with - * `ctrlKey` set, and desktop Safari as `gesture*` events; left alone, both zoom - * the page. Touch pinches are already Leaflet's `touchZoom`. Plain wheels are - * not touched, so scrolling over the map still scrolls the 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 delta = 0; // accumulated px, positive = zoom in - let at: L.Point | null = null; + let levels = 0; // pinched but not yet applied, + = zoom in + let at = map.getSize().divideBy(2); let timer = 0; - // Leaflet drops a zoom asked for mid-animation (its own or a fitBounds), so - // hold the pinch until the running one ends. - let busy = false; - let lastEvent = 0; + let zooming = false; // Leaflet drops a zoom asked for mid-animation, so wait for it let touches = 0; - let gestureScale = 1; + let scale = 1; const apply = (): void => { timer = 0; - if (busy) return; - const levels = delta / PX_PER_ZOOM_LEVEL; - if (Math.abs(levels) < 0.5) return; const step = Math.round(levels); - delta -= step * PX_PER_ZOOM_LEVEL; - const zoom = map.getZoom(); - const target = Math.min(Math.max(zoom + step, map.getMinZoom()), map.getMaxZoom()); - if (target === zoom) { - delta = 0; - return; - } - if (at) map.setZoomAround(at, target); - else map.setZoom(target); + if (zooming || !step) return; + levels = 0; + map.setZoomAround(at, map.getZoom() + step); }; - - map.on("zoomstart", () => (busy = true)); + map.on("zoomstart", () => (zooming = true)); map.on("zoomend", () => { - busy = false; - if (!timer) apply(); + zooming = false; + apply(); }); - - const add = (px: number, e: MouseEvent): void => { - const now = Date.now(); - if (now - lastEvent > 250) delta = 0; // a new pinch: drop the last one's remainder - lastEvent = now; - delta += px; + const pinch = (by: number, e: MouseEvent): void => { + levels += by; at = map.mouseEventToContainerPoint(e); - if (!timer) timer = window.setTimeout(apply, 40); + timer ||= window.setTimeout(apply, 40); }; el.addEventListener( @@ -85,27 +67,25 @@ function zoomOnPinch(map: L.Map, el: HTMLElement): void { (e) => { if (!e.ctrlKey) return; e.preventDefault(); - const unit = e.deltaMode === 1 ? 33 : e.deltaMode === 2 ? 800 : 1; - add(-e.deltaY * unit, e); + pinch(-(e.deltaMode ? e.deltaY * 33 : e.deltaY) / PX_PER_ZOOM_LEVEL, e); }, { passive: false }, ); - // Safari: GestureEvent.scale is relative to the gesture's start. - type GestureEvent = MouseEvent & { scale: number }; - el.addEventListener("touchstart", (e) => (touches = e.touches.length), { passive: true }); - el.addEventListener("touchend", (e) => (touches = e.touches.length), { passive: true }); - el.addEventListener("touchcancel", (e) => (touches = e.touches.length), { passive: true }); + // 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(); - gestureScale = 1; + scale = 1; }); el.addEventListener("gesturechange", (e) => { e.preventDefault(); - const g = e as GestureEvent; - // A touch pinch (iOS, touch-screen Macs) is already zoomed by touchZoom. - if (touches === 0 && g.scale > 0) add(Math.log2(g.scale / gestureScale) * PX_PER_ZOOM_LEVEL, g); - gestureScale = g.scale; + 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()); }