+ {/* 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);
+ });
+});