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
10 changes: 10 additions & 0 deletions src/components/CollabMap.astro
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const label = (pin) =>
<a class="cmap-proj" href="https://en.wikipedia.org/wiki/Equal_Earth_projection">Equal Earth projection</a>
</figcaption>

<div class="cmap-stage">
<svg viewBox={`0 0 ${map.width} ${map.height}`} role="img"
aria-label={`World map: ${pins.length} posts held by ${people.length} collaborators, `
+ `on an Equal Earth projection. The same information is listed below.`}>
Expand Down Expand Up @@ -72,6 +73,13 @@ const label = (pin) =>
</g>
))}
</svg>
{/* Shown by mapzoom.js: with no script there is nothing for them to do. */}
<div class="cmap-zoom" role="group" aria-label="Zoom the map" hidden>
<button type="button" data-zoom="in" aria-label="Zoom in">+</button>
<button type="button" data-zoom="out" aria-label="Zoom out" disabled>−</button>
<button type="button" data-zoom="reset" aria-label="Show the whole world" disabled>⤢</button>
</div>
</div>

<div class="cmap-pick">
<label for="cmap-who" hidden>Show</label>
Expand Down Expand Up @@ -124,5 +132,7 @@ const label = (pin) =>

<script>
import { wireAll } from '../lib/mappick.js';
import { wireZoom } from '../lib/mapzoom.js';
wireAll();
wireZoom();
</script>
10 changes: 10 additions & 0 deletions src/components/ConfMap.astro
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const offMap = unsettled.length + unplaced.length;
<a class="cmap-proj" href="https://en.wikipedia.org/wiki/Equal_Earth_projection">Equal Earth projection</a>
</figcaption>

<div class="cmap-stage">
<svg viewBox={`0 0 ${map.width} ${map.height}`} role="img"
aria-label={`World map: ${plural(conf.talks, 'talk')} given in `
+ `${plural(pins.length, 'place')}, on an Equal Earth projection. `
Expand Down Expand Up @@ -70,6 +71,13 @@ const offMap = unsettled.length + unplaced.length;
</g>
))}
</svg>
{/* Shown by mapzoom.js: with no script there is nothing for them to do. */}
<div class="cmap-zoom" role="group" aria-label="Zoom the map" hidden>
<button type="button" data-zoom="in" aria-label="Zoom in">+</button>
<button type="button" data-zoom="out" aria-label="Zoom out" disabled>−</button>
<button type="button" data-zoom="reset" aria-label="Show the whole world" disabled>⤢</button>
</div>
</div>

<div class="cmap-pick">
<label for="conf-who" hidden>Show</label>
Expand Down Expand Up @@ -140,5 +148,7 @@ const offMap = unsettled.length + unplaced.length;

<script>
import { wireAll } from '../lib/mappick.js';
import { wireZoom } from '../lib/mapzoom.js';
wireAll();
wireZoom();
</script>
117 changes: 117 additions & 0 deletions src/lib/mapzoom.js
Original file line number Diff line number Diff line change
@@ -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); }
}
}
41 changes: 29 additions & 12 deletions src/styles/map.css
Original file line number Diff line number Diff line change
Expand Up @@ -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} }

Expand Down Expand Up @@ -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){
Expand Down Expand Up @@ -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}

Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions tests/mapzoom.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading