Spaces:
Paused
Paused
live Leaflet map with status markers and SOS pulse
Browse files
web/src/components/MapView.jsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from 'react';
|
| 2 |
+
import L from 'leaflet';
|
| 3 |
+
|
| 4 |
+
export default function MapView({ members, groups, sos }) {
|
| 5 |
+
const elRef = useRef(null);
|
| 6 |
+
const mapRef = useRef(null);
|
| 7 |
+
const markersRef = useRef(new Map());
|
| 8 |
+
const didFit = useRef(false);
|
| 9 |
+
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
const map = L.map(elRef.current, { center: [48.8566, 2.3522], zoom: 12 });
|
| 12 |
+
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
| 13 |
+
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
| 14 |
+
maxZoom: 19,
|
| 15 |
+
}).addTo(map);
|
| 16 |
+
mapRef.current = map;
|
| 17 |
+
return () => map.remove();
|
| 18 |
+
}, []);
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
const map = mapRef.current;
|
| 22 |
+
if (!map) return;
|
| 23 |
+
|
| 24 |
+
const sosMembers = new Set(sos.filter((a) => a.state !== 'resolved').map((a) => a.member_id));
|
| 25 |
+
const colorOf = (gid) => groups.find((g) => g.id === gid)?.color || '#64748b';
|
| 26 |
+
const positioned = members.filter((m) => m.lat != null && m.lng != null);
|
| 27 |
+
const seen = new Set();
|
| 28 |
+
|
| 29 |
+
for (const m of positioned) {
|
| 30 |
+
seen.add(m.id);
|
| 31 |
+
const isSos = sosMembers.has(m.id);
|
| 32 |
+
const icon = L.divIcon({
|
| 33 |
+
className: 'marker-wrap',
|
| 34 |
+
html: `<div class="marker ${m.status} ${isSos ? 'sos' : ''}" style="--c:${colorOf(m.group_id)}"></div>`,
|
| 35 |
+
iconSize: [16, 16],
|
| 36 |
+
iconAnchor: [8, 8],
|
| 37 |
+
});
|
| 38 |
+
let mk = markersRef.current.get(m.id);
|
| 39 |
+
if (!mk) {
|
| 40 |
+
mk = L.marker([m.lat, m.lng], { icon }).addTo(map);
|
| 41 |
+
markersRef.current.set(m.id, mk);
|
| 42 |
+
} else {
|
| 43 |
+
mk.setLatLng([m.lat, m.lng]);
|
| 44 |
+
mk.setIcon(icon);
|
| 45 |
+
}
|
| 46 |
+
mk.bindPopup(
|
| 47 |
+
`<b>${m.name}</b>${m.callsign ? ` · ${m.callsign}` : ''}<br/>` +
|
| 48 |
+
`${m.group_name || 'no group'} · ${m.status}<br/>` +
|
| 49 |
+
`battery ${m.battery != null ? m.battery + '%' : '–'}<br/>` +
|
| 50 |
+
`${m.lat.toFixed(5)}, ${m.lng.toFixed(5)}`
|
| 51 |
+
);
|
| 52 |
+
}
|
| 53 |
+
for (const [id, mk] of markersRef.current) {
|
| 54 |
+
if (!seen.has(id)) {
|
| 55 |
+
map.removeLayer(mk);
|
| 56 |
+
markersRef.current.delete(id);
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
if (!didFit.current && positioned.length) {
|
| 60 |
+
didFit.current = true;
|
| 61 |
+
map.fitBounds(L.latLngBounds(positioned.map((m) => [m.lat, m.lng])).pad(0.3));
|
| 62 |
+
}
|
| 63 |
+
}, [members, groups, sos]);
|
| 64 |
+
|
| 65 |
+
return <div ref={elRef} className="map" />;
|
| 66 |
+
}
|