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