Files
Bendik Aagaard LynghaugandClaude Opus 5 4fd60293fa A field can be a place, and a question can be about one
`type: location` puts a pin on a map and stores {lat, lon, zoom}; a
record that holds one renders as a still, on a desk and in any
resource card. `place:` on a question is the counterpart to `event:`:
event makes a page an occasion with a time, place makes it somewhere,
shows the map under the question, and opens any location field on the
page there instead of on the globe.

Every tile and every still is fetched by portal and served from this
origin (src/maps.rs, MapLibre vendored in public/), so a page with a
map on it still talks to nobody but us: no third-party request, no
consent banner, and the token never leaves the server. MAPBOX_TOKEN
unset turns the whole thing off, the way GARAGE_* does for uploads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 21:17:28 +02:00

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