import { useEffect, useMemo, useState } from "react"; import { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents, ZoomControl, } from "react-leaflet"; import L from "leaflet"; import { Flame, Waves, ShieldAlert, Users, Building2, Crosshair, } from "lucide-react"; import { api } from "@/lib/api"; import type { AreaRow, HotspotCell, MapData } from "@/lib/types"; import { HeatLayer } from "@/components/HeatLayer"; import { Spinner, ErrorState } from "@/components/ui/feedback"; import { cn } from "@/lib/utils"; import { pct, minutesToHuman, titleCase, num } from "@/lib/format"; const CARTO_DARK = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"; const CARTO_LIGHT = "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"; type View = "congestion" | "hotspots" | "closure" | "manpower"; const VIEWS: { id: View; label: string; icon: typeof Flame; desc: string; }[] = [ { id: "congestion", label: "Congestion", icon: Waves, desc: "Event density across the city" }, { id: "hotspots", label: "Chronic hotspots", icon: Flame, desc: "~110 m cells likely to reoffend" }, { id: "closure", label: "Closure risk", icon: ShieldAlert, desc: "Where closures/diversions cluster" }, { id: "manpower", label: "Manpower load", icon: Users, desc: "Predicted officer demand" }, ]; const GRADIENTS: Record> = { congestion: { 0.2: "#2f4a63", 0.45: "#3f6f93", 0.75: "#5e9cc0", 1: "#a7cfe6" }, hotspots: { 0.2: "#4f9e7d", 0.5: "#cfa247", 0.8: "#db8a52", 1: "#d35f55" }, closure: { 0.2: "#4f9e7d", 0.5: "#cfa247", 0.8: "#db8a52", 1: "#d35f55" }, manpower: { 0.2: "#4f8fa0", 0.5: "#5ea585", 0.8: "#db8a52", 1: "#d35f55" }, }; function riskColor(r: number): string { if (r >= 0.6) return "#d35f55"; if (r >= 0.3) return "#db8a52"; if (r >= 0.12) return "#cfa247"; return "#4f9e7d"; } function FitBounds({ bounds }: { bounds: MapData["bounds"] }) { const map = useMap(); useEffect(() => { map.fitBounds( [ [bounds.min_lat, bounds.min_lng], [bounds.max_lat, bounds.max_lng], ], { padding: [30, 30] } ); }, [map, bounds]); return null; } function RecenterButton({ bounds }: { bounds: MapData["bounds"] }) { const map = useMap(); return ( ); } /** * Fixed pixel-radius circles render identically at every zoom, so the city * overview collapses into one cluttered blob. Scaling the radius with the zoom * level keeps the map tidy when zoomed out and legible when zoomed in. */ function zoomScale(zoom: number): number { return Math.min(Math.max(2 ** ((zoom - 13) * 0.42), 0.5), 2.1); } function useZoomScale(): number { const map = useMap(); const [zoom, setZoom] = useState(() => map.getZoom()); useMapEvents({ zoomend: () => setZoom(map.getZoom()) }); return zoomScale(zoom); } function HotspotMarkers({ cells }: { cells: HotspotCell[] }) { const scale = useZoomScale(); return ( <> {cells.map((c, i) => (
{c.label}
{c.junction && (
{titleCase(c.junction)}
)}
Events {c.count} Max risk {pct(c.max_risk)} Chronic {c.chronic_count} Closure rate {pct(c.closure_rate)} {c.top_cause && ( <> Top cause {titleCase(c.top_cause)} )}
))} ); } function StationMarkers({ areas }: { areas: AreaRow[] }) { const scale = useZoomScale(); return ( <> {areas.map((a, i) => (
{a.area}
Events {a.n_events} Risk score {a.risk_score} Closure rate {pct(a.closure_rate)} Avg clear {minutesToHuman(a.avg_duration_min)} Avg officers {num(a.avg_officers, 1)}
))} ); } export default function MapView() { const [map, setMap] = useState(null); const [areas, setAreas] = useState([]); const [view, setView] = useState("hotspots"); const [showStations, setShowStations] = useState(false); const [dark, setDark] = useState(true); const [error, setError] = useState(null); useEffect(() => { Promise.all([api.map(), api.areas()]) .then(([m, a]) => { setMap(m); setAreas(a); }) .catch((e) => setError((e as Error).message)); }, []); const heatPoints = useMemo(() => { if (!map || view === "hotspots") return []; return map.points.map((p) => { const [lat, lng, closure, , officers] = p; let w = 0.4; if (view === "congestion") w = 0.5; else if (view === "closure") w = Math.max(0.1, closure); else if (view === "manpower") w = Math.max(0.1, officers / 6); return [lat, lng, w] as L.HeatLatLngTuple; }); }, [map, view]); if (error) return ; const activeView = VIEWS.find((v) => v.id === view)!; // Default to Bengaluru's centre so the basemap tiles can start loading before // the precomputed JSON arrives — the map feels instant, data fills in after. const center: [number, number] = map ? [map.bounds.center_lat, map.bounds.center_lng] : [12.9716, 77.5946]; return (

Bengaluru risk map

{activeView.desc} {map && ` · ${map.points.length.toLocaleString()} events · ${map.hotspot_cells.length.toLocaleString()} hotspot cells.`}

{/* View selector */}
{VIEWS.map(({ id, label, icon: Icon }) => ( ))}
{!map && (
Loading map…
)} {map && } {map && } {/* Heat views */} {map && view !== "hotspots" && ( )} {/* Hotspot cells — radius scales with zoom to stay legible */} {map && view === "hotspots" && ( )} {/* Station overlay */} {map && showStations && ( a.area !== "Unknown / No Station")} /> )}
); } function Legend({ view }: { view: View }) { const items = view === "hotspots" ? [ { c: "#d35f55", l: "≥60% risk" }, { c: "#db8a52", l: "30–60%" }, { c: "#cfa247", l: "12–30%" }, { c: "#4f9e7d", l: "<12%" }, ] : view === "manpower" ? [ { c: "#d35f55", l: "High demand" }, { c: "#db8a52", l: "Medium" }, { c: "#5ea585", l: "Low" }, { c: "#4f8fa0", l: "Minimal" }, ] : view === "closure" ? [ { c: "#d35f55", l: "High" }, { c: "#db8a52", l: "Elevated" }, { c: "#cfa247", l: "Some" }, { c: "#4f9e7d", l: "Low" }, ] : [ { c: "#a7cfe6", l: "Dense" }, { c: "#5e9cc0", l: "Busy" }, { c: "#3f6f93", l: "Moderate" }, { c: "#2f4a63", l: "Sparse" }, ]; return (
Legend
{items.map((it) => (
{it.l}
))}
); }