| 1 | "use client"; |
| 2 | import { useEffect, useState } from "react"; |
| 3 | import { MapContainer, TileLayer, useMap, CircleMarker, Popup, Polyline } from "react-leaflet"; |
| 4 | import L from "leaflet"; |
| 5 | import "leaflet/dist/leaflet.css"; |
| 6 | import "leaflet.markercluster/dist/MarkerCluster.css"; |
| 7 | import "leaflet.markercluster/dist/MarkerCluster.Default.css"; |
| 8 | import "leaflet.markercluster"; |
| 9 | import { api, MapCamera, SatelliteCam } from "@/lib/api"; |
| 10 | |
| 11 | function opColor(op: string | null): string { |
| 12 | const o = (op || "").toLowerCase(); |
| 13 | if (o.includes("flock")) return "#2dd4bf"; |
| 14 | if (o.includes("motorola")) return "#fbbf24"; |
| 15 | if (o.includes("genetec")) return "#a78bfa"; |
| 16 | if (o.includes("police") || o.includes("sheriff")) return "#38bdf8"; |
| 17 | return "#61708a"; |
| 18 | } |
| 19 | |
| 20 | function CameraClusters({ cameras }: { cameras: MapCamera[] }) { |
| 21 | const map = useMap(); |
| 22 | useEffect(() => { |
| 23 | const cluster = L.markerClusterGroup({ chunkedLoading: true, maxClusterRadius: 55, |
| 24 | spiderfyOnMaxZoom: true, showCoverageOnHover: false }); |
| 25 | for (const c of cameras) { |
| 26 | const m = L.circleMarker([c.lat, c.lon], { radius: 4, color: opColor(c.operator), |
| 27 | fillColor: opColor(c.operator), fillOpacity: 0.85, weight: 1 }); |
| 28 | m.bindPopup(`<b>${c.name || "Camera"}</b><br/>Operator: ${c.operator || "-"}<br/>` |
| 29 | + `State: ${c.state || "-"} · Zone: ${c.zone || "-"}<br/>` |
| 30 | + `<a href="/cameras/${c.camera_id}">Open camera →</a>`); |
| 31 | cluster.addLayer(m); |
| 32 | } |
| 33 | map.addLayer(cluster); |
| 34 | return () => { map.removeLayer(cluster); }; |
| 35 | }, [cameras, map]); |
| 36 | return null; |
| 37 | } |
| 38 | |
| 39 | export default function IntelMap({ stateFilter, operatorFilter }: |
| 40 | { stateFilter?: string; operatorFilter?: string }) { |
| 41 | const [cameras, setCameras] = useState<MapCamera[]>([]); |
| 42 | const [sats, setSats] = useState<SatelliteCam[]>([]); |
| 43 | const [track, setTrack] = useState<{ id: string; pts: [number, number][] } | null>(null); |
| 44 | const [err, setErr] = useState<string | null>(null); |
| 45 | const [showSats, setShowSats] = useState(true); |
| 46 | |
| 47 | useEffect(() => { |
| 48 | api.cameraMap({ state: stateFilter, operator: operatorFilter }) |
| 49 | .then((r) => setCameras(r.cameras)) |
| 50 | .catch((e) => setErr(e.status === 401 ? "Sign in to view the map." : "Map load failed.")); |
| 51 | api.satellites().then((r) => setSats(r.satellites)).catch(() => {}); |
| 52 | }, [stateFilter, operatorFilter]); |
| 53 | |
| 54 | useEffect(() => { |
| 55 | const t = setInterval(() => api.satellites().then((r) => setSats(r.satellites)).catch(() => {}), 30000); |
| 56 | return () => clearInterval(t); |
| 57 | }, []); |
| 58 | |
| 59 | async function showTrack(id: string) { |
| 60 | try { |
| 61 | const t = await api.entityTrack(id); |
| 62 | setTrack({ id, pts: t.track.map((p) => [p.lat, p.lon]) }); |
| 63 | } catch { } |
| 64 | } |
| 65 | |
| 66 | if (err) return <div className="card muted">{err}</div>; |
| 67 | |
| 68 | return ( |
| 69 | <div style={{ position: "relative" }}> |
| 70 | <MapContainer center={[39.5, -98]} zoom={4} style={{ height: "72vh", width: "100%", borderRadius: 12 }} |
| 71 | preferCanvas> |
| 72 | <TileLayer url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" |
| 73 | attribution='© OpenStreetMap © CARTO' /> |
| 74 | <CameraClusters cameras={cameras} /> |
| 75 | {showSats && sats.filter((s) => s.lat != null).map((s) => ( |
| 76 | <CircleMarker key={s.identifier} center={[s.lat!, s.lon!]} radius={5} |
| 77 | pathOptions={{ color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.9, weight: 1 }} |
| 78 | eventHandlers={{ click: () => showTrack(s.identifier) }}> |
| 79 | <Popup> |
| 80 | <b>🛰 {s.label}</b><br />Imaging satellite ({s.constellation})<br /> |
| 81 | Alt: {s.altitude_km} km · Period: {s.period_min ? Math.round(s.period_min) : "-"} min<br /> |
| 82 | <span className="muted">click to plot ground track</span> |
| 83 | </Popup> |
| 84 | </CircleMarker> |
| 85 | ))} |
| 86 | {track && <Polyline positions={track.pts} pathOptions={{ color: "#fbbf24", weight: 1.5, dashArray: "4 4" }} />} |
| 87 | </MapContainer> |
| 88 | <div style={{ position: "absolute", top: 12, right: 12, zIndex: 1000 }} className="card"> |
| 89 | <div className="row" style={{ gap: 8, marginBottom: 8 }}> |
| 90 | <strong style={{ fontSize: 12 }}>{cameras.length.toLocaleString()} cameras</strong> |
| 91 | </div> |
| 92 | <label className="row" style={{ gap: 6, fontSize: 12 }}> |
| 93 | <input type="checkbox" checked={showSats} onChange={(e) => setShowSats(e.target.checked)} style={{ width: 14 }} /> |
| 94 | {sats.length} satellite cameras |
| 95 | </label> |
| 96 | <div style={{ marginTop: 8, fontSize: 11 }} className="muted"> |
| 97 | <div><span style={{ color: "#2dd4bf" }}>●</span> Flock <span style={{ color: "#fbbf24" }}>●</span> Motorola/Sat</div> |
| 98 | <div><span style={{ color: "#a78bfa" }}>●</span> Genetec <span style={{ color: "#38bdf8" }}>●</span> Police</div> |
| 99 | </div> |
| 100 | </div> |
| 101 | </div> |
| 102 | ); |
| 103 | } |