Spaces:
Paused
Paused
map with selection + trail
Browse files- web/src/components/MapView.jsx +30 -13
web/src/components/MapView.jsx
CHANGED
|
@@ -1,52 +1,54 @@
|
|
| 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
|
| 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
|
| 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
|
| 32 |
const icon = L.divIcon({
|
| 33 |
-
className: '
|
| 34 |
-
html: `<div class="
|
| 35 |
-
iconSize: [
|
| 36 |
-
iconAnchor: [
|
| 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.
|
| 49 |
-
`battery ${m.battery != null ? m.battery + '%' : '–'}<br/>` +
|
| 50 |
`${m.lat.toFixed(5)}, ${m.lng.toFixed(5)}`
|
| 51 |
);
|
| 52 |
}
|
|
@@ -60,7 +62,22 @@ export default function MapView({ members, groups, sos }) {
|
|
| 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 |
}
|
|
|
|
| 1 |
import { useEffect, useRef } from 'react';
|
| 2 |
import L from 'leaflet';
|
| 3 |
+
import { api } from '../api.js';
|
| 4 |
|
| 5 |
+
export default function MapView({ members, groups, sos, selected, onSelect }) {
|
| 6 |
const elRef = useRef(null);
|
| 7 |
const mapRef = useRef(null);
|
| 8 |
const markersRef = useRef(new Map());
|
| 9 |
+
const trailRef = useRef(null);
|
| 10 |
const didFit = useRef(false);
|
| 11 |
|
| 12 |
useEffect(() => {
|
| 13 |
+
const map = L.map(elRef.current).setView([48.8566, 2.3522], 12);
|
| 14 |
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
| 15 |
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
| 16 |
maxZoom: 19,
|
| 17 |
}).addTo(map);
|
| 18 |
mapRef.current = map;
|
| 19 |
+
return () => { map.remove(); mapRef.current = null; };
|
| 20 |
}, []);
|
| 21 |
|
| 22 |
useEffect(() => {
|
| 23 |
const map = mapRef.current;
|
| 24 |
if (!map) return;
|
| 25 |
|
| 26 |
+
const sosIds = new Set(sos.filter((a) => a.state !== 'resolved').map((a) => a.member_id));
|
| 27 |
const colorOf = (gid) => groups.find((g) => g.id === gid)?.color || '#64748b';
|
| 28 |
const positioned = members.filter((m) => m.lat != null && m.lng != null);
|
| 29 |
const seen = new Set();
|
| 30 |
|
| 31 |
for (const m of positioned) {
|
| 32 |
seen.add(m.id);
|
| 33 |
+
const cls = `pin ${m.status === 'online' ? 'online' : 'offline'} ${sosIds.has(m.id) ? 'sos' : ''}`;
|
| 34 |
const icon = L.divIcon({
|
| 35 |
+
className: 'pin-wrap',
|
| 36 |
+
html: `<div class="${cls}" style="background:${colorOf(m.group_id)}">${m.name.slice(0, 2).toUpperCase()}</div>`,
|
| 37 |
+
iconSize: [26, 26],
|
| 38 |
+
iconAnchor: [13, 13],
|
| 39 |
});
|
| 40 |
let mk = markersRef.current.get(m.id);
|
| 41 |
if (!mk) {
|
| 42 |
mk = L.marker([m.lat, m.lng], { icon }).addTo(map);
|
| 43 |
+
mk.on('click', () => onSelect(m.id));
|
| 44 |
markersRef.current.set(m.id, mk);
|
| 45 |
} else {
|
| 46 |
mk.setLatLng([m.lat, m.lng]);
|
| 47 |
mk.setIcon(icon);
|
| 48 |
}
|
| 49 |
mk.bindPopup(
|
| 50 |
+
`<b>${m.name}</b>${m.callsign ? ` · ${m.callsign}` : ''} · ${m.group_name || 'no group'}<br/>` +
|
| 51 |
+
`${m.status} · battery ${m.battery != null ? m.battery + '%' : '–'}<br/>` +
|
|
|
|
| 52 |
`${m.lat.toFixed(5)}, ${m.lng.toFixed(5)}`
|
| 53 |
);
|
| 54 |
}
|
|
|
|
| 62 |
didFit.current = true;
|
| 63 |
map.fitBounds(L.latLngBounds(positioned.map((m) => [m.lat, m.lng])).pad(0.3));
|
| 64 |
}
|
| 65 |
+
}, [members, groups, sos, onSelect]);
|
| 66 |
+
|
| 67 |
+
// GPS trail of the selected member
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
const map = mapRef.current;
|
| 70 |
+
if (!map) return;
|
| 71 |
+
trailRef.current?.remove();
|
| 72 |
+
trailRef.current = null;
|
| 73 |
+
if (!selected) return;
|
| 74 |
+
let cancelled = false;
|
| 75 |
+
api.get(`/members/${selected}/trail`).then((rows) => {
|
| 76 |
+
if (cancelled || rows.length < 2) return;
|
| 77 |
+
trailRef.current = L.polyline(rows.map((r) => [r.lat, r.lng]), { color: '#4fc3f7', weight: 3, opacity: 0.7 }).addTo(map);
|
| 78 |
+
}).catch(() => {});
|
| 79 |
+
return () => { cancelled = true; };
|
| 80 |
+
}, [selected]);
|
| 81 |
|
| 82 |
return <div ref={elRef} className="map" />;
|
| 83 |
}
|