import { onMount, onCleanup, createEffect } from "solid-js";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import type { PersonState } from "./api";
// Tiles go through our own caching proxy, not straight to OSM. Same origin, so
// the session cookie rides along and no browser ever talks to tile.openstreetmap
// .org — the upstream sees one server, not every user's IP. Attribution stays:
// proxying changes who fetches the tiles, not who made them, and showing it is a
// condition of the OSM tile usage policy.
const TILE_URL = "/tiles/{z}/{x}/{y}";
const ATTRIBUTION = '© OpenStreetMap contributors';
interface Props {
people: Record;
order: () => number[];
selected: () => number | null;
onSelect: (id: number) => void;
trail: () => [number, number][] | null;
}
export default function MapView(props: Props) {
let container!: HTMLDivElement;
// Leaflet is imperative and Solid is fine-grained, so none of this may live in
// reactive state: the map is created once and driven by effects. Wrapping
// Leaflet in components that re-render is the one thing that must not happen.
let map: L.Map | undefined;
let trailLine: L.Polyline | undefined;
const markers = new Map();
let fitted = false;
onMount(() => {
map = L.map(container, { zoomControl: true }).setView([52.52, 13.405], 13);
L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTRIBUTION }).addTo(map);
});
onCleanup(() => map?.remove());
createEffect(() => {
const ids = props.order();
if (!map) return;
for (const id of ids) {
const person = props.people[id];
const pos = person?.position;
if (!pos) continue;
const latlng = L.latLng(pos.lat_e7 / 1e7, pos.lon_e7 / 1e7);
const colour = person.is_self ? "#1d6fd8" : "#d8461d";
let entry = markers.get(id);
if (!entry) {
const halo = L.circle(latlng, { radius: 0, color: colour, weight: 1, opacity: 0.35, fillOpacity: 0.08 }).addTo(map);
const dot = L.circleMarker(latlng, { radius: 7, color: "#fff", weight: 2, fillColor: colour, fillOpacity: 1 })
.addTo(map)
.on("click", () => props.onSelect(id));
entry = { dot, halo };
markers.set(id, entry);
}
entry.dot.setLatLng(latlng).setStyle({ fillColor: colour });
entry.dot.bindTooltip(person.display_name, { direction: "top", offset: [0, -8] });
entry.halo.setLatLng(latlng).setRadius(pos.acc_dm === null ? 0 : pos.acc_dm / 10);
}
for (const [id, entry] of markers) {
if (props.people[id]?.position) continue;
entry.dot.remove();
entry.halo.remove();
markers.delete(id);
}
// Fit once. Re-fitting on every poll would fight the user's panning.
if (!fitted && markers.size > 0) {
fitted = true;
map.fitBounds(L.latLngBounds([...markers.values()].map((m) => m.dot.getLatLng())).pad(0.3), { maxZoom: 16 });
}
});
createEffect(() => {
const points = props.trail();
if (!map) return;
trailLine?.remove();
trailLine = undefined;
if (points && points.length > 1) {
trailLine = L.polyline(points, { color: "#1d6fd8", weight: 3, opacity: 0.55 }).addTo(map);
}
});
// Recentre when the selection changes.
createEffect(() => {
const id = props.selected();
if (id === null || !map) return;
const entry = markers.get(id);
if (entry) map.panTo(entry.dot.getLatLng());
});
return ;
}