● Zion Boggan
repos/Sightline/frontend/src/components/IntelMap.tsx
zionboggan.com ↗
103 lines · tsx
History for this file →
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='&copy; OpenStreetMap &copy; 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 &nbsp; <span style={{ color: "#fbbf24" }}>●</span> Motorola/Sat</div>
98
          <div><span style={{ color: "#a78bfa" }}>●</span> Genetec &nbsp; <span style={{ color: "#38bdf8" }}>●</span> Police</div>
99
        </div>
100
      </div>
101
    </div>
102
  );
103
}