import { useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { MapContainer, TileLayer, CircleMarker, Popup } from 'react-leaflet' import { useQuery } from '@tanstack/react-query' import api from '../lib/api' import { withSpan } from '../instrumentation' import { expandStateName } from '../utils/formatters' import { STATE_CODES } from '../lib/usStates' import 'leaflet/dist/leaflet.css' /** * A single geocoded civic-decision pin from `GET /decisions/map`. * * NOTE: this maps the `event_decision` mart, NOT `event_policy_decision`. The * `/decisions/{id}` detail route resolves against a *different* mart, so the * popup here is intentionally display-only — do not deep-link `event_decision_id` * to that page (it will 404). */ interface DecisionPin { event_decision_id: number | string decision_id: number | string | null place_id: number | string | null latitude: number longitude: number headline: string | null primary_theme: string | null outcome: string | null vote_tally: string | null jurisdiction_name: string | null state_code: string | null /** Wire calendar values can arrive as ISO date strings; treat as display text. */ event_date: string | null normalized_address: string | null is_primary: boolean | null } /** * Controlled-vocabulary `primary_theme` → color. Labels mirror the canonical * theme strings used elsewhere in the app (see Home.tsx cause icon map). Any * theme not listed (or null) falls back to a neutral slate. */ const THEME_COLORS: Record = { 'Fiscal and Budget Management': '#16a34a', 'Zoning and Land Use': '#9333ea', 'Infrastructure and Capital Projects': '#ea580c', 'Economic Development and Business': '#0d9488', 'Social Protection': '#db2777', 'Recreation, Culture, and Religion': '#d97706', Recreation: '#d97706', 'General Public Services': '#2563eb', 'Governance and Administrative Policy': '#2563eb', 'Public Engagement and Communications': '#0891b2', Defense: '#64748b', Transportation: '#dc2626', } const FALLBACK_COLOR = '#475569' function themeColor(theme: string | null): string { if (!theme) return FALLBACK_COLOR return THEME_COLORS[theme] ?? FALLBACK_COLOR } /** Center of the contiguous US, matching the Heatmap reference. */ const US_CENTER: [number, number] = [39.8283, -98.5795] export default function DecisionsMapPage() { // Deep-link support: `?state=AL` (e.g. the "Follow the money" spending headline // drills down here scoped to a state). Normalize to the 2-letter upper form. const [searchParams, setSearchParams] = useSearchParams() const initialState = (searchParams.get('state') || '').trim().toUpperCase() const [selectedState, setSelectedState] = useState( STATE_CODES.includes(initialState) ? initialState : '', ) const [selectedTheme, setSelectedTheme] = useState('') const { data: pins, isLoading, isError, } = useQuery({ queryKey: ['decisions-map', selectedState, selectedTheme], queryFn: async () => { const params: Record = { limit: '1000' } if (selectedState) params.state = selectedState if (selectedTheme) params.theme = selectedTheme // Trace the data load with low-cardinality attributes only. return withSpan( 'decisions_map.fetch', async () => { const response = await api.get('/decisions/map', { params }) // Tolerate both a bare array and an envelope ({ decisions: [...] }). const data = response.data if (Array.isArray(data)) return data as DecisionPin[] if (Array.isArray(data?.decisions)) return data.decisions as DecisionPin[] if (Array.isArray(data?.pins)) return data.pins as DecisionPin[] return [] as DecisionPin[] }, { 'decisions_map.has_state': !!selectedState, 'decisions_map.has_theme': !!selectedTheme, }, ) }, }) // Only pins with finite coordinates are renderable — the backend returns a // handful of geocoded rows today, the rest get filtered out here defensively. const geocoded = useMemo( () => (pins ?? []).filter( (p) => typeof p.latitude === 'number' && typeof p.longitude === 'number' && Number.isFinite(p.latitude) && Number.isFinite(p.longitude), ), [pins], ) // Theme legend: the canonical themes that actually appear in the data, so the // legend stays relevant as the geocode backfill grows. const presentThemes = useMemo(() => { const set = new Set() for (const p of geocoded) if (p.primary_theme) set.add(p.primary_theme) return Array.from(set).sort() }, [geocoded]) const onStateChange = (v: string) => withSpan( 'decisions_map.filter_state', () => { setSelectedState(v) // Keep the URL shareable/back-button friendly in sync with the filter. setSearchParams( (prev) => { const next = new URLSearchParams(prev) if (v) next.set('state', v) else next.delete('state') return next }, { replace: true }, ) }, { 'decisions_map.has_state': !!v }, ) const onThemeChange = (v: string) => withSpan('decisions_map.filter_theme', () => setSelectedTheme(v), { 'decisions_map.has_theme': !!v, }) return (

Civic Decisions Map

Geocoded local-government decisions nationwide, colored by policy theme. Coverage grows as the address geocode backfill runs.

{/* Filters */}
{/* Legend — only themes present in the current view */} {presentThemes.length > 0 && (

Policy Theme

{presentThemes.map((theme) => (
{theme}
))}
)} {/* Map */}
{geocoded.map((p) => (

{p.headline || 'Untitled decision'}

{p.primary_theme && (

Theme: {p.primary_theme}

)} {p.outcome && (

Outcome: {p.outcome}

)} {p.vote_tally && (

Vote: {p.vote_tally}

)} {p.jurisdiction_name && (

Jurisdiction: {p.jurisdiction_name} {p.state_code ? `, ${expandStateName(p.state_code)}` : ''}

)} {p.event_date && (

Date: {p.event_date}

)} {p.normalized_address && (

{p.normalized_address}

)}
))}
{/* Overlay states — non-blocking, sit above the map chrome */} {isLoading && (
Loading decisions…
)} {!isLoading && isError && (
Couldn’t load decisions. Try again.
)} {!isLoading && !isError && geocoded.length === 0 && (
No geocoded decisions in view {selectedState ? ` for ${selectedState}` : ''} {selectedTheme ? ` · ${selectedTheme}` : ''}.
)}
{/* Summary */}

Summary

Showing {geocoded.length} geocoded decision {geocoded.length === 1 ? '' : 's'} {selectedState && ` in ${selectedState}`} {selectedTheme && ` · ${selectedTheme}`}.

) }