Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/pr-pinch/pinch-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-pinch/pinch-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 39 additions & 0 deletions scripts/e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
68 changes: 68 additions & 0 deletions src/ui/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand All @@ -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: "&copy; OpenStreetMap",
Expand Down
Loading