diff --git a/src/components/CollabMap.astro b/src/components/CollabMap.astro index 5f4bf633..272c59f2 100644 --- a/src/components/CollabMap.astro +++ b/src/components/CollabMap.astro @@ -43,6 +43,7 @@ const label = (pin) => Equal Earth projection +
@@ -72,6 +73,13 @@ const label = (pin) => ))} + {/* Shown by mapzoom.js: with no script there is nothing for them to do. */} + +
@@ -124,5 +132,7 @@ const label = (pin) => diff --git a/src/components/ConfMap.astro b/src/components/ConfMap.astro index 61cc6781..e433b3f8 100644 --- a/src/components/ConfMap.astro +++ b/src/components/ConfMap.astro @@ -37,6 +37,7 @@ const offMap = unsettled.length + unplaced.length; Equal Earth projection +
))} + {/* Shown by mapzoom.js: with no script there is nothing for them to do. */} + +
@@ -140,5 +148,7 @@ const offMap = unsettled.length + unplaced.length; diff --git a/src/lib/mapzoom.js b/src/lib/mapzoom.js new file mode 100644 index 00000000..48bb1029 --- /dev/null +++ b/src/lib/mapzoom.js @@ -0,0 +1,117 @@ +// Zoom and pan for the world maps, by moving the SVG's viewBox. +// +// Progressive enhancement, like mappick.js: the controls are `hidden` in the +// markup and shown here, so with no script there is nothing that does nothing. +// The viewBox keeps the map's aspect ratio, so the figure never changes height +// and the page below it never jumps. + +export const MAX_ZOOM = 8; +const STEP = 2; + +/** The viewBox for zoom `z` centred on (cx, cy), kept inside the map. */ +export function viewFor(W, H, z, cx, cy) { + const w = W / z; + const h = H / z; + return { + x: Math.min(Math.max(cx - w / 2, 0), W - w), + y: Math.min(Math.max(cy - h / 2, 0), H - h), + w, + h, + }; +} + +/** Is the point inside the view? */ +export const inView = (view, x, y) => + x >= view.x && x <= view.x + view.w && y >= view.y && y <= view.y + view.h; + +/** Zoom to `z`, holding the point under the cursor (fractions fx, fy of the + * view) where it is: the centre of a button zoom is (.5, .5). */ +export function zoomAbout(W, H, view, z, fx = 0.5, fy = 0.5) { + const next = Math.min(Math.max(z, 1), MAX_ZOOM); + const w = W / next; + const h = H / next; + return viewFor(W, H, next, view.x + fx * view.w - fx * w + w / 2, view.y + fy * view.h - fy * h + h / 2); +} + +function wire(fig) { + const stage = fig.querySelector('.cmap-stage'); + const svg = stage?.querySelector('svg'); + const bar = stage?.querySelector('.cmap-zoom'); + if (!svg || !bar) return; + const [, , W, H] = svg.getAttribute('viewBox').split(' ').map(Number); + let view = { x: 0, y: 0, w: W, h: H }; + const zoomOf = () => W / view.w; + + // Where each person's pins are, so a trail can be dropped when none of them is + // on screen: a line running across the view from somewhere else says nothing. + const pinsOf = new Map(); + for (const c of stage.querySelectorAll('.cmap-pin')) { + const dot = c.querySelector('circle'); + if (!pinsOf.has(c.dataset.c)) pinsOf.set(c.dataset.c, []); + pinsOf.get(c.dataset.c).push([Number(dot.getAttribute('cx')), Number(dot.getAttribute('cy'))]); + } + const trails = [...stage.querySelectorAll('.cmap-trail')]; + + const apply = () => { + svg.setAttribute('viewBox', `${view.x.toFixed(2)} ${view.y.toFixed(2)} ${view.w.toFixed(2)} ${view.h.toFixed(2)}`); + const z = zoomOf(); + // Pins and lines are drawn in map units, so they grow with the zoom unless + // the stylesheet scales them back: it multiplies every size by --k. + svg.style.setProperty('--k', String(1 / z)); + stage.dataset.zoom = z > 1 ? String(z) : ''; + for (const t of trails) { + t.toggleAttribute('data-offview', z > 1 && !pinsOf.get(t.dataset.c)?.some(([x, y]) => inView(view, x, y))); + } + bar.querySelector('[data-zoom="in"]').disabled = z >= MAX_ZOOM; + bar.querySelector('[data-zoom="out"]').disabled = z <= 1; + bar.querySelector('[data-zoom="reset"]').disabled = z <= 1; + }; + const to = (z, fx, fy) => { view = zoomAbout(W, H, view, z, fx, fy); apply(); }; + + bar.hidden = false; + bar.addEventListener('click', (e) => { + const how = e.target.closest('[data-zoom]')?.dataset.zoom; + if (how === 'in') to(zoomOf() * STEP); + else if (how === 'out') to(zoomOf() / STEP); + else if (how === 'reset') to(1); + }); + + // Ctrl or ⌘ plus wheel — which is also what a trackpad pinch sends — zooms at + // the cursor. A bare wheel is left alone: it is the page scrolling past. + svg.addEventListener('wheel', (e) => { + if (!e.ctrlKey && !e.metaKey) return; + e.preventDefault(); + const r = svg.getBoundingClientRect(); + to(zoomOf() * Math.exp(-e.deltaY * 0.01), (e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height); + }, { passive: false }); + + // Drag to pan, once there is somewhere to pan to. A drag that moved is not a + // click: without this, letting go over a pin would pick it. + let drag = null; + let moved = false; + svg.addEventListener('pointerdown', (e) => { + if (zoomOf() > 1 && e.button === 0) { drag = { x: e.clientX, y: e.clientY, view: { ...view } }; moved = false; } + }); + svg.addEventListener('pointermove', (e) => { + if (!drag) return; + const r = svg.getBoundingClientRect(); + const dx = e.clientX - drag.x; + const dy = e.clientY - drag.y; + if (!moved && Math.hypot(dx, dy) < 4) return; + if (!moved) svg.setPointerCapture(e.pointerId); + moved = true; + view = viewFor(W, H, zoomOf(), drag.view.x + drag.view.w / 2 - (dx / r.width) * view.w, + drag.view.y + drag.view.h / 2 - (dy / r.height) * view.h); + apply(); + }); + const end = () => { drag = null; }; + svg.addEventListener('pointerup', end); + svg.addEventListener('pointercancel', end); + fig.addEventListener('click', (e) => { if (moved) { e.stopPropagation(); e.preventDefault(); moved = false; } }, true); +} + +export function wireZoom() { + for (const fig of document.querySelectorAll('.cmap')) { + if (!fig.dataset.zoomWired) { fig.dataset.zoomWired = '1'; wire(fig); } + } +} diff --git a/src/styles/map.css b/src/styles/map.css index da0c665a..18fc0231 100644 --- a/src/styles/map.css +++ b/src/styles/map.css @@ -31,23 +31,23 @@ referred to twice without repeating it. */ .cmap-lakes{fill:color-mix(in srgb, var(--sea) 6%, var(--ground)); stroke:none} .cmap-trail{ - fill:none; stroke:light-dark(oklch(.55 .16 var(--h)), oklch(.74 .15 var(--h))); stroke-width:1.6; - stroke-dasharray:4 4; stroke-linecap:round; opacity:.75; + fill:none; stroke:light-dark(oklch(.55 .16 var(--h)), oklch(.74 .15 var(--h))); stroke-width:calc(1.6px * var(--k,1)); + stroke-dasharray:calc(4px * var(--k,1)) calc(4px * var(--k,1)); stroke-linecap:round; opacity:.75; } /* Radius lives here rather than in the markup so it can grow as the map shrinks: at phone width the whole 1000-unit map is ~330px, where a 4.5-unit pin is three pixels across and invisible. The `r` attribute stays in the SVG as the value a stylesheet-less render would use. */ .cmap-pin circle{ - r:3; fill:var(--ground); stroke:light-dark(oklch(.55 .16 var(--h)), oklch(.74 .15 var(--h))); stroke-width:2; + r:calc(3px * var(--k,1)); fill:var(--ground); stroke:light-dark(oklch(.55 .16 var(--h)), oklch(.74 .15 var(--h))); stroke-width:calc(2px * var(--k,1)); transition:r .12s ease; } -.cmap-pin circle.is-shared{r:4.5; fill:light-dark(oklch(.55 .16 var(--h)), oklch(.74 .15 var(--h)))} -.cmap-pin:hover circle, .cmap-pin:focus-visible circle{r:8} +.cmap-pin circle.is-shared{r:calc(4.5px * var(--k,1)); fill:light-dark(oklch(.55 .16 var(--h)), oklch(.74 .15 var(--h)))} +.cmap-pin:hover circle, .cmap-pin:focus-visible circle{r:calc(8px * var(--k,1))} @media (max-width:44rem){ - .cmap-pin circle{r:6; stroke-width:3} - .cmap-pin circle.is-shared{r:8} - .cmap-trail{stroke-width:3; stroke-dasharray:7 7} + .cmap-pin circle{r:calc(6px * var(--k,1)); stroke-width:calc(3px * var(--k,1))} + .cmap-pin circle.is-shared{r:calc(8px * var(--k,1))} + .cmap-trail{stroke-width:calc(3px * var(--k,1)); stroke-dasharray:calc(7px * var(--k,1)) calc(7px * var(--k,1))} } @media (prefers-reduced-motion:reduce){ .cmap-pin circle{transition:none} } @@ -82,7 +82,7 @@ writing an inline opacity, so "has no inline opacity" is exactly "is the one being pointed at" — expressed here rather than as a second class to keep in step. */ -.cmap[data-hover] .cmap-pin:not([style*="opacity"]) circle{r:7} +.cmap[data-hover] .cmap-pin:not([style*="opacity"]) circle{r:calc(7px * var(--k,1))} .cmap-pin, .cmap-trail, .cmap-list > li{transition:opacity .15s ease} .cmap-list > li{outline-offset:3px} @media (prefers-reduced-motion:reduce){ @@ -165,6 +165,23 @@ .cmap-orcid .ico{width:.9375rem; height:.9375rem; fill:currentColor; display:block} .cmap-orcid:hover, .cmap-orcid:focus-visible{color:var(--accent)} +/* ── Zoom ──────────────────────────────────────────────────────────────────── + mapzoom.js moves the svg's viewBox and sets --k to 1/zoom, which every pin and + line above multiplies by so they stay the size they were. */ +.cmap-stage{position:relative} +.cmap-zoom{position:absolute; top:.6rem; right:.6rem; display:flex; flex-direction:column; gap:.25rem} +.cmap-zoom[hidden]{display:none} +.cmap-zoom button{ + width:1.9rem; height:1.9rem; padding:0; line-height:1; font:inherit; font-size:1.05rem; + color:var(--ink-2); background:var(--ground); border:1px solid var(--rule-strong); border-radius:.4rem; cursor:pointer; +} +.cmap-zoom button:hover:not(:disabled), .cmap-zoom button:focus-visible{color:var(--accent); border-color:currentColor} +.cmap-zoom button:disabled{color:var(--ink-faint); cursor:default; opacity:.55} +/* Once there is somewhere to pan to, a drag pans: the page must not scroll under + a finger doing it. */ +.cmap-trail[data-offview]{display:none} +.cmap-stage[data-zoom] svg{cursor:grab; touch-action:none} + /* A pin is pressable, so it should look it. */ .cmap-pin, .cmap-trail{cursor:pointer} @@ -178,16 +195,16 @@ the `r` attribute in the markup is still what a stylesheet-less render uses. Part-transparent so overlapping cities read as overlapping rather than as one blob, which is what Cambridge and New York do at this scale. */ -.cmap--conf .cmap-pin circle{r:var(--r); fill:oklch(.55 .16 var(--h) / .72)} +.cmap--conf .cmap-pin circle{r:calc(var(--r) * var(--k,1)); fill:oklch(.55 .16 var(--h) / .72)} .cmap--conf .cmap-pin:hover circle, .cmap--conf .cmap-pin:focus-visible circle, .cmap--conf[data-hover] .cmap-pin:not([style*="opacity"]) circle{ - r:calc(var(--r) * 1.45); fill-opacity:1 + r:calc(var(--r) * 1.45 * var(--k,1)); fill-opacity:1 } @media (max-width:44rem){ /* The phone rule above doubles a three-unit pin; the same generosity here, applied to a size that already varies. */ - .cmap--conf .cmap-pin circle{r:calc(var(--r) * 1.7); stroke-width:3} + .cmap--conf .cmap-pin circle{r:calc(var(--r) * 1.7 * var(--k,1)); stroke-width:calc(3px * var(--k,1))} } /* Filled and unstroked, because a conference pin is: a 2px stroke on a circle diff --git a/tests/mapzoom.test.js b/tests/mapzoom.test.js new file mode 100644 index 00000000..9c22526c --- /dev/null +++ b/tests/mapzoom.test.js @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_ZOOM, inView, viewFor, zoomAbout } from '../src/lib/mapzoom.js'; + +const W = 1000; +const H = 500; +const whole = { x: 0, y: 0, w: W, h: H }; + +describe('map zoom', () => { + it('shows the whole map at 1x, however it is asked for', () => { + expect(viewFor(W, H, 1, 123, 456)).toEqual(whole); + }); + + it('never leaves the map: a view centred past an edge slides back inside', () => { + expect(viewFor(W, H, 4, 0, 0)).toEqual({ x: 0, y: 0, w: 250, h: 125 }); + expect(viewFor(W, H, 4, W, H)).toEqual({ x: 750, y: 375, w: 250, h: 125 }); + }); + + it('zooms about the centre for a button, and about the cursor for a wheel', () => { + expect(zoomAbout(W, H, whole, 2)).toEqual({ x: 250, y: 125, w: 500, h: 250 }); + // The point under the cursor stays under it: fraction .25 of the view is + // user x = 250 before, and still is after. + const v = zoomAbout(W, H, whole, 2, 0.25, 0.25); + expect(v.x + 0.25 * v.w).toBeCloseTo(250); + expect(v.y + 0.25 * v.h).toBeCloseTo(125); + }); + + it('clamps the zoom between the whole map and MAX_ZOOM', () => { + expect(zoomAbout(W, H, whole, 0.2)).toEqual(whole); + expect(zoomAbout(W, H, whole, 1e6).w).toBe(W / MAX_ZOOM); + }); + + it('knows which points are on screen, edges included', () => { + const v = { x: 100, y: 50, w: 250, h: 125 }; + expect(inView(v, 100, 50)).toBe(true); + expect(inView(v, 350, 175)).toBe(true); + expect(inView(v, 99, 100)).toBe(false); + expect(inView(v, 200, 176)).toBe(false); + }); +});