Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| 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<string, string> = { | |
| '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<string>( | |
| STATE_CODES.includes(initialState) ? initialState : '', | |
| ) | |
| const [selectedTheme, setSelectedTheme] = useState<string>('') | |
| const { | |
| data: pins, | |
| isLoading, | |
| isError, | |
| } = useQuery<DecisionPin[]>({ | |
| queryKey: ['decisions-map', selectedState, selectedTheme], | |
| queryFn: async () => { | |
| const params: Record<string, string> = { 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<string>() | |
| 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 ( | |
| <div className="space-y-6"> | |
| <div> | |
| <h1 className="text-2xl font-bold text-gray-900">Civic Decisions Map</h1> | |
| <p className="mt-1 text-sm text-gray-600"> | |
| Geocoded local-government decisions nationwide, colored by policy theme. | |
| Coverage grows as the address geocode backfill runs. | |
| </p> | |
| </div> | |
| {/* Filters */} | |
| <div className="card flex flex-col gap-4 sm:flex-row"> | |
| <div className="flex-1"> | |
| <label className="mb-2 block text-sm font-medium text-gray-700"> | |
| Filter by State | |
| </label> | |
| <select | |
| className="block w-full rounded-md border-gray-300 text-gray-900 shadow-sm focus:border-primary-500 focus:ring-primary-500" | |
| value={selectedState} | |
| onChange={(e) => onStateChange(e.target.value)} | |
| > | |
| <option value="">All States</option> | |
| {STATE_CODES.map((code) => ( | |
| <option key={code} value={code}> | |
| {code} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| <div className="flex-1"> | |
| <label className="mb-2 block text-sm font-medium text-gray-700"> | |
| Filter by Theme | |
| </label> | |
| <select | |
| className="block w-full rounded-md border-gray-300 text-gray-900 shadow-sm focus:border-primary-500 focus:ring-primary-500" | |
| value={selectedTheme} | |
| onChange={(e) => onThemeChange(e.target.value)} | |
| > | |
| <option value="">All Themes</option> | |
| {Object.keys(THEME_COLORS).map((theme) => ( | |
| <option key={theme} value={theme}> | |
| {theme} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| </div> | |
| {/* Legend — only themes present in the current view */} | |
| {presentThemes.length > 0 && ( | |
| <div className="card"> | |
| <h3 className="mb-3 text-sm font-medium text-gray-700">Policy Theme</h3> | |
| <div className="flex flex-wrap gap-x-6 gap-y-2"> | |
| {presentThemes.map((theme) => ( | |
| <div key={theme} className="flex items-center gap-2"> | |
| <span | |
| className="inline-block h-4 w-4 rounded-full" | |
| style={{ backgroundColor: themeColor(theme) }} | |
| /> | |
| <span className="text-sm text-gray-700">{theme}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Map */} | |
| <div className="card relative h-[600px]"> | |
| <MapContainer | |
| center={US_CENTER} | |
| zoom={4} | |
| scrollWheelZoom={false} | |
| style={{ height: '100%', width: '100%' }} | |
| > | |
| <TileLayer | |
| attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' | |
| url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" | |
| /> | |
| {geocoded.map((p) => ( | |
| <CircleMarker | |
| key={String(p.event_decision_id)} | |
| center={[p.latitude, p.longitude]} | |
| radius={p.is_primary ? 9 : 7} | |
| pathOptions={{ | |
| fillColor: themeColor(p.primary_theme), | |
| fillOpacity: 0.75, | |
| color: themeColor(p.primary_theme), | |
| weight: 2, | |
| }} | |
| > | |
| <Popup> | |
| <div className="min-w-[260px] p-1"> | |
| <h4 className="font-bold text-gray-900"> | |
| {p.headline || 'Untitled decision'} | |
| </h4> | |
| {p.primary_theme && ( | |
| <p className="mt-2 text-sm text-gray-700"> | |
| <strong>Theme:</strong> {p.primary_theme} | |
| </p> | |
| )} | |
| {p.outcome && ( | |
| <p className="text-sm text-gray-700"> | |
| <strong>Outcome:</strong> {p.outcome} | |
| </p> | |
| )} | |
| {p.vote_tally && ( | |
| <p className="text-sm text-gray-700"> | |
| <strong>Vote:</strong> {p.vote_tally} | |
| </p> | |
| )} | |
| {p.jurisdiction_name && ( | |
| <p className="text-sm text-gray-700"> | |
| <strong>Jurisdiction:</strong> {p.jurisdiction_name} | |
| {p.state_code ? `, ${expandStateName(p.state_code)}` : ''} | |
| </p> | |
| )} | |
| {p.event_date && ( | |
| <p className="text-sm text-gray-700"> | |
| <strong>Date:</strong> {p.event_date} | |
| </p> | |
| )} | |
| {p.normalized_address && ( | |
| <p className="mt-1 text-xs text-gray-500">{p.normalized_address}</p> | |
| )} | |
| </div> | |
| </Popup> | |
| </CircleMarker> | |
| ))} | |
| </MapContainer> | |
| {/* Overlay states — non-blocking, sit above the map chrome */} | |
| {isLoading && ( | |
| <div className="pointer-events-none absolute inset-0 z-[1000] flex items-center justify-center bg-white/60"> | |
| <span className="text-sm font-medium text-gray-700">Loading decisions…</span> | |
| </div> | |
| )} | |
| {!isLoading && isError && ( | |
| <div className="pointer-events-none absolute inset-x-0 top-2 z-[1000] mx-auto w-fit rounded-md bg-red-50 px-3 py-1.5 text-sm text-red-700 shadow"> | |
| Couldn’t load decisions. Try again. | |
| </div> | |
| )} | |
| {!isLoading && !isError && geocoded.length === 0 && ( | |
| <div className="pointer-events-none absolute inset-x-0 top-2 z-[1000] mx-auto w-fit rounded-md bg-amber-50 px-3 py-1.5 text-sm text-amber-800 shadow"> | |
| No geocoded decisions in view | |
| {selectedState ? ` for ${selectedState}` : ''} | |
| {selectedTheme ? ` · ${selectedTheme}` : ''}. | |
| </div> | |
| )} | |
| </div> | |
| {/* Summary */} | |
| <div className="card"> | |
| <h3 className="mb-2 text-lg font-semibold">Summary</h3> | |
| <p className="text-gray-600"> | |
| Showing <strong>{geocoded.length}</strong> geocoded decision | |
| {geocoded.length === 1 ? '' : 's'} | |
| {selectedState && ` in ${selectedState}`} | |
| {selectedTheme && ` · ${selectedTheme}`}. | |
| </p> | |
| </div> | |
| </div> | |
| ) | |
| } | |