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