Files
portal/map.js
T

129 lines
4.2 KiB
JavaScript
Raw Normal View History

// 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
// <input> 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);
}