source.fact.ngo a coherence.ngo project

assets/emergence-map.js

raw ↗ · AGPL-3.0

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(); }