Map.tsx
⎇
Raw
1import { onMount, onCleanup, createEffect } from "solid-js";
2import L from "leaflet";
3import "leaflet/dist/leaflet.css";
4import type { PersonState } from "./api";
5
6// Straight to OSM for now. The caching proxy at /tiles/{z}/{x}/{y}.png lands in
7// step 14; swapping this string is the whole migration. Attribution is not
8// removable either way — it is a condition of the tile policy.
9const TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
10const ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
11
12interface Props {
13 people: Record<number, PersonState>;
14 order: () => number[];
15 selected: () => number | null;
16 onSelect: (id: number) => void;
17 trail: () => [number, number][] | null;
18}
19
20export default function MapView(props: Props) {
21 let container!: HTMLDivElement;
22
23 // Leaflet is imperative and Solid is fine-grained, so none of this may live in
24 // reactive state: the map is created once and driven by effects. Wrapping
25 // Leaflet in components that re-render is the one thing that must not happen.
26 let map: L.Map | undefined;
27 let trailLine: L.Polyline | undefined;
28 const markers = new Map<number, { dot: L.CircleMarker; halo: L.Circle }>();
29 let fitted = false;
30
31 onMount(() => {
32 map = L.map(container, { zoomControl: true }).setView([52.52, 13.405], 13);
33 L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTRIBUTION }).addTo(map);
34 });
35 onCleanup(() => map?.remove());
36
37 createEffect(() => {
38 const ids = props.order();
39 if (!map) return;
40
41 for (const id of ids) {
42 const person = props.people[id];
43 const pos = person?.position;
44 if (!pos) continue;
45 const latlng = L.latLng(pos.lat_e7 / 1e7, pos.lon_e7 / 1e7);
46 const colour = person.is_self ? "#1d6fd8" : "#d8461d";
47
48 let entry = markers.get(id);
49 if (!entry) {
50 const halo = L.circle(latlng, { radius: 0, color: colour, weight: 1, opacity: 0.35, fillOpacity: 0.08 }).addTo(map);
51 const dot = L.circleMarker(latlng, { radius: 7, color: "#fff", weight: 2, fillColor: colour, fillOpacity: 1 })
52 .addTo(map)
53 .on("click", () => props.onSelect(id));
54 entry = { dot, halo };
55 markers.set(id, entry);
56 }
57 entry.dot.setLatLng(latlng).setStyle({ fillColor: colour });
58 entry.dot.bindTooltip(person.display_name, { direction: "top", offset: [0, -8] });
59 entry.halo.setLatLng(latlng).setRadius(pos.acc_dm === null ? 0 : pos.acc_dm / 10);
60 }
61
62 for (const [id, entry] of markers) {
63 if (props.people[id]?.position) continue;
64 entry.dot.remove();
65 entry.halo.remove();
66 markers.delete(id);
67 }
68
69 // Fit once. Re-fitting on every poll would fight the user's panning.
70 if (!fitted && markers.size > 0) {
71 fitted = true;
72 map.fitBounds(L.latLngBounds([...markers.values()].map((m) => m.dot.getLatLng())).pad(0.3), { maxZoom: 16 });
73 }
74 });
75
76 createEffect(() => {
77 const points = props.trail();
78 if (!map) return;
79 trailLine?.remove();
80 trailLine = undefined;
81 if (points && points.length > 1) {
82 trailLine = L.polyline(points, { color: "#1d6fd8", weight: 3, opacity: 0.55 }).addTo(map);
83 }
84 });
85
86 // Recentre when the selection changes.
87 createEffect(() => {
88 const id = props.selected();
89 if (id === null || !map) return;
90 const entry = markers.get(id);
91 if (entry) map.panTo(entry.dot.getLatLng());
92 });
93
94 return <div class="map" ref={container} />;
95}
96