assets/emergence-map.js
import { projectEqualEarth, invertEqualEarth, localAreaFrame, projectLocalArea, invertLocalArea } from './emergence-projection.js';
import { mapGeometry } from './emergence-map-geometry.js';
// Country links and boundary notes remain the server-rendered, no-JS fallback.
export function enhanceMobileMap(world) {
const map = world.querySelector('.em-map'), full = map.viewBox.baseVal;
const original = { x: full.x, y: full.y, w: full.width, h: full.height };
const stage = document.createElement('div');
stage.className = 'em-map-window'; map.before(stage); stage.append(map);
map.setAttribute('tabindex', '0');
map.setAttribute('aria-description', 'Use arrow keys to move. Zoom controls follow the country selector.');
const geometry = mapGeometry(map);
const marks = [...map.querySelectorAll('a[data-map-name]')].map(a => ({
anchor: a, circle: a.querySelector('circle'), radius: Number(a.querySelector('circle').getAttribute('r')),
place: a.hasAttribute('data-map-place'),
lon: Number(a.dataset.mapLon), lat: Number(a.dataset.mapLat), href: a.getAttribute('href')
}));
let zoom = 1, view = { ...original }, baseView = { ...original }, focus = null;
let gesture, suppressUntil = 0, frame = 0, lastRadiusZoom, activeRegion = null;
const pointers = new Map(), clamp = (n, min, max) => Math.max(min, Math.min(n, max));
const localThreshold = 3;
const tools = document.createElement('div');
tools.className = 'em-map-tools'; tools.setAttribute('role', 'group'); tools.setAttribute('aria-label', 'Map zoom');
const button = (text, label, action) => {
const el = document.createElement('button');
el.type = 'button'; el.textContent = text; el.setAttribute('aria-label', label);
el.addEventListener('click', action); tools.append(el); return el;
};
const out = button('−', 'Zoom out', () => scale(zoom / 1.6));
button('World', 'Show the whole world', () => {
pointers.clear(); gesture = null; zoom = 1; focus = null; view = { ...original };
geometry.set(null, view); positionMarks(); map.classList.remove('em-map-local');
clearSelection(); select.value = '';
render();
});
const into = button('+', 'Zoom in', () => scale(zoom * 1.6));
const picker = document.createElement('div'); picker.className = 'em-map-picker';
const label = document.createElement('label');
label.className = 'em-visually-hidden'; label.id = 'em-map-country-label'; label.textContent = 'Country';
const select = document.createElement('select');
select.id = 'em-map-country'; select.setAttribute('aria-labelledby', label.id); label.htmlFor = select.id;
const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = 'Country'; select.append(placeholder);
map.querySelectorAll('a[data-map-name]:not([data-map-place])').forEach(a => {
const option = document.createElement('option');
option.value = a.getAttribute('href'); option.textContent = `${a.dataset.mapName} · ${a.dataset.mapSummary}`; select.append(option);
});
select.addEventListener('change', () => {
if (!select.value) return;
focusCountry(select.value);
location.hash = select.value;
const country = document.getElementById(select.value.slice(1));
if (country) {
country.open = true; country.closest('.em-country-list').open = true;
requestAnimationFrame(() => country.scrollIntoView({ block: 'start' }));
}
});
picker.append(label, select, tools); world.before(picker);
const hint = document.createElement('span'); hint.className = 'em-map-hint'; world.querySelector('figcaption').append(hint);
const legendLink = world.querySelector('.em-map-legend-link'), legendText = legendLink.textContent;
function clearSelection() {
activeRegion = null;
map.querySelectorAll('.em-region-selected').forEach(el => el.classList.remove('em-region-selected'));
}
function focusCountry(href) {
const mark = marks.find(mark => mark.href === href);
if (!mark) return;
clearSelection(); select.value = href; zoom = Math.max(zoom, 4.096);
resizeView(); changeProjection(mark.lon, mark.lat); render();
}
const inverse = (x, y) => focus ? invertLocalArea(x, y, focus) : invertEqualEarth(x, y);
function dimensions() {
const rect = stage.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const w = Math.max(original.w, original.h * rect.width / rect.height) / zoom;
return { rect, w, h: w * rect.height / rect.width };
}
function resizeView() {
const dims = dimensions(); if (!dims) return null;
const cx = view.x + view.w / 2, cy = view.y + view.h / 2;
view.w = dims.w; view.h = dims.h;
if (focus) { view.x = cx - view.w / 2; view.y = cy - view.h / 2; }
else {
// A tall phone can contain extra ocean above/below the globe. Do not
// pin its centre to the equator when a gesture targets a northern place.
view.x = (zoom === 1 ? original.w / 2 : clamp(cx, 0, original.w)) - view.w / 2;
view.y = (zoom === 1 ? original.h / 2 : clamp(cy, 0, original.h)) - view.h / 2;
}
return dims;
}
function positionMarks() {
const dotsRecede = zoom > 3;
for (const mark of marks) {
const [x, y] = focus ? projectLocalArea(mark.lon, mark.lat, focus) : projectEqualEarth(mark.lon, mark.lat);
mark.circle.setAttribute('cx', x.toFixed(2)); mark.circle.setAttribute('cy', y.toFixed(2));
if (mark.anchor) mark.anchor.classList.toggle('em-far', dotsRecede && !mark.place);
}
}
function changeProjection(lon, lat) {
focus = zoom >= localThreshold ? localAreaFrame(lon, lat) : null;
const [x, y] = focus ? projectLocalArea(lon, lat, focus) : projectEqualEarth(lon, lat);
view.x = x - view.w / 2; view.y = y - view.h / 2;
resizeView(); geometry.set(focus, view); positionMarks(); map.classList.toggle('em-map-local', !!focus);
}
function maskViewport() {
const mask = map.querySelector('#em-attention-mask'), rect = mask.querySelector('rect');
for (const el of [mask, rect]) {
el.setAttribute('x', focus ? (view.x - view.w / 2).toFixed(2) : '0');
el.setAttribute('y', focus ? (view.y - view.h / 2).toFixed(2) : '0');
el.setAttribute('width', focus ? (view.w * 2).toFixed(2) : original.w);
el.setAttribute('height', focus ? (view.h * 2).toFixed(2) : original.h);
}
}
function render() {
if (frame) cancelAnimationFrame(frame);
frame = 0;
const dims = resizeView(); if (!dims) return;
// Move an existing SVG surface during gestures; commit the viewBox when
// fingers lift. Pointer events are coalesced to one update per frame.
if (pointers.size && gesture?.moved) {
const k = baseView.w / view.w;
const tx = (baseView.x - view.x) / baseView.w * dims.rect.width * k;
const ty = (baseView.y - view.y) / baseView.h * dims.rect.height * k;
map.style.transform = `translate3d(${tx}px,${ty}px,0) scale(${k})`; map.classList.add('em-map-moving');
} else {
map.style.transform = ''; map.classList.remove('em-map-moving'); maskViewport();
map.setAttribute('viewBox', [view.x, view.y, view.w, view.h].map(n => n.toFixed(2)).join(' ')); baseView = { ...view };
}
if (lastRadiusZoom !== zoom) {
// Keep relative coverage sizes, but reduce screen size at close zoom.
for (const mark of marks) {
const decayed = mark.radius / (zoom * (1 + Math.log2(zoom) * .12));
mark.circle.setAttribute('r', Math.max(decayed, mark.place ? 0.28 : 0.1).toFixed(3));
}
map.querySelector('#em-dispute-hatch').setAttribute('patternTransform', `scale(${1 / zoom})`);
lastRadiusZoom = zoom;
}
map.classList.toggle('em-map-close', zoom > 1.5);
out.disabled = zoom <= 1; into.disabled = zoom >= 16;
const hintText = focus ? 'Local equal-area · drag to move' : 'Equal Earth · drag to move';
if (hint.textContent !== hintText) hint.textContent = hintText;
const mapLabel = `${focus ? 'Local equal-area' : 'Equal Earth'} attention map. Country counts describe reporting, not sovereignty. Hatched areas have boundary or status notes.`;
if (map.getAttribute('aria-label') !== mapLabel) map.setAttribute('aria-label', mapLabel);
const linkText = activeRegion ? `${activeRegion.name} · status & sources` : legendText;
if (legendLink.textContent !== linkText) legendLink.textContent = linkText;
legendLink.setAttribute('href', activeRegion ? `#map-region-${activeRegion.id}` : '#map-boundary-notes');
}
function schedule() { if (!frame) frame = requestAnimationFrame(render); }
function scale(next) {
const [lon, lat] = inverse(view.x + view.w / 2, view.y + view.h / 2);
zoom = clamp(next, 1, 16); resizeView();
if (!!focus !== (zoom >= localThreshold)) changeProjection(lon, lat);
render();
}
function settle() {
if (zoom >= localThreshold || focus) {
const [lon, lat] = inverse(view.x + view.w / 2, view.y + view.h / 2); changeProjection(lon, lat);
}
render();
}
function startGesture() {
gesture = { points: [...pointers.values()].map(p => ({ ...p })), view: { ...view }, zoom, moved: pointers.size > 1 || !!gesture?.moved };
}
map.classList.add('em-map-touch');
map.addEventListener('pointerdown', event => {
if (event.button !== 0) return;
const preview = world.querySelector('.em-tile-preview');
if (preview) preview.hidden = true;
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
event.target.setPointerCapture?.(event.pointerId); startGesture();
});
map.addEventListener('pointermove', event => {
if (!pointers.has(event.pointerId) || !gesture) return;
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
const current = [...pointers.values()], start = gesture.points, rect = stage.getBoundingClientRect();
const midpoint = points => ({ x: (points[0].x + (points[1]?.x ?? points[0].x)) / 2, y: (points[0].y + (points[1]?.y ?? points[0].y)) / 2 });
const a = midpoint(start), b = midpoint(current);
if (current.length > 1) {
const distance = points => Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
zoom = clamp(gesture.zoom * distance(current) / Math.max(1, distance(start)), 1, 16); gesture.moved = true;
} else if (Math.hypot(b.x - a.x, b.y - a.y) > 6) gesture.moved = true;
if (!gesture.moved) return;
if (activeRegion) clearSelection();
const w = Math.max(original.w, original.h * rect.width / rect.height) / zoom, h = w * rect.height / rect.width;
const anchorX = gesture.view.x + (a.x - rect.left) / rect.width * gesture.view.w;
const anchorY = gesture.view.y + (a.y - rect.top) / rect.height * gesture.view.h;
view = { x: anchorX - (b.x - rect.left) / rect.width * w, y: anchorY - (b.y - rect.top) / rect.height * h, w, h }; schedule();
});
const finish = event => {
if (!pointers.has(event.pointerId)) return;
if (gesture?.moved) suppressUntil = performance.now() + 500;
pointers.delete(event.pointerId);
if (pointers.size) { render(); startGesture(); }
else { gesture = null; settle(); }
};
map.addEventListener('pointerup', finish); map.addEventListener('pointercancel', finish);
map.addEventListener('click', event => {
if (event.detail && performance.now() < suppressUntil) { event.preventDefault(); event.stopImmediatePropagation(); }
}, true);
map.addEventListener('click', event => {
const country = event.target.closest('a[href^="#country-"]');
if (country && !event.defaultPrevented) focusCountry(country.getAttribute('href'));
});
map.addEventListener('keydown', event => {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const moves = { ArrowLeft: [-1,0], ArrowRight: [1,0], ArrowUp: [0,-1], ArrowDown: [0,1] };
if (!moves[event.key]) return;
event.preventDefault(); clearSelection(); view.x += moves[event.key][0] * view.w / 5; view.y += moves[event.key][1] * view.h / 5; settle();
});
document.querySelectorAll('[data-map-bounds]').forEach(button => {
button.hidden = false;
button.addEventListener('click', () => {
const [left, top, right, bottom] = button.dataset.mapBounds.split(',').map(Number), rect = stage.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const fitWidth = Math.max(original.w, original.h * rect.width / rect.height);
zoom = clamp(fitWidth / Math.max(30, (right - left) * 1.7, (bottom - top) * 1.7 * rect.width / rect.height), 1, 16);
const [lon, lat] = invertEqualEarth((left + right) / 2, (top + bottom) / 2);
resizeView(); changeProjection(lon, lat);
map.querySelectorAll('.em-region-selected').forEach(el => el.classList.remove('em-region-selected'));
const region = [...map.querySelectorAll('[data-region]')].find(el => el.dataset.region === button.dataset.mapRegion);
activeRegion = { id: button.dataset.mapRegion, name: button.closest('.em-region-note').querySelector('summary>span').textContent };
region?.classList.add('em-region-selected'); render();
location.hash = '#map'; document.getElementById('map').scrollTop = 0;
requestAnimationFrame(() => region?.focus({ preventScroll: true }));
});
});
new ResizeObserver(() => { pointers.clear(); gesture = null; settle(); }).observe(stage);
render();
}