129 lines
4.2 KiB
JavaScript
129 lines
4.2 KiB
JavaScript
// 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);
|
||
|
|
}
|