// Glue for `type: location` requirement fields (see AlternativeCard in // src/app.rs) - the same hidden-input bridge every widget kind uses, // and the same typed handle with stop() that gesture.js and voice.js // use so SPA navigation tears the map down. // // The pin is the centre of the map, not a draggable marker: panning // works the same with a finger, a mouse or the arrow keys, and there // is nothing to miss by a few pixels. Whatever the map is looking at // when it stops moving is the answer, written into the paired hidden // as {"lat","lon","zoom"} with a real `input` event, so the // Rust side reuses the signal wiring every other field already has. // // MapLibre is vendored and served from this origin (/maplibre-gl.js), // and every tile comes from this origin too (/map/tiles/...), so a // page with a map on it still talks to nobody but us. const WAIT_MS = 15000; async function maplibre() { const started = Date.now(); while (!window.maplibregl) { if (Date.now() - started > WAIT_MS) throw new Error('maplibre-gl did not load'); await new Promise((r) => setTimeout(r, 50)); } return window.maplibregl; } // The value as stored: five decimals is about a metre, which is as // much as a pin on a map means. function place(map) { const c = map.getCenter(); return { lat: Number(c.lat.toFixed(5)), lon: Number(c.lng.toFixed(5)), zoom: Number(map.getZoom().toFixed(2)), }; } // Where to open: what the field already holds, else what the content // gave as `value` ("lat,lon,zoom"), else the whole world, which is // honest about knowing nothing rather than guessing a country. function opening(hidden, initial) { try { const held = JSON.parse(hidden.value || 'null'); if (held && typeof held.lat === 'number' && typeof held.lon === 'number') { return { center: [held.lon, held.lat], zoom: held.zoom ?? 13, pinned: true }; } } catch {} const parts = String(initial || '').split(',').map((p) => Number(p.trim())); if (parts.length >= 2 && parts.every((n) => Number.isFinite(n))) { return { center: [parts[1], parts[0]], zoom: parts[2] ?? 13, pinned: false }; } return { center: [0, 20], zoom: 1, pinned: false }; } class MapWidget { constructor(container, hidden, initial) { this.container = container; this.map = null; this.dead = false; this.start(container, hidden, initial); } async start(container, hidden, initial) { let gl; try { gl = await maplibre(); } catch (e) { container.textContent = 'The map could not be loaded. ' + (hidden.value ? 'Your saved place is kept.' : ''); return; } if (this.dead) return; const at = opening(hidden, initial); const canvas = document.createElement('div'); canvas.className = 'map-canvas'; const pin = document.createElement('div'); pin.className = 'map-pin'; pin.setAttribute('aria-hidden', 'true'); const readout = document.createElement('p'); readout.className = 'map-readout'; container.append(canvas, pin, readout); const map = new gl.Map({ container: canvas, style: '/map/style.json', center: at.center, zoom: at.zoom, attributionControl: { compact: true }, }); this.map = map; map.addControl(new gl.NavigationControl({ showCompass: false }), 'top-right'); map.addControl( new gl.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: false }), 'top-right', ); // Nothing is answered until the map has been moved, or was opened // on an answer it already had: an untouched map means "no place", // not "the middle of the ocean". let answered = at.pinned; const write = () => { if (!answered) return; const value = place(map); hidden.value = JSON.stringify(value); hidden.dispatchEvent(new Event('input', { bubbles: true })); readout.textContent = `${value.lat}, ${value.lon}`; }; map.on('moveend', () => { answered = true; write(); }); map.on('load', write); } stop() { this.dead = true; if (this.map) { this.map.remove(); this.map = null; } this.container.replaceChildren(); } } export function mountMap(container, hidden, initial) { return new MapWidget(container, hidden, initial); }