open-navigator / web_app /src /pages /CensusMapPage.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
169 kB
// @ts-nocheck — react-simple-maps ships without TypeScript types (same as USMap.tsx)
import {
startTransition,
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import { motion, useReducedMotion } from 'framer-motion'
import { Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { ComposableMap, Geographies, Geography } from 'react-simple-maps'
import { geoCentroid } from 'd3-geo'
import { feature } from 'topojson-client'
import { MapContainer, GeoJSON, useMap, CircleMarker, Tooltip as LeafletTooltip } from 'react-leaflet'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import {
AdjustmentsHorizontalIcon,
ArrowLeftIcon,
ChartBarSquareIcon,
PauseIcon,
PlayIcon,
SwatchIcon,
TableCellsIcon,
XMarkIcon,
} from '@heroicons/react/24/outline'
import { CartesianGrid, ResponsiveContainer, Tooltip as RechartsTooltip, XAxis, YAxis, Line, LineChart } from 'recharts'
import { STATE_CODE_TO_NAME } from '../utils/stateMapping'
import {
CENSUS_CHORO_FILL_TRANSITION,
CENSUS_SCALES,
bubbleFillFromT,
bubbleRadiusPx,
colorFromT,
formatCensusMapAxisTickForMetric,
formatMetricValueCompact,
formatMetricValueDisplay,
metricToDisplayT,
minMaxExtent,
quantileExtent,
type CensusScaleId,
} from '../utils/censusMapTransforms'
import { giniLetterSuffix } from '../utils/giniLetterGrade'
import {
type CensusValueMode,
displayValueForMode,
nationalBaselineWithFallback,
prevVintageInList,
trendCell,
} from '../utils/censusMapValueMode'
import {
type CensusChoroLegendSemantics,
censusChoroLegendSemantics,
censusMetricExploreQuestion,
censusMetricFullHelp,
censusMetricRankDirection,
censusMetricStaleDataNote,
censusMetricWinnerCaption,
censusMapShowOfficialCensusLabel,
compareRankedMetricValues,
CENSUS_EXPLORER_HIDDEN_METRIC_SLUGS,
CENSUS_MAP_UI_HELP,
} from '../utils/censusDataDictionary'
import {
buildCensusNarrativePack,
buildCensusTrendChartTitle,
buildPlaceTrendNarrative,
type CensusNarrativePack,
} from '../utils/censusMapNarrative'
import { CensusRaceBarChart } from '../components/CensusRaceBarChart'
import { GiniIncomeInequalityLetterLegend } from '../components/GiniInequalityReadout'
import { InfoHelpTrigger } from '../components/InfoHelpTrigger'
import MapAddressSearch, { type MapAddressResult } from '../components/MapAddressSearch'
import CensusMapLeftRail, { type CensusMapRailSection } from '../components/CensusMapLeftRail'
import { censusMapPathPrefix, mapPathPlace, mapPathState, mapPathUs } from '../utils/dataExplorerPaths'
const STATES_TOPO = 'https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json'
const COUNTY_TOPO = 'https://cdn.jsdelivr.net/npm/us-atlas@3/counties-10m.json'
/** Truncate long state / place names for chart Y-axis (single line). */
function truncateStateLabel(name: string, maxChars = 18) {
if (!name) return ''
if (name.length <= maxChars) return name
return `${name.slice(0, Math.max(1, maxChars - 1))}…`
}
/** High-contrast tooltips (default Recharts is pale on transparent). */
const CENSUS_RECHARTS_TOOLTIP = {
contentStyle: {
backgroundColor: '#0f172a',
border: '1px solid #334155',
borderRadius: 10,
boxShadow: '0 14px 28px rgba(0,0,0,0.35)',
padding: '10px 14px',
color: '#f8fafc',
},
labelStyle: { color: '#e2e8f0', fontWeight: 700, fontSize: 12, marginBottom: 6 },
itemStyle: { color: '#f8fafc', fontSize: 13 },
}
/** Lon/lat pair for geography centroids. */
function toLonLatPair(c: unknown): [number, number] | null {
if (!Array.isArray(c) || c.length < 2) return null
const lon = Number(c[0])
const lat = Number(c[1])
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null
return [lon, lat]
}
/** Project [lon,lat] to SVG; geoAlbersUsa may return null / non-array — never feed that to r-s-m Marker. */
function safeProjectScreen(
projection: ((c: [number, number]) => unknown) | null | undefined,
lonLat: [number, number],
): [number, number] | null {
if (projection == null || typeof projection !== 'function') return null
try {
const p = projection(lonLat)
if (!Array.isArray(p) || p.length < 2) return null
const x = Number(p[0])
const y = Number(p[1])
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
return [x, y]
} catch {
return null
}
}
const FIPS2_TO_USPS: Record<string, string> = {
'01': 'AL', '02': 'AK', '04': 'AZ', '05': 'AR', '06': 'CA', '08': 'CO', '09': 'CT', '10': 'DE',
'11': 'DC', '12': 'FL', '13': 'GA', '15': 'HI', '16': 'ID', '17': 'IL', '18': 'IN', '19': 'IA',
'20': 'KS', '21': 'KY', '22': 'LA', '23': 'ME', '24': 'MD', '25': 'MA', '26': 'MI', '27': 'MN',
'28': 'MS', '29': 'MO', '30': 'MT', '31': 'NE', '32': 'NV', '33': 'NH', '34': 'NJ', '35': 'NM',
'36': 'NY', '37': 'NC', '38': 'ND', '39': 'OH', '40': 'OK', '41': 'OR', '42': 'PA', '44': 'RI',
'45': 'SC', '46': 'SD', '47': 'TN', '48': 'TX', '49': 'UT', '50': 'VT', '51': 'VA', '53': 'WA',
'54': 'WV', '55': 'WI', '56': 'WY', '72': 'PR',
}
const USPS_TO_FIPS2: Record<string, string> = Object.fromEntries(
Object.entries(FIPS2_TO_USPS).map(([fips, usps]) => [usps, fips]),
)
/** Topo / Census ids may be 1 or "01"; normalize to 2-digit state FIPS for routes and JSON keys. */
function normalizeStateFips(raw: unknown): string | null {
const s = String(raw ?? '').trim()
if (!s) return null
const n = Number.parseInt(s, 10)
if (!Number.isFinite(n) || n < 1 || n > 99) return null
return String(n).padStart(2, '0')
}
interface CensusMetric {
slug: string
label: string
format: string
table?: string
}
interface CensusManifest {
vintage: string
vintages?: string[]
county_topo_cdn: string
state_topo_cdn?: string
metrics: CensusMetric[]
place_states: string[]
national_ref?: Record<string, Record<string, { us?: number | null; pop_weighted_states?: number | null }>>
paths: {
county_metrics: string
place_geojson: string
state_metrics?: string
state_trends?: string
county_trends?: string
place_trends?: string
}
}
interface StateMetricsPayload {
geography: string
vintage: string
values: Record<string, Record<string, number | null | undefined>>
}
interface CountyMetricsPayload {
geography: string
vintage: string
values: Record<string, Record<string, number | null | undefined>>
}
/** Multi-year state series from ``state_trends.json`` */
interface StateTrendsPayload {
geography: string
vintages: string[]
by_state: Record<string, Record<string, unknown>>
}
interface CountyPlaceTrendsPayload {
geography: string
state: string
vintages: string[]
byGeoid: Record<string, Record<string, unknown>>
}
type GeoJSONFeatureCollection = GeoJSON.FeatureCollection
function stateMetricsFromTrends(
trends: StateTrendsPayload,
vintage: string,
metricSlugs: string[],
): StateMetricsPayload {
const values: Record<string, Record<string, number | null | undefined>> = {}
for (const [st, row] of Object.entries(trends.by_state)) {
const cell: Record<string, number | null | undefined> = {}
const nm = row.NAME
if (typeof nm === 'string' && nm.trim()) cell.NAME = nm.trim()
for (const slug of metricSlugs) {
const series = row[slug]
if (series && typeof series === 'object' && !Array.isArray(series)) {
const v = (series as Record<string, unknown>)[vintage]
cell[slug] = typeof v === 'number' && Number.isFinite(v) ? v : null
} else {
cell[slug] = null
}
}
values[st] = cell
}
return { geography: 'state', vintage, values }
}
function countyMetricsFromTrends(
trends: CountyPlaceTrendsPayload,
vintage: string,
metricSlugs: string[],
stateFips: string,
): CountyMetricsPayload {
const values: Record<string, Record<string, number | null | undefined>> = {}
const stp = stateFips.padStart(2, '0')
for (const [gid, row] of Object.entries(trends.byGeoid)) {
if (!gid.startsWith(stp)) continue
const cell: Record<string, number | null | undefined> = {}
const nm = row.NAME
if (typeof nm === 'string' && nm.trim()) cell.NAME = nm.trim()
cell.GEOID = gid
for (const slug of metricSlugs) {
const series = row[slug]
if (series && typeof series === 'object' && !Array.isArray(series)) {
const v = (series as Record<string, unknown>)[vintage]
cell[slug] = typeof v === 'number' && Number.isFinite(v) ? v : null
} else {
cell[slug] = null
}
}
const g5 = gid.replace(/\D/g, '').slice(-5).padStart(5, '0')
values[g5] = cell
}
return { geography: 'county', vintage, values }
}
function mergePlaceGeoWithTrends(
base: GeoJSONFeatureCollection | undefined,
trends: CountyPlaceTrendsPayload | undefined,
vintage: string,
metricSlugs: string[],
): GeoJSONFeatureCollection | undefined {
if (!base) return undefined
if (!trends?.byGeoid) return base
return {
...base,
features: base.features.map((f) => {
const p = (f.properties ?? {}) as Record<string, unknown>
const gid = String(p.GEOID ?? '')
const row = trends.byGeoid[gid]
if (!row) return f
const np: Record<string, unknown> = { ...p }
for (const slug of metricSlugs) {
const series = row[slug]
if (series && typeof series === 'object' && !Array.isArray(series)) {
const v = (series as Record<string, unknown>)[vintage]
if (typeof v === 'number' && Number.isFinite(v)) np[slug] = v
}
}
return { ...f, properties: np }
}),
}
}
function manifestVintagesFromManifest(manifest: CensusManifest): string[] {
const v = manifest.vintages
if (Array.isArray(v) && v.length > 0) return [...v]
return manifest.vintage ? [manifest.vintage] : []
}
/** Dedupe + ascending calendar order for ACS end-years (slider / play). */
function chronoUniqueVintages(years: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const y of years) {
if (!y || seen.has(y)) continue
seen.add(y)
out.push(y)
}
out.sort((a, b) => Number(a) - Number(b))
return out
}
/**
* After the vintage list is swapped mid-play (manifest → trends), map the same calendar year
* to its new index; if that year vanished, use the latest year still ≤ heldYear.
*/
function indexForHeldYearInNewVintages(vintages: string[], heldYear: string): number {
if (!vintages.length) return 0
const exact = vintages.indexOf(heldYear)
if (exact >= 0) return exact
const hy = Number(heldYear)
if (!Number.isFinite(hy)) return 0
let bestIdx = 0
let bestVal = -Infinity
for (let i = 0; i < vintages.length; i++) {
const y = Number(vintages[i])
if (!Number.isFinite(y)) continue
if (y <= hy && y >= bestVal) {
bestVal = y
bestIdx = i
}
}
if (bestVal > -Infinity) return bestIdx
return 0
}
function metricHasTrendSeriesInRow(row: Record<string, unknown>, slug: string): boolean {
const series = row[slug]
return Boolean(series && typeof series === 'object' && !Array.isArray(series))
}
function stateHasAnySeriesForSlug(trends: StateTrendsPayload, slug: string): boolean {
return Object.values(trends.by_state).some((row) =>
metricHasTrendSeriesInRow(row as Record<string, unknown>, slug),
)
}
function stateTrendSliderVintages(trends: StateTrendsPayload, metricSlug: string): string[] {
const base = trends.vintages ?? []
if (!base.length) return base
if (!stateHasAnySeriesForSlug(trends, metricSlug)) return base
const filtered = base.filter((y) =>
Object.values(trends.by_state).some((row) => {
const series = (row as Record<string, unknown>)[metricSlug]
const v = trendCell(series, y)
return typeof v === 'number' && Number.isFinite(v)
}),
)
return filtered.length ? filtered : base
}
function countyPlaceSliderVintages(
trends: CountyPlaceTrendsPayload,
stateFips: string,
metricSlug: string,
): string[] {
const base = trends.vintages ?? []
if (!base.length) return base
const stp = stateFips.padStart(2, '0')
const hasAny = Object.entries(trends.byGeoid).some(
([gid, row]) =>
gid.startsWith(stp) && metricHasTrendSeriesInRow(row as Record<string, unknown>, metricSlug),
)
if (!hasAny) return base
const filtered = base.filter((y) =>
Object.entries(trends.byGeoid).some(([gid, row]) => {
if (!gid.startsWith(stp)) return false
const series = (row as Record<string, unknown>)[metricSlug]
const v = trendCell(series, y)
return typeof v === 'number' && Number.isFinite(v)
}),
)
return filtered.length ? filtered : base
}
function sliderVintages(args: {
mode: 'us' | 'stateCounty' | 'place'
manifest: CensusManifest
metricSlug: string
stateTrends: StateTrendsPayload | null | undefined
countyTrends: CountyPlaceTrendsPayload | null | undefined
placeTrends: CountyPlaceTrendsPayload | null | undefined
stateFips: string | undefined
}): string[] {
const mv = chronoUniqueVintages(manifestVintagesFromManifest(args.manifest))
let resolved: string[]
if (args.mode === 'us') {
if (args.stateTrends?.vintages?.length) resolved = stateTrendSliderVintages(args.stateTrends, args.metricSlug)
else resolved = mv.length ? mv : ['2022']
} else if (args.mode === 'stateCounty' && args.stateFips && args.countyTrends?.vintages?.length) {
resolved = countyPlaceSliderVintages(args.countyTrends, args.stateFips, args.metricSlug)
} else if (args.mode === 'place' && args.stateFips && args.placeTrends?.vintages?.length) {
resolved = countyPlaceSliderVintages(args.placeTrends, args.stateFips, args.metricSlug)
} else {
resolved = mv.length ? mv : ['2022']
}
return chronoUniqueVintages(resolved)
}
/** Top-N for race bar charts (states / counties / places). */
const CENSUS_TOP_BAR_ROW_LIMIT = 10
/**
* Pool display values for the choropleth legend across every manifest vintage using trend
* sidecars, so min/max (and percentile clipping) stay fixed while the year slider moves.
*/
function collectAllVintageDisplayValuesState(
trends: StateTrendsPayload,
vintages: string[],
metricSlug: string,
valueMode: CensusValueMode,
nationalRef: CensusManifest['national_ref'],
): number[] {
const vals: number[] = []
for (const vy of vintages) {
const prevV = prevVintageInList(vintages, vy)
const nat = nationalBaselineWithFallback(nationalRef, vy, metricSlug, { stateTrends: trends })
for (const row of Object.values(trends.by_state)) {
const rec = row as Record<string, unknown>
const series = rec[metricSlug]
const raw = trendCell(series, vy)
let prev: number | null = null
if (valueMode === 'yoy' && prevV) prev = trendCell(series, prevV)
const d = displayValueForMode(valueMode, raw, prev, nat)
if (typeof d === 'number' && Number.isFinite(d)) vals.push(d)
}
}
return vals
}
function collectAllVintageDisplayValuesCounty(
trends: CountyPlaceTrendsPayload,
stateFips: string,
vintages: string[],
metricSlug: string,
valueMode: CensusValueMode,
nationalRef: CensusManifest['national_ref'],
stateTrends: StateTrendsPayload | null | undefined,
): number[] {
const stp = stateFips.padStart(2, '0')
const vals: number[] = []
for (const vy of vintages) {
const prevV = prevVintageInList(vintages, vy)
const nat = nationalBaselineWithFallback(nationalRef, vy, metricSlug, { stateTrends })
for (const [gid, row] of Object.entries(trends.byGeoid)) {
if (!gid.startsWith(stp)) continue
const rec = row as Record<string, unknown>
const series = rec[metricSlug]
const raw = trendCell(series, vy)
let prev: number | null = null
if (valueMode === 'yoy' && prevV) prev = trendCell(series, prevV)
const d = displayValueForMode(valueMode, raw, prev, nat)
if (typeof d === 'number' && Number.isFinite(d)) vals.push(d)
}
}
return vals
}
function collectAllVintageDisplayValuesPlace(
trends: CountyPlaceTrendsPayload,
stateFips: string,
vintages: string[],
metricSlug: string,
valueMode: CensusValueMode,
nationalRef: CensusManifest['national_ref'],
stateTrends: StateTrendsPayload | null | undefined,
): number[] {
const stp = stateFips.padStart(2, '0')
const vals: number[] = []
for (const vy of vintages) {
const prevV = prevVintageInList(vintages, vy)
const nat = nationalBaselineWithFallback(nationalRef, vy, metricSlug, { stateTrends })
for (const [gid, row] of Object.entries(trends.byGeoid)) {
if (!gid.startsWith(stp)) continue
const rec = row as Record<string, unknown>
const series = rec[metricSlug]
const raw = trendCell(series, vy)
let prev: number | null = null
if (valueMode === 'yoy' && prevV) prev = trendCell(series, prevV)
const d = displayValueForMode(valueMode, raw, prev, nat)
if (typeof d === 'number' && Number.isFinite(d)) vals.push(d)
}
}
return vals
}
function LabelWithInfo({
label,
help,
labelClassName = 'text-xs font-semibold uppercase tracking-wide text-slate-500',
}: {
label: string
help: string
labelClassName?: string
}) {
return (
<span className="inline-flex items-center gap-1 shrink-0">
<span className={labelClassName}>{label}</span>
<InfoHelpTrigger help={help} topic={label} align="left" />
</span>
)
}
function CensusMetricToolbarControl({
metricFullHelp,
metrics,
metricSlug,
onPickMetric,
unconstrainedWidth = false,
}: {
metricFullHelp: string
metrics: CensusMetric[]
metricSlug: string
onPickMetric: (slug: string) => void
/** When true, drop the narrow max-width so the control can fill a toolbar tile. */
unconstrainedWidth?: boolean
}) {
const current = metrics.find((m) => m.slug === metricSlug)
return (
<div
className={
unconstrainedWidth
? 'flex min-w-0 max-w-full flex-col gap-0.5'
: 'flex min-w-0 max-w-full flex-col gap-0.5 sm:max-w-[min(26rem,calc(100vw-9rem))]'
}
>
<LabelWithInfo
label="What do you want to explore?"
help={`${CENSUS_MAP_UI_HELP.metric}\n\n${metricFullHelp}`}
labelClassName={
unconstrainedWidth
? 'text-[10px] font-semibold uppercase tracking-wide text-slate-500 leading-tight'
: undefined
}
/>
<select
aria-label="Topic to show on the map"
className="rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs font-medium text-slate-900 shadow-sm w-full"
value={metricSlug}
onChange={(e) => onPickMetric(e.target.value)}
>
{metrics.map((m) => (
<option key={m.slug} value={m.slug}>
{censusMetricExploreQuestion(m.slug, m.label)}
</option>
))}
</select>
{current && censusMapShowOfficialCensusLabel(current.slug) ? (
<span className="text-[10px] leading-snug text-slate-500">
Census label: <span className="font-medium text-slate-600">{current.label}</span>
</span>
) : null}
</div>
)
}
/** Thin heading row directly above the map (keeps `aria-labelledby` target). */
function CensusMapHeadingStrip({
titleId,
title,
insight,
selectionActive,
}: {
titleId: string
title: string
/** Extra context (e.g. selected state vs national) shown under the title. */
insight?: string | null
/** True when a leaderboard row is pinned so the strip reads asthis location is selected.” */
selectionActive?: boolean
}) {
return (
<div
className={
selectionActive
? 'border-b border-amber-200/90 bg-amber-50/80 px-3 py-1 sm:px-3 border-l-[5px] border-l-amber-500'
: 'border-b border-slate-100 bg-slate-50/60 px-3 py-1 sm:px-3'
}
>
<h2 id={titleId} className="text-sm font-semibold text-slate-900 leading-snug tracking-tight">
{title}
</h2>
{insight ? (
<p className="mt-0.5 text-xs leading-snug text-slate-600" id={`${titleId}-insight`}>
{insight}
</p>
) : null}
</div>
)
}
/** Source line + “how to read” tips; panel opens on button click to keep the map chrome compact. */
function CensusMapExplainerDetails({ subtitle, calloutLines }: { subtitle: string; calloutLines: string[] }) {
const panelId = useId()
const [open, setOpen] = useState(false)
return (
<div className="border-b border-slate-200 bg-slate-50/40">
<div className="flex items-center justify-end gap-2 px-3 py-1">
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-2"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((v) => !v)}
>
<span>How to read this map</span>
<span
className={`text-[10px] text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`}
aria-hidden
>
</span>
</button>
</div>
{open ? (
<div
id={panelId}
className="space-y-2 border-t border-slate-100 bg-white px-3 pb-3 pt-2"
role="region"
aria-label="How to read this map"
>
<p className="text-xs leading-relaxed text-slate-600">{subtitle}</p>
{calloutLines.length > 0 ? (
<ul className="space-y-1.5 text-xs leading-snug text-slate-800">
{calloutLines.map((line, i) => (
<li key={i} className="flex gap-2">
<span className="select-none font-semibold text-slate-500" aria-hidden>
·
</span>
<span className="min-w-0 flex-1">{line}</span>
</li>
))}
</ul>
) : null}
</div>
) : null}
</div>
)
}
function CensusMapAdvancedMapOptionsFlyout({
open,
onClose,
metricFullHelp,
viz,
setViz,
scale,
setScale,
valueMode,
setValueMode,
focusSection,
onConsumedFocusSection,
yearControls,
}: {
open: boolean
onClose: () => void
metricFullHelp: string
viz: 'filled' | 'bubble'
setViz: (v: 'filled' | 'bubble') => void
scale: CensusScaleId
setScale: (s: CensusScaleId) => void
valueMode: CensusValueMode
setValueMode: (m: CensusValueMode) => void
/** When opening from a toolbar affordance, scroll this block into view. */
focusSection?: 'year' | 'view' | 'scale' | 'values' | null
onConsumedFocusSection?: () => void
/** When provided, renders an in-flyout Year + Play section above the other sections. */
yearControls?: {
vintages: string[]
displayVintage: string
singleVintage: boolean
showPlay: boolean
playing: boolean
setPlaying: (v: boolean) => void
onVintageChange: (v: string) => void
onBeginPlay?: () => void
yearHelp: string
}
}) {
useEffect(() => {
if (!open) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
window.removeEventListener('keydown', onKey)
}
}, [open, onClose])
useEffect(() => {
if (!open || !focusSection) return
const id =
focusSection === 'year'
? 'census-advanced-section-year'
: focusSection === 'view'
? 'census-advanced-section-view'
: focusSection === 'scale'
? 'census-advanced-section-scale'
: 'census-advanced-section-values'
const t = window.setTimeout(() => {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
onConsumedFocusSection?.()
}, 40)
return () => window.clearTimeout(t)
}, [open, focusSection, onConsumedFocusSection])
if (!open || typeof document === 'undefined') return null
return createPortal(
<div className="fixed inset-0 z-[200] flex justify-end">
<button
type="button"
className="absolute inset-0 z-0 bg-slate-900/45"
aria-label="Close map options"
onClick={onClose}
/>
<div
className="relative z-10 flex h-full w-[min(100vw,22rem)] flex-col border-l border-slate-200 bg-white shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="census-advanced-map-options-title"
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 px-3 py-2.5">
<h2 id="census-advanced-map-options-title" className="text-sm font-semibold text-slate-900">
Map display options
</h2>
<button
type="button"
onClick={onClose}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-2"
aria-label="Close"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto overscroll-contain p-3">
{yearControls ? (
<div
id="census-advanced-section-year"
className="rounded-lg border border-slate-200 bg-slate-50/50 p-3 scroll-mt-3"
>
<div className="mb-2">
<LabelWithInfo label="Year (ACS end year)" help={yearControls.yearHelp} />
</div>
<VintageAndPlayControls
vintages={yearControls.vintages}
displayVintage={yearControls.displayVintage}
singleVintage={yearControls.singleVintage}
showPlay={yearControls.showPlay}
playing={yearControls.playing}
setPlaying={yearControls.setPlaying}
onVintageChange={yearControls.onVintageChange}
onBeginPlay={yearControls.onBeginPlay}
yearHelp={yearControls.yearHelp}
/>
</div>
) : null}
<div id="census-advanced-section-view" className="rounded-lg border border-slate-200 bg-slate-50/50 p-3 scroll-mt-3">
<div className="mb-2">
<LabelWithInfo
label="Map view"
help={`${CENSUS_MAP_UI_HELP.vizFilled} ${CENSUS_MAP_UI_HELP.vizBubble}\n\n${metricFullHelp}`}
/>
</div>
<div className="flex overflow-hidden rounded-md border border-slate-200">
<button
type="button"
onClick={() => setViz('filled')}
className={`flex-1 px-3 py-2 text-xs font-medium ${
viz === 'filled' ? 'bg-[#354F52] text-white' : 'bg-white text-slate-700 hover:bg-slate-50'
}`}
>
Filled map
</button>
<button
type="button"
onClick={() => setViz('bubble')}
className={`flex-1 border-l border-slate-200 px-3 py-2 text-xs font-medium ${
viz === 'bubble' ? 'bg-[#354F52] text-white' : 'bg-white text-slate-700 hover:bg-slate-50'
}`}
>
Bubbles
</button>
</div>
</div>
<div id="census-advanced-section-scale" className="rounded-lg border border-slate-200 bg-slate-50/50 p-3 scroll-mt-3">
<div className="mb-2">
<LabelWithInfo label="Color spread" help={`${CENSUS_MAP_UI_HELP.scale}\n\n${metricFullHelp}`} />
</div>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-xs text-slate-900 shadow-sm"
value={scale}
onChange={(e) => setScale(e.target.value as CensusScaleId)}
>
{CENSUS_SCALES.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</div>
<div id="census-advanced-section-values" className="rounded-lg border border-slate-200 bg-slate-50/50 p-3 scroll-mt-3">
<div className="mb-2">
<LabelWithInfo label="What numbers are on the map" help={`${CENSUS_MAP_UI_HELP.mapValue}\n\n${metricFullHelp}`} />
</div>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-xs shadow-sm"
value={valueMode}
onChange={(e) => setValueMode(e.target.value as CensusValueMode)}
>
<option value="raw">ACS value (color spread adjusted)</option>
<option value="yoy">% change vs prior year</option>
<option value="vs_natl">% vs national benchmark</option>
</select>
</div>
</div>
</div>
</div>,
document.body,
)
}
function trendPointsFromSeries(
vintages: string[],
series: Record<string, unknown> | undefined,
): { year: string; value: number | null }[] {
if (!series || typeof series !== 'object' || Array.isArray(series)) return []
return vintages.map((y) => {
const v = (series as Record<string, unknown>)[y]
return { year: y, value: typeof v === 'number' && Number.isFinite(v) ? v : null }
})
}
function VintageAndPlayControls({
vintages,
displayVintage,
singleVintage,
showPlay,
playing,
setPlaying,
onVintageChange,
onBeginPlay,
yearHelp = CENSUS_MAP_UI_HELP.year,
}: {
vintages: string[]
displayVintage: string
singleVintage: boolean
showPlay: boolean
playing: boolean
setPlaying: (v: boolean) => void
onVintageChange: (v: string) => void
/** When starting Play, jump to the first year in the list (oldest). */
onBeginPlay?: () => void
yearHelp?: string
}) {
const yearScrollRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const root = yearScrollRef.current
if (!root || !vintages.length) return
const active = root.querySelector<HTMLElement>(`[data-census-vintage="${displayVintage}"]`)
if (!active) return
const run = () => active.scrollIntoView({ behavior: 'auto', inline: 'center', block: 'nearest' })
requestAnimationFrame(() => requestAnimationFrame(run))
}, [displayVintage, vintages])
return (
<div className="flex min-w-0 max-w-full flex-wrap items-center gap-x-1.5 gap-y-1">
<div className="flex min-w-0 max-w-full flex-1 flex-col gap-0.5">
<LabelWithInfo
label="Year (ACS end year)"
help={yearHelp}
labelClassName="text-[10px] font-semibold uppercase tracking-wide text-slate-500 leading-tight"
/>
<div
ref={yearScrollRef}
className="flex min-w-0 max-w-full flex-nowrap items-center gap-1 overflow-x-auto overflow-y-hidden overscroll-x-contain py-0.5 [scrollbar-gutter:stable] [scrollbar-width:thin] pr-1 [-webkit-overflow-scrolling:touch]"
role="group"
aria-label="Select ACS vintage"
>
{vintages.map((y) => {
const active = y === displayVintage
return (
<button
key={y}
type="button"
data-census-vintage={y}
disabled={singleVintage && !active}
onClick={() => {
setPlaying(false)
onVintageChange(y)
}}
className={`shrink-0 rounded-full border px-2.5 py-1 text-[11px] font-semibold tabular-nums transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40 ${
active
? 'border-[#354F52] bg-[#354F52] text-white shadow-sm'
: 'border-slate-300 bg-white text-slate-800 hover:bg-slate-50'
}`}
>
{y}
</button>
)
})}
</div>
</div>
{showPlay ? (
<button
type="button"
onClick={() => {
if (playing) {
setPlaying(false)
} else {
onBeginPlay?.()
setPlaying(true)
}
}}
className="inline-flex items-center gap-1 rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50 shrink-0"
title={
playing
? 'Pause animation'
: `${CENSUS_MAP_UI_HELP.play} Starts at the oldest year in the list and advances until the newest, then stops.`
}
>
{playing ? <PauseIcon className="h-4 w-4" /> : <PlayIcon className="h-4 w-4" />}
{playing ? 'Pause' : 'Play years'}
</button>
) : null}
</div>
)
}
const explorerFilterTile =
'rounded-xl border border-slate-200 bg-white px-2 py-1.5 shadow-sm sm:px-2.5 sm:py-2 min-w-0 flex flex-col justify-center'
function CensusExplorerFilterBar({
leadingSlot,
metricFullHelp,
metricChoices,
metricSlug,
onMetricChange,
vintages,
displayVintage,
singleVintage,
showPlay,
playing,
setPlaying,
onVintageChange,
onBeginPlay,
yearHelp,
onOpenAdvanced,
}: {
leadingSlot?: ReactNode
metricFullHelp: string
/** Metrics shown in the topic dropdown (excludes internal / table-total slugs). */
metricChoices: CensusMetric[]
metricSlug: string
onMetricChange: (slug: string) => void
vintages: string[]
displayVintage: string
singleVintage: boolean
showPlay: boolean
playing: boolean
setPlaying: (v: boolean) => void
onVintageChange: (v: string) => void
onBeginPlay?: () => void
yearHelp: string
onOpenAdvanced: (section: 'view' | 'scale' | 'values') => void
}) {
return (
<div
className="flex flex-col gap-1.5 xl:flex-row xl:flex-wrap xl:items-stretch"
role="toolbar"
aria-label="Map and chart filters"
>
{leadingSlot ? (
<div className={`${explorerFilterTile} shrink-0 xl:max-w-[min(100%,22rem)]`}>
<div className="flex flex-wrap items-center gap-2">{leadingSlot}</div>
</div>
) : null}
<div className={`${explorerFilterTile} min-w-0 flex-1 xl:min-w-[11rem] xl:max-w-md`}>
<CensusMetricToolbarControl
metricFullHelp={metricFullHelp}
metrics={metricChoices}
metricSlug={metricSlug}
onPickMetric={onMetricChange}
unconstrainedWidth
/>
</div>
<div className={`${explorerFilterTile} min-w-0 flex-1 xl:min-w-[18rem]`}>
<VintageAndPlayControls
vintages={vintages}
displayVintage={displayVintage}
singleVintage={singleVintage}
showPlay={showPlay}
playing={playing}
setPlaying={setPlaying}
onVintageChange={onVintageChange}
onBeginPlay={onBeginPlay}
yearHelp={yearHelp}
/>
</div>
<div
className={`${explorerFilterTile} flex flex-row flex-wrap items-center gap-1 xl:ml-auto xl:max-w-[24rem] xl:justify-end`}
>
<button
type="button"
title="Filled map vs bubbles"
onClick={() => onOpenAdvanced('view')}
className="inline-flex shrink-0 items-center rounded-md border border-slate-300 bg-white px-2 py-1.5 text-[11px] font-medium text-slate-800 shadow-sm hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-2"
>
Map view
</button>
<button
type="button"
title="How values stretch across colors"
onClick={() => onOpenAdvanced('scale')}
className="inline-flex shrink-0 items-center rounded-md border border-slate-300 bg-white px-2 py-1.5 text-[11px] font-medium text-slate-800 shadow-sm hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-2"
>
Color spread
</button>
<button
type="button"
title="Raw ACS value, year-over-year change, or vs national"
onClick={() => onOpenAdvanced('values')}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-slate-300 bg-white px-2 py-1.5 text-[11px] font-medium text-slate-800 shadow-sm hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-2"
>
<AdjustmentsHorizontalIcon className="h-3.5 w-3.5 shrink-0 text-slate-600" aria-hidden />
Map numbers
</button>
</div>
</div>
)
}
function AcTrendChart({
title,
subtitle,
readingLines,
chartTitleId,
points,
format,
metricHelp,
metricTooltipLabel,
}: {
title: string
subtitle?: string
readingLines?: string[]
chartTitleId?: string
points: { year: string; value: number | null }[]
format: (v: number) => string
metricHelp?: string
/** Shown in the point tooltip as ``{label}: {formatted value}``. */
metricTooltipLabel?: string
}) {
const readingPanelId = useId()
const [readingOpen, setReadingOpen] = useState(false)
const nonNull = points.filter((p) => p.value != null)
if (nonNull.length < 2) {
return (
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50/80 p-3 text-xs text-slate-500">
<div id={chartTitleId}>
<span className="font-semibold text-slate-700">{title}</span>
{subtitle ? <p className="mt-1 text-slate-600 leading-snug">{subtitle}</p> : null}
</div>
<p className="mt-2">Need at least two years with data for a trend line.</p>
{readingLines?.length ? (
<div className="mt-3 border-t border-slate-200 pt-2">
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-2"
aria-expanded={readingOpen}
aria-controls={readingPanelId}
onClick={() => setReadingOpen((v) => !v)}
>
<span>How to read this chart</span>
<span
className={`text-[10px] text-slate-400 transition-transform ${readingOpen ? 'rotate-180' : ''}`}
aria-hidden
>
</span>
</button>
{readingOpen ? (
<div
id={readingPanelId}
className="mt-2 rounded-md border border-amber-100/90 bg-amber-50/45 px-2.5 py-2"
role="region"
aria-label="How to read this chart"
>
<ul className="space-y-1 text-xs text-slate-800 leading-snug">
{readingLines.map((line, i) => (
<li key={i} className="flex gap-2">
<span className="font-semibold text-amber-700 select-none" aria-hidden>
·
</span>
<span className="min-w-0 flex-1">{line}</span>
</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
</div>
)
}
return (
<div
className="rounded-lg border border-slate-200 bg-white p-3 shadow-sm"
role="region"
{...(chartTitleId ? { 'aria-labelledby': chartTitleId } : {})}
>
<div className="mb-1.5 flex min-w-0 items-start gap-1 border-b border-slate-100 pb-1.5">
<div className="min-w-0 flex-1">
<div
id={chartTitleId}
className="text-[11px] font-semibold uppercase tracking-wide text-slate-800 leading-tight"
title={title}
>
{title}
</div>
{subtitle ? (
<p className="mt-0.5 text-xs font-normal normal-case text-slate-600 leading-relaxed">{subtitle}</p>
) : null}
</div>
{metricHelp ? <InfoHelpTrigger help={metricHelp} topic="Trend chart" align="right" /> : null}
</div>
{readingLines?.length ? (
<div className="mb-1.5">
<button
type="button"
className="mb-1.5 inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-amber-200/90 bg-amber-50/60 px-2.5 py-1.5 text-xs font-medium text-amber-950/90 shadow-sm hover:bg-amber-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-2 sm:w-auto sm:justify-start"
aria-expanded={readingOpen}
aria-controls={readingPanelId}
onClick={() => setReadingOpen((v) => !v)}
>
<span>How to read this chart</span>
<span
className={`text-[10px] text-slate-500 transition-transform ${readingOpen ? 'rotate-180' : ''}`}
aria-hidden
>
</span>
</button>
{readingOpen ? (
<div
id={readingPanelId}
className="rounded-md border border-amber-100/90 bg-amber-50/45 px-2.5 py-2"
role="region"
aria-label="How to read this chart"
>
<ul className="space-y-1 text-xs text-slate-800 leading-snug">
{readingLines.map((line, i) => (
<li key={i} className="flex gap-2">
<span className="font-semibold text-amber-700 select-none" aria-hidden>
·
</span>
<span className="min-w-0 flex-1">{line}</span>
</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
<div className="h-[160px] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={points} margin={{ left: 0, right: 8, top: 4, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-slate-200" />
<XAxis dataKey="year" tick={{ fontSize: 9 }} interval="preserveStartEnd" />
<YAxis
width={44}
tick={{ fontSize: 9 }}
tickFormatter={(x) => {
const n = Number(x)
if (!Number.isFinite(n)) return ''
if (Math.abs(n) >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (Math.abs(n) >= 1000) return `${Math.round(n / 1000)}k`
return String(Math.round(n))
}}
/>
<RechartsTooltip
{...CENSUS_RECHARTS_TOOLTIP}
formatter={(value: number | string) => {
const n = Number(value)
const formatted = Number.isFinite(n) ? format(n) : String(value)
const line = metricTooltipLabel ? `${metricTooltipLabel}: ${formatted}` : formatted
return [line, '']
}}
labelFormatter={(y) => `Year ${y}`}
/>
<Line type="monotone" dataKey="value" stroke="#52796F" strokeWidth={2} dot={{ r: 3 }} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
}
function countyGeoidFromFeature(f: GeoJSON.Feature): string {
const id = f.id
if (typeof id === 'number' && Number.isFinite(id)) return String(Math.trunc(id)).padStart(5, '0')
if (typeof id === 'string' && /^\d+$/.test(id)) return id.padStart(5, '0')
const p = f.properties as Record<string, unknown> | null
const raw = p?.GEOID ?? p?.GEO_ID ?? p?.geoid
if (raw == null) return ''
const digits = String(raw).replace(/\D/g, '')
if (!digits) return ''
return digits.length <= 5 ? digits.padStart(5, '0') : digits.slice(-5).padStart(5, '0')
}
/** 7-digit place GEOID aligned with ``placeDisplayByGeoid`` / map styling. */
function placeGeoid7FromProperties(p: Record<string, unknown> | null, fallbackIdx: number): string {
const raw = String(p?.GEOID ?? '').replace(/\D/g, '')
if (!raw) return `idx_${fallbackIdx}`
return raw.length <= 7 ? raw.padStart(7, '0') : raw.slice(-7).padStart(7, '0')
}
function buildStateCountyGeoJson(
topology: { objects: { counties: unknown } },
values: Record<string, Record<string, unknown>>,
stateFips: string,
metricSlug: string,
): GeoJSONFeatureCollection | null {
try {
const fc = feature(topology as never, topology.objects.counties as never) as GeoJSON.FeatureCollection
const features: GeoJSON.Feature[] = []
for (const f of fc.features) {
const gid = countyGeoidFromFeature(f)
if (gid.length !== 5 || !gid.startsWith(stateFips)) continue
const row = values[gid] ?? {}
const base = (f.properties as Record<string, unknown> | undefined) ?? {}
const name = typeof row.NAME === 'string' ? row.NAME : typeof base.name === 'string' ? base.name : gid
const props: Record<string, unknown> = {
...base,
GEOID: gid,
NAME: name,
...row,
[metricSlug]: row[metricSlug],
}
features.push({ ...f, properties: props, id: gid })
}
if (!features.length) return null
return { type: 'FeatureCollection', features }
} catch {
return null
}
}
function censusNull(v: unknown): v is number {
return typeof v === 'number' && Number.isFinite(v)
}
function formatMetricValue(
slug: string,
v: number | null | undefined,
metrics: CensusMetric[],
valueMode: CensusValueMode = 'raw',
): string {
const s = formatMetricValueDisplay(slug, v, metrics, valueMode)
if (slug !== 'gini_income_inequality' || valueMode !== 'raw' || v == null || !Number.isFinite(v)) return s
return `${s}${giniLetterSuffix(v)}`
}
function metricValueTooltipLine(
metricLabel: string,
slug: string,
v: number | null | undefined,
metrics: CensusMetric[],
valueMode: CensusValueMode,
): string {
return `${metricLabel}: ${formatMetricValue(slug, v, metrics, valueMode)}`
}
/** County / place drill-down: fit data, clamp zoom/pan so wheel zoom cannot leave the state/city context. */
function DrilldownMapBoundsController({ data }: { data: GeoJSONFeatureCollection }) {
const map = useMap()
useEffect(() => {
if (!data?.features?.length) return
const layer = L.geoJSON(data as never)
const b = layer.getBounds()
if (!b.isValid()) return
let cancelled = false
let clampOnMoveEnd: (() => void) | null = null
/** Padding passed to getBoundsZoom (Leaflet subtracts this once from map width/height). */
const fitPad = L.point(28, 28)
const run = () => {
if (cancelled) return
map.invalidateSize()
const size = map.getSize()
if (size.x < 32 || size.y < 32) return
// “Contain” zooms bound how far users can zoom out / in while still framing the geography.
const zLoose = map.getBoundsZoom(b.pad(0.22), false)
const zTight = map.getBoundsZoom(b.pad(0.06), false)
if (!Number.isFinite(zLoose) || !Number.isFinite(zTight)) return
const zOut = Math.min(zLoose, zTight)
const zIn = Math.max(zLoose, zTight)
let safeMin = Math.max(5, Math.floor(zOut) - 1)
// Initial view: fit the whole area in the map (inside=false). Use generous bounds padding so
// narrow side-by-side layouts do not “over-zoom” into a few counties (e.g. Alabama in a short map pane).
const zFit = map.getBoundsZoom(b.pad(0.14), false, fitPad)
if (!Number.isFinite(zFit)) return
let safeMax = Math.min(13, Math.max(Math.ceil(zIn) + 1, Math.ceil(zFit) + 2))
if (safeMax <= safeMin) safeMax = safeMin + 1
// Use floor(zFit) so the entire padded bounds stay in view; avoid +1 here — it was zooming one level too tight.
let z = Math.min(safeMax, Math.max(safeMin, Math.floor(zFit)))
map.setMinZoom(safeMin)
map.setMaxZoom(safeMax)
map.setMaxBounds(b.pad(0.32))
map.options.maxBoundsViscosity = 0.75
map.setView(b.getCenter(), z, { animate: false })
if (clampOnMoveEnd) {
map.off('moveend', clampOnMoveEnd)
clampOnMoveEnd = null
}
clampOnMoveEnd = () => {
const zz = map.getZoom()
if (zz > safeMax) map.setZoom(safeMax)
else if (zz < safeMin) map.setZoom(safeMin)
}
if (!cancelled) map.once('moveend', clampOnMoveEnd)
}
const schedule = () => {
requestAnimationFrame(() => requestAnimationFrame(run))
}
map.whenReady(schedule)
const host = map.getContainer().parentElement
const ro =
typeof ResizeObserver !== 'undefined' && host
? new ResizeObserver(() => {
if (cancelled) return
requestAnimationFrame(run)
})
: null
if (ro && host) ro.observe(host)
return () => {
cancelled = true
ro?.disconnect()
if (clampOnMoveEnd) map.off('moveend', clampOnMoveEnd)
map.setMinZoom(5)
map.setMaxZoom(13)
map.setMaxBounds(null as any)
map.options.maxBoundsViscosity = undefined
}
}, [map, data])
return null
}
function featureLatLng(feature: GeoJSON.Feature): { lat: number; lng: number } | null {
try {
const layer = L.geoJSON(feature as never)
const c = layer.getBounds().getCenter()
if (c && Number.isFinite(c.lat) && Number.isFinite(c.lng)) return { lat: c.lat, lng: c.lng }
} catch {
/* ignore */
}
return null
}
const CHORO_LEGEND_GRADIENT_STOPS = 17
function ChoroplethLegend({
min,
max,
scale,
format,
valueMode = 'raw',
extentPoolsAllVintages = false,
metricHelp,
semantics,
letterGradeLegend,
}: {
min: number
max: number
scale: CensusScaleId
format: (v: number) => string
valueMode?: CensusValueMode
/** When trend sidecars are used, min/max are from all vintages pooled (still ~4th–96th pct.). */
extentPoolsAllVintages?: boolean
metricHelp?: string
/** Plain-language ends of the ramp + one sentence on how to read color intensity. */
semantics?: CensusChoroLegendSemantics | null
/** Optional strip (e.g. Gini A–F grades) shown under the semantics line. */
letterGradeLegend?: import('react').ReactNode
}) {
const legendHelpPanelId = useId()
const [legendHelpOpen, setLegendHelpOpen] = useState(false)
const n = CHORO_LEGEND_GRADIENT_STOPS
const stops = Array.from({ length: n }, (_, i) => {
const u = n <= 1 ? 0 : i / (n - 1)
const v = min + u * (max - min)
const t = metricToDisplayT(v, min, max, scale) ?? 0
return { offset: `${u * 100}%`, color: colorFromT(t), value: v }
})
const tickUs = [0, 0.25, 0.5, 0.75, 1] as const
const gradId = `census-ramp-${scale}-${Math.round(min)}-${Math.round(max)}`
const legendInfoBody = [
'Legend maps the displayed value to color using the selected transform. When multi-year data is loaded, low/high can be pooled across years so colors stay comparable as you change year.',
metricHelp,
]
.filter(Boolean)
.join('\n\n')
const legendFootnote = `Stops are evenly spaced in mapped value range; shading follows the selected transform (${CENSUS_SCALES.find((x) => x.id === scale)?.label ?? scale}). ${
valueMode === 'raw'
? extentPoolsAllVintages
? 'Percentile band (~4th–96th pct.) is computed across all years in the slider when multi-year trend data is present, so colors stay comparable as you change year.'
: 'Extremes use percentile clipping so outliers do not wash out the map.'
: valueMode === 'yoy'
? 'Legend shows percent change vs the prior year in the slider order.'
: 'Legend shows percent difference from the national benchmark (population-weighted state composite when available).'
}${
extentPoolsAllVintages && valueMode !== 'raw'
? ' Endpoints pool all years from trend data so the scale stays fixed while you scrub the year slider.'
: ''
}`
return (
<div className="rounded-lg border border-slate-200 bg-slate-50/80 p-3 shadow-sm">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-600 mb-2">
<SwatchIcon className="h-4 w-4 shrink-0" />
{metricHelp ? (
<LabelWithInfo label="What the colors mean" help={metricHelp} />
) : (
<span>What the colors mean</span>
)}
</div>
{semantics ? (
<>
<div className="mb-1 flex items-center justify-between gap-2 px-0.5 text-[10px] font-semibold text-slate-700">
<span className="min-w-0 text-left leading-tight">{semantics.lowEnd}</span>
<span className="shrink-0 text-slate-400" aria-hidden>
←→
</span>
<span className="min-w-0 text-right leading-tight">{semantics.highEnd}</span>
</div>
<p className="mb-2 text-[10px] leading-snug text-slate-600">{semantics.gradientHint}</p>
</>
) : null}
<svg width="100%" height="52" viewBox="0 0 260 52" preserveAspectRatio="xMidYMid meet" className="max-w-full">
<defs>
<linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="0%">
{stops.map((s, i) => (
<stop key={i} offset={s.offset} stopColor={s.color} />
))}
</linearGradient>
</defs>
<rect x="8" y="8" width="244" height="14" rx="3" fill={`url(#${gradId})`} stroke="#94a3b8" strokeWidth="0.5" />
{tickUs.map((frac) => (
<text key={frac} x={8 + frac * 244} y="44" fontSize="9" fill="#475569" textAnchor="middle">
{format(min + frac * (max - min))}
</text>
))}
</svg>
{letterGradeLegend ? (
<div className="mt-2 border-t border-slate-200/90 pt-2">{letterGradeLegend}</div>
) : null}
<div className="mt-2">
<button
type="button"
className="inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-amber-200/90 bg-amber-50/60 px-2.5 py-1.5 text-xs font-medium text-amber-950/90 shadow-sm hover:bg-amber-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-2 sm:w-auto sm:justify-start"
aria-expanded={legendHelpOpen}
aria-controls={legendHelpPanelId}
onClick={() => setLegendHelpOpen((v) => !v)}
>
<span>How to read this legend</span>
<span
className={`text-[10px] text-slate-500 transition-transform ${legendHelpOpen ? 'rotate-180' : ''}`}
aria-hidden
>
</span>
</button>
{legendHelpOpen ? (
<div
id={legendHelpPanelId}
className="mt-2 rounded-md border border-amber-100/90 bg-amber-50/45 px-2.5 py-2 space-y-2"
role="region"
aria-label="How to read this legend"
>
<p className="text-[11px] text-slate-800 leading-snug whitespace-pre-wrap">{legendInfoBody}</p>
<p className="text-[10px] text-slate-600 leading-snug">{legendFootnote}</p>
</div>
) : null}
</div>
</div>
)
}
function BubbleLegend({
min,
max,
scale,
format,
metricHelp,
letterGradeLegend,
}: {
min: number
max: number
scale: CensusScaleId
format: (v: number) => string
metricHelp?: string
letterGradeLegend?: import('react').ReactNode
}) {
const legendHelpPanelId = useId()
const [legendHelpOpen, setLegendHelpOpen] = useState(false)
const refs = [0.15, 0.5, 0.88].map((u) => min + u * (max - min))
const items = refs.map((v) => ({
v,
r: bubbleRadiusPx(v, min, max, scale, 4, 22),
t: metricToDisplayT(v, min, max, scale),
label: format(v),
}))
const bubbleInfoBody = [
'Circle area encodes the mapped value; color follows the Deep Ocean ramp (steel blue → teal → deep emerald) as on the map.',
metricHelp,
]
.filter(Boolean)
.join('\n\n')
return (
<div className="rounded-lg border border-slate-200 bg-slate-50/80 p-3 shadow-sm">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-600 mb-2">
{metricHelp ? (
<LabelWithInfo label="Bubble size scale" help={metricHelp} />
) : (
<span>Bubble size scale</span>
)}
</div>
<div className="flex items-end justify-around gap-2 px-2" style={{ height: 56 }}>
{items.map((it, i) => (
<div key={i} className="flex flex-col items-center gap-1">
<div
className="rounded-full border border-white shadow"
style={{
width: it.r * 2,
height: it.r * 2,
backgroundColor: bubbleFillFromT(it.t, 0.9),
}}
/>
<span className="text-[10px] text-slate-600 text-center max-w-[72px] leading-tight">{it.label}</span>
</div>
))}
</div>
{letterGradeLegend ? (
<div className="mt-2 border-t border-slate-200/90 pt-2">{letterGradeLegend}</div>
) : null}
<div className="mt-2">
<button
type="button"
className="inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-amber-200/90 bg-amber-50/60 px-2.5 py-1.5 text-xs font-medium text-amber-950/90 shadow-sm hover:bg-amber-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-2 sm:w-auto sm:justify-start"
aria-expanded={legendHelpOpen}
aria-controls={legendHelpPanelId}
onClick={() => setLegendHelpOpen((v) => !v)}
>
<span>How to read this legend</span>
<span
className={`text-[10px] text-slate-500 transition-transform ${legendHelpOpen ? 'rotate-180' : ''}`}
aria-hidden
>
</span>
</button>
{legendHelpOpen ? (
<div
id={legendHelpPanelId}
className="mt-2 rounded-md border border-amber-100/90 bg-amber-50/45 px-2.5 py-2"
role="region"
aria-label="How to read this legend"
>
<p className="text-[11px] text-slate-800 leading-snug whitespace-pre-wrap">{bubbleInfoBody}</p>
</div>
) : null}
</div>
</div>
)
}
/** When the slider has 2+ vintages (from manifest or trend sidecars), advance years with Play. */
const PLAY_INTERVAL_MS = 1950
function CensusMapPage() {
const navigate = useNavigate()
const location = useLocation()
const [searchParams, setSearchParams] = useSearchParams()
const { vintage, metric, stateFips: stateFipsRaw } = useParams<{
vintage?: string
metric?: string
stateFips?: string
}>()
const stateFips = stateFipsRaw
? normalizeStateFips(stateFipsRaw) ?? (stateFipsRaw.length <= 2 ? stateFipsRaw.padStart(2, '0') : stateFipsRaw)
: undefined
const stateUsps = stateFips ? FIPS2_TO_USPS[stateFips] : undefined
const stateName = stateUsps ? STATE_CODE_TO_NAME[stateUsps] : undefined
const mapPrefix = useMemo(() => censusMapPathPrefix(location.pathname), [location.pathname])
const mode = useMemo(() => {
if (location.pathname.includes('/census-map/place/') || location.pathname.includes('/data-explorer/map/place/'))
return 'place'
if (location.pathname.includes('/census-map/state/') || location.pathname.includes('/data-explorer/map/state/'))
return 'stateCounty'
return 'us'
}, [location.pathname])
const viz: 'filled' | 'bubble' = searchParams.get('viz') === 'bubble' ? 'bubble' : 'filled'
const scaleRaw = searchParams.get('scale') || 'linear'
const scale: CensusScaleId = (['linear', 'sqrt', 'log', 'exp'].includes(scaleRaw) ? scaleRaw : 'linear') as CensusScaleId
const setViz = (v: 'filled' | 'bubble') => {
const next = new URLSearchParams(searchParams)
if (v === 'filled') next.delete('viz')
else next.set('viz', 'bubble')
setSearchParams(next, { replace: true })
}
const setScale = (s: CensusScaleId) => {
const next = new URLSearchParams(searchParams)
if (s === 'linear') next.delete('scale')
else next.set('scale', s)
setSearchParams(next, { replace: true })
}
const valueModeRaw = searchParams.get('valueMode') || 'raw'
const valueMode: CensusValueMode = (
['raw', 'yoy', 'vs_natl'].includes(valueModeRaw) ? valueModeRaw : 'raw'
) as CensusValueMode
const setValueMode = (m: CensusValueMode) => {
const next = new URLSearchParams(searchParams)
if (m === 'raw') next.delete('valueMode')
else next.set('valueMode', m)
setSearchParams(next, { replace: true })
}
const [advancedMapOptionsOpen, setAdvancedMapOptionsOpen] = useState(false)
const [advancedFocusSection, setAdvancedFocusSection] = useState<'year' | 'view' | 'scale' | 'values' | null>(null)
const [pinnedAddress, setPinnedAddress] = useState<{
displayName: string
shortLabel: string
lat: number
lon: number
stateCode: string | null
stateFips: string | null
county: string
city: string
} | null>(null)
const [censusOnboardingDismissed, setCensusOnboardingDismissed] = useState(() => {
if (typeof window === 'undefined') return false
try {
return window.localStorage.getItem('open-navigator-census-map-onboarding-dismissed') === '1'
} catch {
return false
}
})
const dismissCensusOnboarding = useCallback(() => {
try {
if (typeof window !== 'undefined') {
window.localStorage.setItem('open-navigator-census-map-onboarding-dismissed', '1')
}
} catch {
/* ignore */
}
setCensusOnboardingDismissed(true)
}, [])
const { data: manifest, isError: manifestError } = useQuery({
queryKey: ['census-map-manifest'],
queryFn: async (): Promise<CensusManifest> => {
const r = await fetch('/data/census-map/manifest.json')
if (!r.ok) throw new Error('manifest')
return r.json()
},
})
const metricSlug = metric ?? 'median_household_income'
const metricSlugsList = useMemo(() => (manifest?.metrics ?? []).map((m) => m.slug), [manifest?.metrics])
const { data: stateTrends, isFetched: stateTrendsFetched } = useQuery({
queryKey: ['census-state-trends'],
queryFn: async (): Promise<StateTrendsPayload | null> => {
const r = await fetch('/data/census-map/state_trends.json')
if (r.status === 404) return null
if (!r.ok) throw new Error('state trends')
return r.json() as StateTrendsPayload
},
enabled: !!manifest,
retry: false,
})
const { data: countyTrends, isFetched: countyTrendsFetched } = useQuery({
queryKey: ['census-county-trends', stateFips],
queryFn: async (): Promise<CountyPlaceTrendsPayload | null> => {
const r = await fetch(`/data/census-map/county_trends_${stateFips}.json`)
if (r.status === 404) return null
if (!r.ok) throw new Error('county trends')
return r.json() as CountyPlaceTrendsPayload
},
enabled: !!manifest && mode === 'stateCounty' && !!stateFips,
retry: false,
})
const { data: placeTrends, isFetched: placeTrendsFetched } = useQuery({
queryKey: ['census-place-trends', stateFips],
queryFn: async (): Promise<CountyPlaceTrendsPayload | null> => {
const r = await fetch(`/data/census-map/place_trends_${stateFips}.json`)
if (r.status === 404) return null
if (!r.ok) throw new Error('place trends')
return r.json() as CountyPlaceTrendsPayload
},
enabled: !!manifest && mode === 'place' && !!stateFips,
retry: false,
})
const stateTrendsDriveStatePayload =
mode === 'us' && !!stateTrends && stateHasAnySeriesForSlug(stateTrends, metricSlug)
const countyTrendsDriveCountyPayload =
mode === 'stateCounty' &&
!!countyTrends &&
!!stateFips &&
Object.entries(countyTrends.byGeoid).some(
([gid, row]) =>
gid.startsWith(stateFips.padStart(2, '0')) &&
metricHasTrendSeriesInRow(row as Record<string, unknown>, metricSlug),
)
const vintages = useMemo(() => {
if (!manifest) return ['2022']
return sliderVintages({
mode,
manifest,
metricSlug,
stateTrends,
countyTrends,
placeTrends,
stateFips,
})
}, [mode, manifest, metricSlug, stateTrends, countyTrends, placeTrends, stateFips])
const effectiveVintage = useMemo(() => {
if (!manifest) return vintage ?? '2022'
const list = vintages
if (!list.length) return vintage ?? manifest.vintage ?? '2022'
const latest = list[list.length - 1]!
if (!vintage) return latest
if (list.includes(vintage)) return vintage
return latest
}, [manifest, vintage, vintages])
const [playing, setPlaying] = useState(false)
const [animIndex, setAnimIndex] = useState(0)
const vintagesRef = useRef(vintages)
vintagesRef.current = vintages
/** While dragging the year slider, update charts immediately; URL follows on a short debounce. */
const [scrubVintage, setScrubVintage] = useState<string | null>(null)
const vintageNavigateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => {
if (vintageNavigateTimerRef.current) window.clearTimeout(vintageNavigateTimerRef.current)
}
}, [])
useEffect(() => {
if (scrubVintage != null && vintage === scrubVintage) setScrubVintage(null)
}, [vintage, scrubVintage])
useEffect(() => {
setScrubVintage(null)
}, [metricSlug, mode, stateFips])
useEffect(() => {
if (playing) return
const ix = vintages.indexOf(effectiveVintage)
setAnimIndex(ix >= 0 ? ix : 0)
}, [effectiveVintage, vintages.join(','), playing])
const vintagesPlayKeyRef = useRef<string>('')
const animIndexPlayRef = useRef(0)
animIndexPlayRef.current = animIndex
useLayoutEffect(() => {
const key = vintages.join(',')
if (playing) {
const prevKey = vintagesPlayKeyRef.current
if (prevKey && prevKey !== key) {
const prevList = prevKey.split(',')
const safeIx = Math.min(
Math.max(0, animIndexPlayRef.current),
Math.max(0, prevList.length - 1),
)
const heldYear = prevList[safeIx]
if (heldYear) {
const nextIx = indexForHeldYearInNewVintages(vintages, heldYear)
setAnimIndex(nextIx)
}
}
}
vintagesPlayKeyRef.current = key
}, [playing, vintages])
const canPlayMultiVintage =
vintages.length > 1 &&
(mode === 'us' || (mode === 'stateCounty' && !!stateFips) || (mode === 'place' && !!stateFips))
const canTrendAnimate = canPlayMultiVintage
const displayVintage = useMemo(() => {
if (playing && canTrendAnimate && vintages.length) {
const ix = Math.min(Math.max(0, animIndex), vintages.length - 1)
return vintages[ix]!
}
if (scrubVintage && vintages.includes(scrubVintage)) return scrubVintage
return effectiveVintage
}, [playing, canTrendAnimate, animIndex, vintages, scrubVintage, effectiveVintage])
useEffect(() => {
if (!playing || !canTrendAnimate) return
const t = window.setInterval(() => {
setAnimIndex((i) => {
const list = vintagesRef.current
if (!list.length) return 0
const last = list.length - 1
if (i >= last) {
queueMicrotask(() => setPlaying(false))
return last
}
return i + 1
})
}, PLAY_INTERVAL_MS)
return () => window.clearInterval(t)
}, [playing, canTrendAnimate])
useEffect(() => {
setPlaying(false)
}, [effectiveVintage, mode, metricSlug, valueMode])
useEffect(() => {
if (valueMode !== 'raw' && viz === 'bubble') {
const next = new URLSearchParams(searchParams)
next.delete('viz')
setSearchParams(next, { replace: true })
}
}, [valueMode, viz, searchParams, setSearchParams])
const showPlay = canTrendAnimate
const { data: statePayloadRaw } = useQuery({
queryKey: ['census-state-metrics', displayVintage],
queryFn: async (): Promise<StateMetricsPayload> => {
const r = await fetch(`/data/census-map/${displayVintage}/state_metrics.json`)
if (!r.ok) throw new Error('state metrics')
return r.json()
},
enabled: mode === 'us' && !!displayVintage && !stateTrendsDriveStatePayload,
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 60,
retry: false,
})
const statePayload = useMemo(() => {
if (mode === 'us' && stateTrendsDriveStatePayload && stateTrends && displayVintage) {
return stateMetricsFromTrends(stateTrends, displayVintage, metricSlugsList)
}
return statePayloadRaw
}, [mode, stateTrendsDriveStatePayload, stateTrends, statePayloadRaw, displayVintage, metricSlugsList])
const { data: countyPayloadRaw, isError: countyPayloadError, isPending: countyPayloadLoading } = useQuery({
queryKey: ['census-county-metrics', displayVintage],
queryFn: async (): Promise<CountyMetricsPayload> => {
const r = await fetch(`/data/census-map/${displayVintage}/county_metrics.json`)
if (!r.ok) throw new Error('county metrics')
return r.json()
},
enabled: mode === 'stateCounty' && !!displayVintage && !countyTrendsDriveCountyPayload,
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 60,
retry: false,
})
const countyPayload = useMemo(() => {
if (mode === 'stateCounty' && countyTrendsDriveCountyPayload && countyTrends && stateFips && displayVintage) {
return countyMetricsFromTrends(countyTrends, displayVintage, metricSlugsList, stateFips)
}
return countyPayloadRaw
}, [mode, countyTrendsDriveCountyPayload, countyTrends, countyPayloadRaw, displayVintage, metricSlugsList, stateFips])
const { data: countyTopo, isPending: countyTopoLoading } = useQuery({
queryKey: ['census-county-topo', manifest?.county_topo_cdn],
queryFn: async () => {
const u = manifest!.county_topo_cdn || COUNTY_TOPO
const r = await fetch(u)
if (!r.ok) throw new Error('county topo')
return r.json()
},
enabled: mode === 'stateCounty' && !!manifest,
staleTime: Infinity,
})
const placeUrl =
mode === 'place' && stateFips
? `/data/census-map/${displayVintage}/place_${stateFips}.geojson`
: null
const { data: placeGeo, isError: placeGeoError } = useQuery({
queryKey: ['census-place-geo', placeUrl],
queryFn: async (): Promise<GeoJSONFeatureCollection> => {
const r = await fetch(placeUrl!)
if (!r.ok) throw new Error('place geojson')
return r.json()
},
enabled: mode === 'place' && !!placeUrl,
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 60,
retry: false,
})
const placeGeoMerged = useMemo(
() => mergePlaceGeoWithTrends(placeGeo, placeTrends ?? undefined, displayVintage, metricSlugsList),
[placeGeo, placeTrends, displayVintage, metricSlugsList],
)
const [countyHover, setCountyHover] = useState<{ id: string; name: string; value: number | null } | null>(null)
const [placeHover, setPlaceHover] = useState<{ id: string; name: string; value: number | null } | null>(null)
/** Bar-chart selection: highlight matching area on the map (click same bar again to clear). */
const [leaderboardPinnedId, setLeaderboardPinnedId] = useState<string | null>(null)
useEffect(() => {
setLeaderboardPinnedId(null)
}, [mode, stateFips, metricSlug])
const toggleLeaderboardPin = useCallback((id: string) => {
setLeaderboardPinnedId((prev) => (prev === id ? null : id))
}, [])
const placePinnedLabel = useMemo((): string | null => {
if (mode !== 'place' || !leaderboardPinnedId || !placeGeoMerged?.features.length) return null
const pid = leaderboardPinnedId
for (const f of placeGeoMerged.features) {
const p = f.properties as Record<string, unknown> | null
if (placeGeoid7FromProperties(p, 0) === pid) {
const n = String(p?.NAME ?? '').trim()
return n || null
}
}
return null
}, [mode, leaderboardPinnedId, placeGeoMerged])
const focusPinnedStateNarrative = useMemo(() => {
if (mode !== 'us' || leaderboardPinnedId == null || leaderboardPinnedId === '' || !statePayload?.values)
return null
const sid = normalizeStateFips(leaderboardPinnedId) ?? leaderboardPinnedId
const row = statePayload.values[sid]
if (!row) return null
const name = typeof row.NAME === 'string' && row.NAME.trim() ? row.NAME.trim() : sid
return { geoid: sid, name }
}, [mode, leaderboardPinnedId, statePayload])
const manifestMetrics = manifest?.metrics ?? []
const selectableMetrics = useMemo(
() => manifestMetrics.filter((m) => !CENSUS_EXPLORER_HIDDEN_METRIC_SLUGS.has(m.slug)),
[manifestMetrics],
)
const metrics = manifestMetrics
const placeStates = manifest?.place_states ?? []
const currentMetricMeta = useMemo(() => metrics.find((m) => m.slug === metricSlug), [metrics, metricSlug])
const metricFullHelp = useMemo(
() => censusMetricFullHelp(metricSlug, currentMetricMeta),
[metricSlug, currentMetricMeta],
)
const choroLegendSemantics = useMemo(
() => censusChoroLegendSemantics(metricSlug, valueMode, currentMetricMeta?.label ?? metricSlug),
[metricSlug, valueMode, currentMetricMeta?.label],
)
const giniRawLetterLegend = useMemo(
() => (metricSlug === 'gini_income_inequality' && valueMode === 'raw' ? <GiniIncomeInequalityLetterLegend /> : null),
[metricSlug, valueMode],
)
const nationalContextNote = useMemo(() => {
if (valueMode !== 'raw' || mode !== 'us') return null
const baseline = nationalBaselineWithFallback(manifest?.national_ref, displayVintage, metricSlug, {
stateRows: statePayload?.values,
stateTrends,
})
if (baseline == null || !Number.isFinite(baseline)) return null
const label = currentMetricMeta?.label ?? metricSlug
const formatted = formatMetricValue(metricSlug, baseline, metrics, 'raw')
return `National benchmark for ${label}: ${formatted} (population-weighted U.S. composite when available).`
}, [valueMode, mode, manifest?.national_ref, displayVintage, metricSlug, currentMetricMeta?.label, metrics, statePayload, stateTrends])
const [hoverRegion, setHoverRegion] = useState<{
id: string
name: string
value: number | null
} | null>(null)
const trendAsideCapturingHoverRef = useRef(false)
const hoverClearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const cancelScheduledHoverClear = useCallback(() => {
if (hoverClearTimerRef.current != null) {
window.clearTimeout(hoverClearTimerRef.current)
hoverClearTimerRef.current = null
}
}, [])
const scheduleHoverRegionClear = useCallback(() => {
cancelScheduledHoverClear()
hoverClearTimerRef.current = window.setTimeout(() => {
hoverClearTimerRef.current = null
if (!trendAsideCapturingHoverRef.current) {
setHoverRegion(null)
}
}, 240)
}, [cancelScheduledHoverClear])
useEffect(() => {
return () => {
cancelScheduledHoverClear()
}
}, [cancelScheduledHoverClear])
useEffect(() => {
cancelScheduledHoverClear()
}, [metricSlug, mode, cancelScheduledHoverClear])
const usMapInnerRef = useRef<HTMLDivElement | null>(null)
const [usMapTipPos, setUsMapTipPos] = useState<{ x: number; y: number } | null>(null)
const updateUsMapTip = useCallback((clientX: number, clientY: number) => {
const el = usMapInnerRef.current
if (!el) return
const r = el.getBoundingClientRect()
const lx = clientX - r.left + el.scrollLeft
const ly = clientY - r.top + el.scrollTop
const estW = 280
const estH = 132
let left = lx + 14
let top = ly + 14
if (left + estW > el.scrollWidth - 8) left = Math.max(8, lx - estW - 14)
if (top + estH > el.scrollTop + el.clientHeight - 8) top = Math.max(8, ly - estH - 14)
left = Math.max(8, Math.min(left, Math.max(8, el.scrollWidth - estW - 8)))
top = Math.max(8, Math.min(top, Math.max(8, el.scrollTop + el.clientHeight - estH - 8)))
setUsMapTipPos({ x: left, y: top })
}, [])
const [tableSort, setTableSort] = useState<{ key: 'name' | 'value' | 'geoid'; dir: 'asc' | 'desc' }>(() => ({
key: 'value',
dir: censusMetricRankDirection(metricSlug) === 'lower' ? 'asc' : 'desc',
}))
useEffect(() => {
setTableSort((prev) =>
prev.key === 'value'
? { key: 'value', dir: censusMetricRankDirection(metricSlug) === 'lower' ? 'asc' : 'desc' }
: prev,
)
}, [metricSlug])
const reduceMotion = useReducedMotion()
const trendChartOpenCounty = Boolean(countyTrends && countyHover && stateFips)
const trendFadeTransition = { duration: reduceMotion ? 0 : 0.28, ease: 'easeInOut' }
const stateDisplayById = useMemo(() => {
const out: Record<string, number | null> = {}
if (!statePayload || !metricSlug) return out
const prevV = prevVintageInList(vintages, displayVintage)
const nat = nationalBaselineWithFallback(manifest?.national_ref, displayVintage, metricSlug, {
stateRows: statePayload.values,
stateTrends,
})
for (const [sid, row] of Object.entries(statePayload.values)) {
const raw = typeof row[metricSlug] === 'number' && Number.isFinite(row[metricSlug]) ? row[metricSlug] : null
let prev: number | null = null
if (valueMode === 'yoy' && prevV && stateTrends?.by_state?.[sid]) {
prev = trendCell((stateTrends.by_state[sid] as Record<string, unknown>)[metricSlug], prevV)
}
out[sid] = displayValueForMode(valueMode, raw, prev, nat)
}
return out
}, [
statePayload,
metricSlug,
valueMode,
displayVintage,
vintages.join(','),
stateTrends,
manifest?.national_ref,
])
useEffect(() => {
setHoverRegion((prev) => {
if (!prev?.id) return prev
const row = statePayload?.values?.[prev.id]
const name = row && typeof row.NAME === 'string' ? row.NAME : prev.name
const disp = stateDisplayById[prev.id] ?? null
if (name === prev.name && disp === prev.value) return prev
return { ...prev, name, value: disp }
})
}, [statePayload, stateDisplayById, displayVintage, metricSlug])
/** US trend line: hovered state, or the bar-chart pinned state when the pointer has left the map. */
const usTrendSubject = useMemo(() => {
if (mode !== 'us') return null
if (hoverRegion) return hoverRegion
if (leaderboardPinnedId != null && leaderboardPinnedId !== '' && statePayload?.values) {
const sid = normalizeStateFips(leaderboardPinnedId) ?? leaderboardPinnedId
const row = statePayload.values[sid]
if (!row) return null
const name = typeof (row as { NAME?: string }).NAME === 'string' ? (row as { NAME: string }).NAME : sid
const disp = stateDisplayById[sid] ?? null
return { id: sid, name, value: disp }
}
return null
}, [mode, hoverRegion, leaderboardPinnedId, statePayload, stateDisplayById])
const trendChartOpenUs = Boolean(stateTrends && usTrendSubject)
const stateChoroPooledForLegend = useMemo((): number[] | null => {
if (mode !== 'us' || !stateTrends || !metricSlug || vintages.length < 2) return null
const arr = collectAllVintageDisplayValuesState(
stateTrends,
vintages,
metricSlug,
valueMode,
manifest?.national_ref,
)
return arr.length >= 20 ? arr : null
}, [mode, stateTrends, vintages.join(','), metricSlug, valueMode, manifest?.national_ref])
const stateChoroExtent = useMemo(() => {
if (stateChoroPooledForLegend?.length) return quantileExtent(stateChoroPooledForLegend)
const vals = Object.values(stateDisplayById).filter(
(x): x is number => typeof x === 'number' && Number.isFinite(x),
)
if (!vals.length) return { min: 0, max: 1 }
return quantileExtent(vals)
}, [stateChoroPooledForLegend, stateDisplayById])
const stateBubbleExtent = useMemo(() => {
if (!statePayload || !metricSlug) return { min: 0, max: 1 }
const displayVals = Object.values(stateDisplayById).filter(
(x): x is number => typeof x === 'number' && Number.isFinite(x),
)
if (displayVals.length >= 2) {
return minMaxExtent(displayVals)
}
const vals = Object.values(statePayload.values)
.map((row) => row[metricSlug])
.filter((x): x is number => typeof x === 'number' && Number.isFinite(x))
if (!vals.length) return { min: 0, max: 1 }
return minMaxExtent(vals)
}, [statePayload, metricSlug, stateDisplayById])
const placeDisplayByGeoid = useMemo(() => {
const out: Record<string, number | null> = {}
const g = placeGeoMerged
if (!g || !metricSlug) return out
const prevV = prevVintageInList(vintages, displayVintage)
const nat = nationalBaselineWithFallback(manifest?.national_ref, displayVintage, metricSlug, {
stateRows: statePayload?.values,
stateTrends,
})
for (const f of g.features) {
const p = f.properties as Record<string, unknown> | null
const raw = typeof p?.[metricSlug] === 'number' && Number.isFinite(p[metricSlug]) ? p[metricSlug] : null
const rawG = String(p?.GEOID ?? '').replace(/\D/g, '')
const gid7 = rawG.length <= 7 ? rawG.padStart(7, '0') : rawG.slice(-7).padStart(7, '0')
let prev: number | null = null
if (valueMode === 'yoy' && prevV && placeTrends?.byGeoid?.[gid7]) {
prev = trendCell((placeTrends.byGeoid[gid7] as Record<string, unknown>)[metricSlug], prevV)
}
out[gid7] = displayValueForMode(valueMode, raw, prev, nat)
}
return out
}, [
placeGeoMerged,
metricSlug,
valueMode,
displayVintage,
vintages.join(','),
placeTrends,
manifest?.national_ref,
stateTrends,
statePayload,
])
const placeTrendSubject = useMemo(() => {
if (mode !== 'place') return null
if (placeHover) return placeHover
if (leaderboardPinnedId && placeGeoMerged?.features.length) {
for (const f of placeGeoMerged.features) {
const p = f.properties as Record<string, unknown> | null
const gid7 = placeGeoid7FromProperties(p, 0)
if (gid7 === leaderboardPinnedId) {
const name = String(p?.NAME ?? gid7)
const value = placeDisplayByGeoid[gid7] ?? null
return { id: gid7, name, value }
}
}
}
return null
}, [mode, placeHover, leaderboardPinnedId, placeGeoMerged, placeDisplayByGeoid])
const trendChartOpenPlace = Boolean(placeTrends && placeTrendSubject)
const placeChoroPooledForLegend = useMemo((): number[] | null => {
if (mode !== 'place' || !placeTrends || !stateFips || !metricSlug || vintages.length < 2) return null
const arr = collectAllVintageDisplayValuesPlace(
placeTrends,
stateFips,
vintages,
metricSlug,
valueMode,
manifest?.national_ref,
stateTrends,
)
return arr.length >= 20 ? arr : null
}, [mode, placeTrends, stateFips, vintages.join(','), metricSlug, valueMode, manifest?.national_ref, stateTrends])
const placeChoroExtent = useMemo(() => {
if (placeChoroPooledForLegend?.length) return quantileExtent(placeChoroPooledForLegend)
const vals = Object.values(placeDisplayByGeoid).filter(
(x): x is number => typeof x === 'number' && Number.isFinite(x),
)
if (!vals.length) return { min: 0, max: 1 }
return quantileExtent(vals)
}, [placeChoroPooledForLegend, placeDisplayByGeoid])
const placeBubbleExtent = useMemo(() => {
const g = placeGeoMerged
if (!g || !metricSlug) return { min: 0, max: 1 }
const displayVals = Object.values(placeDisplayByGeoid).filter(
(x): x is number => typeof x === 'number' && Number.isFinite(x),
)
if (displayVals.length >= 2) {
return minMaxExtent(displayVals)
}
const vals = g.features
.map((f) => {
const v = (f.properties as Record<string, unknown> | null)?.[metricSlug]
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
.filter((x): x is number => x != null)
if (!vals.length) return { min: 0, max: 1 }
return minMaxExtent(vals)
}, [placeGeoMerged, metricSlug, placeDisplayByGeoid])
const placeRankByGeoid = useMemo(() => {
const m = new Map<string, { rank: number; total: number }>()
const g = placeGeoMerged
if (!g) return m
const rows: { id: string; value: number }[] = []
for (const f of g.features) {
const p = f.properties as Record<string, unknown> | null
const gid7 = placeGeoid7FromProperties(p, 0)
const v = placeDisplayByGeoid[gid7]
if (typeof v === 'number' && Number.isFinite(v)) rows.push({ id: gid7, value: v })
}
rows.sort((a, b) => compareRankedMetricValues(a.value, b.value, metricSlug))
const total = rows.length
rows.forEach((r, i) => m.set(r.id, { rank: i + 1, total }))
return m
}, [placeGeoMerged, placeDisplayByGeoid, metricSlug])
const stateCountyGeo = useMemo(() => {
if (mode !== 'stateCounty' || !stateFips || !countyTopo || !countyPayload?.values) return null
return buildStateCountyGeoJson(countyTopo, countyPayload.values, stateFips, metricSlug)
}, [mode, stateFips, countyTopo, countyPayload, metricSlug])
const countyDisplayByGeoid = useMemo(() => {
const out: Record<string, number | null> = {}
if (!stateCountyGeo || !metricSlug) return out
const prevV = prevVintageInList(vintages, displayVintage)
const nat = nationalBaselineWithFallback(manifest?.national_ref, displayVintage, metricSlug, {
stateRows: statePayload?.values,
stateTrends,
})
for (const f of stateCountyGeo.features) {
const p = f.properties as Record<string, unknown> | null
const gid = countyGeoidFromFeature(f as GeoJSON.Feature)
const raw = typeof p?.[metricSlug] === 'number' && Number.isFinite(p[metricSlug]) ? p[metricSlug] : null
let prev: number | null = null
if (valueMode === 'yoy' && prevV && countyTrends?.byGeoid?.[gid]) {
prev = trendCell((countyTrends.byGeoid[gid] as Record<string, unknown>)[metricSlug], prevV)
}
out[gid] = displayValueForMode(valueMode, raw, prev, nat)
}
return out
}, [
stateCountyGeo,
metricSlug,
valueMode,
displayVintage,
vintages.join(','),
countyTrends,
manifest?.national_ref,
stateTrends,
statePayload,
])
const focusPinnedCountyNarrative = useMemo(() => {
if (
mode !== 'stateCounty' ||
leaderboardPinnedId == null ||
leaderboardPinnedId === '' ||
!stateCountyGeo?.features?.length
)
return null
for (const f of stateCountyGeo.features) {
const gid = countyGeoidFromFeature(f as GeoJSON.Feature)
if (gid !== leaderboardPinnedId) continue
const p = (f as GeoJSON.Feature).properties as Record<string, unknown> | null
const name = String(p?.NAME ?? gid).trim() || gid
return { geoid: gid, name }
}
return { geoid: leaderboardPinnedId, name: leaderboardPinnedId }
}, [mode, leaderboardPinnedId, stateCountyGeo])
const narrativePack = useMemo((): CensusNarrativePack => {
const label = currentMetricMeta?.label ?? metricSlug
const region =
mode === 'us'
? 'United States'
: stateName && String(stateName).trim()
? String(stateName)
: stateFips
? `State ${stateFips}`
: 'This area'
const geoLevel = mode === 'us' ? 'us_states' : mode === 'stateCounty' ? 'counties' : 'places'
const focusFips = stateFips ? normalizeStateFips(stateFips) ?? stateFips : ''
const stRow = focusFips ? stateTrends?.by_state?.[focusFips] : undefined
const rawSeries = stRow?.[metricSlug]
const stateMetricSeries =
rawSeries != null && typeof rawSeries === 'object' && !Array.isArray(rawSeries)
? (rawSeries as Record<string, unknown>)
: undefined
const focusState =
(mode === 'stateCounty' || mode === 'place') && stateFips
? { stateName: region, stateFips: focusFips, stateMetricSeries }
: null
return buildCensusNarrativePack({
geoLevel,
regionDisplayName: region,
metricLabel: label,
metricSlug,
displayVintage,
viz,
valueMode,
nationalRef: manifest?.national_ref,
vintages,
focusState,
focusPlaceName: mode === 'place' ? placePinnedLabel : null,
focusPinnedState: focusPinnedStateNarrative,
focusPinnedCounty: focusPinnedCountyNarrative,
})
}, [
mode,
stateName,
stateFips,
currentMetricMeta?.label,
metricSlug,
displayVintage,
viz,
valueMode,
manifest?.national_ref,
vintages.join(','),
stateTrends,
placePinnedLabel,
focusPinnedStateNarrative,
focusPinnedCountyNarrative,
])
const mapHeadingSelectionActive =
leaderboardPinnedId != null && String(leaderboardPinnedId).trim() !== ''
const censusStaleDataNote = useMemo(() => {
if (valueMode !== 'raw') return null
const raw: number[] = []
if (mode === 'us' && statePayload?.values) {
for (const row of Object.values(statePayload.values)) {
const v = (row as Record<string, unknown>)[metricSlug]
if (typeof v === 'number' && Number.isFinite(v)) raw.push(v)
}
} else if (mode === 'stateCounty' && stateCountyGeo?.features?.length) {
for (const f of stateCountyGeo.features) {
const v = (f.properties as Record<string, unknown> | null)?.[metricSlug]
if (typeof v === 'number' && Number.isFinite(v)) raw.push(v)
}
} else if (mode === 'place' && placeGeoMerged?.features?.length) {
for (const f of placeGeoMerged.features) {
const v = (f.properties as Record<string, unknown> | null)?.[metricSlug]
if (typeof v === 'number' && Number.isFinite(v)) raw.push(v)
}
}
return censusMetricStaleDataNote(metricSlug, valueMode, raw)
}, [mode, valueMode, metricSlug, statePayload, stateCountyGeo, placeGeoMerged])
const countyChoroPooledForLegend = useMemo((): number[] | null => {
if (mode !== 'stateCounty' || !countyTrends || !stateFips || !metricSlug || vintages.length < 2) return null
const arr = collectAllVintageDisplayValuesCounty(
countyTrends,
stateFips,
vintages,
metricSlug,
valueMode,
manifest?.national_ref,
stateTrends,
)
return arr.length >= 20 ? arr : null
}, [mode, countyTrends, stateFips, vintages.join(','), metricSlug, valueMode, manifest?.national_ref, stateTrends])
const countyChoroExtent = useMemo(() => {
if (countyChoroPooledForLegend?.length) return quantileExtent(countyChoroPooledForLegend)
const vals = Object.values(countyDisplayByGeoid).filter(
(x): x is number => typeof x === 'number' && Number.isFinite(x),
)
if (!vals.length) return { min: 0, max: 1 }
return quantileExtent(vals)
}, [countyChoroPooledForLegend, countyDisplayByGeoid])
const countyBubbleExtent = useMemo(() => {
if (!stateCountyGeo || !metricSlug) return { min: 0, max: 1 }
const displayVals = Object.values(countyDisplayByGeoid).filter(
(x): x is number => typeof x === 'number' && Number.isFinite(x),
)
if (displayVals.length >= 2) {
return minMaxExtent(displayVals)
}
const vals = stateCountyGeo.features
.map((f) => {
const v = (f.properties as Record<string, unknown> | null)?.[metricSlug]
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
.filter((x): x is number => x != null)
if (!vals.length) return { min: 0, max: 1 }
return minMaxExtent(vals)
}, [stateCountyGeo, metricSlug, countyDisplayByGeoid])
const countyRankByGeoid = useMemo(() => {
const m = new Map<string, { rank: number; total: number }>()
if (!stateCountyGeo) return m
const rows: { id: string; value: number }[] = []
for (const f of stateCountyGeo.features) {
const gid = countyGeoidFromFeature(f as GeoJSON.Feature)
const v = countyDisplayByGeoid[gid]
if (typeof v === 'number' && Number.isFinite(v)) rows.push({ id: gid, value: v })
}
rows.sort((a, b) => compareRankedMetricValues(a.value, b.value, metricSlug))
const total = rows.length
rows.forEach((r, i) => m.set(r.id, { rank: i + 1, total }))
return m
}, [stateCountyGeo, countyDisplayByGeoid, metricSlug])
/** Stable ref for react-leaflet GeoJSON `style` — inline functions change every render and trigger Leaflet
* `setStyle` on all paths whenever e.g. `countyHover` updates, which clears hover / breaks tooltips. */
const countyFilledGeoJsonStyle = useCallback(
(feature: GeoJSON.Feature) => {
const gid = countyGeoidFromFeature(feature)
const disp = countyDisplayByGeoid[gid]
const t = metricToDisplayT(disp, countyChoroExtent.min, countyChoroExtent.max, scale)
const isHL = leaderboardPinnedId != null && gid === leaderboardPinnedId
return {
fillColor: colorFromT(t),
// pinned county pops on small screens via a thicker amber stroke + dashed
// inner ring (the dashArray reads through any neighbor that overpaints).
color: isHL ? '#b45309' : '#334155',
weight: isHL ? 4.5 : 0.5,
dashArray: isHL ? '0' : undefined,
fillOpacity: isHL ? 0.96 : 0.88,
}
},
[scale, countyChoroExtent.min, countyChoroExtent.max, countyDisplayByGeoid, leaderboardPinnedId],
)
const placeFilledGeoJsonStyle = useCallback(
(feature: GeoJSON.Feature) => {
const p = feature?.properties as Record<string, unknown> | undefined
const raw = String(p?.GEOID ?? '').replace(/\D/g, '')
const gid7 = raw.length <= 7 ? raw.padStart(7, '0') : raw.slice(-7).padStart(7, '0')
const disp = placeDisplayByGeoid[gid7]
const t = metricToDisplayT(disp, placeChoroExtent.min, placeChoroExtent.max, scale)
const isHL = leaderboardPinnedId != null && gid7 === leaderboardPinnedId
return {
fillColor: colorFromT(t),
color: isHL ? '#b45309' : '#334155',
weight: isHL ? 4.5 : 0.5,
fillOpacity: isHL ? 0.96 : 0.88,
}
},
[scale, placeChoroExtent.min, placeChoroExtent.max, placeDisplayByGeoid, leaderboardPinnedId],
)
const fmt = useCallback(
(v: number) => formatMetricValue(metricSlug, v, metrics, valueMode),
[metricSlug, metrics, valueMode],
)
const formatAxisTick = useCallback(
(x: number, span?: number) => formatCensusMapAxisTickForMetric(metricSlug, metrics, x, span, valueMode),
[metricSlug, metrics, valueMode],
)
const stateRows = useMemo(() => {
if (!statePayload) return []
return Object.entries(statePayload.values).map(([st, row]) => {
const name = typeof row.NAME === 'string' ? row.NAME : st
const disp = stateDisplayById[st] ?? null
return { geoid: st, name, value: disp }
})
}, [statePayload, metricSlug, stateDisplayById])
const sortedStateRows = useMemo(() => {
const arr = [...stateRows]
const mul = tableSort.dir === 'asc' ? 1 : -1
arr.sort((a, b) => {
if (tableSort.key === 'geoid') return mul * a.geoid.localeCompare(b.geoid)
if (tableSort.key === 'name') return mul * a.name.localeCompare(b.name)
const av = a.value ?? -Infinity
const bv = b.value ?? -Infinity
return mul * (av - bv)
})
return arr
}, [stateRows, tableSort])
const barData = useMemo(() => {
const withVal = stateRows.filter((r) => r.value != null)
withVal.sort((a, b) => compareRankedMetricValues(a.value!, b.value!, metricSlug))
return withVal.slice(0, CENSUS_TOP_BAR_ROW_LIMIT).map((r) => ({
name: r.name,
fullName: r.name,
value: r.value,
geoid: r.geoid,
}))
}, [stateRows, metricSlug])
const stateRankByGeoid = useMemo(() => {
const m = new Map<string, { rank: number; total: number }>()
const withVal = stateRows.filter((r) => r.value != null)
withVal.sort((a, b) => compareRankedMetricValues(a.value!, b.value!, metricSlug))
const total = withVal.length
withVal.forEach((r, i) => m.set(r.geoid, { rank: i + 1, total }))
return m
}, [stateRows, metricSlug])
const onMetricChange = (slug: string) => {
if (!manifest) return
const list = sliderVintages({
mode,
manifest,
metricSlug: slug,
stateTrends,
countyTrends,
placeTrends,
stateFips,
})
const nextV = list.includes(effectiveVintage)
? effectiveVintage
: (list[list.length - 1] ?? effectiveVintage)
const q = searchParams.toString()
if (mode === 'place' && stateFips) {
navigate(mapPathPlace(mapPrefix, stateFips, nextV, slug, q))
} else if (mode === 'stateCounty' && stateFips) {
navigate(mapPathState(mapPrefix, stateFips, nextV, slug, q))
} else {
navigate(mapPathUs(mapPrefix, nextV, slug, q))
}
}
const onVintageChange = (v: string) => {
setPlaying(false)
setScrubVintage(v)
const q = searchParams.toString()
if (vintageNavigateTimerRef.current) window.clearTimeout(vintageNavigateTimerRef.current)
vintageNavigateTimerRef.current = window.setTimeout(() => {
vintageNavigateTimerRef.current = null
startTransition(() => {
if (mode === 'place' && stateFips) {
navigate(mapPathPlace(mapPrefix, stateFips, v, metricSlug, q), { replace: true })
} else if (mode === 'stateCounty' && stateFips) {
navigate(mapPathState(mapPrefix, stateFips, v, metricSlug, q), { replace: true })
} else {
navigate(mapPathUs(mapPrefix, v, metricSlug, q), { replace: true })
}
})
}, 90)
}
const stateFill = useCallback(
(stateId: string) => {
if (!statePayload?.values) return '#e2e8f0'
const disp = stateDisplayById[stateId]
const t = metricToDisplayT(disp, stateChoroExtent.min, stateChoroExtent.max, scale)
return colorFromT(t)
},
[statePayload, stateDisplayById, stateChoroExtent.min, stateChoroExtent.max, scale],
)
const onStateClick = (stateId: string) => {
const fid = normalizeStateFips(stateId)
if (!fid) return
navigate(mapPathState(mapPrefix, fid, effectiveVintage, metricSlug, searchParams.toString()))
}
const toggleTableSort = (key: 'name' | 'value' | 'geoid') => {
setTableSort((prev) => {
if (prev.key === key) return { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
const valueDir = censusMetricRankDirection(metricSlug) === 'lower' ? 'asc' : 'desc'
return { key, dir: key === 'value' ? valueDir : 'asc' }
})
}
if (manifestError) {
return (
<div className="max-w-3xl mx-auto p-8 text-slate-700">
<h1 className="text-xl font-semibold text-slate-900">Census map</h1>
<p className="mt-2">
Static data is missing. Run{' '}
<code className="rounded bg-slate-100 px-1">
.venv/bin/python scripts/datasources/census/export_census_map_static.py
</code>{' '}
from the repo root after caching ACS parquets.
</p>
</div>
)
}
if (!manifest) {
return <div className="p-8 text-slate-600">Loading census map…</div>
}
if (mode === 'us' && !stateTrendsFetched) {
return <div className="p-8 text-slate-600">Loading census map…</div>
}
if (mode === 'stateCounty' && stateFips && !countyTrendsFetched) {
return <div className="p-8 text-slate-600">Loading census map…</div>
}
if (mode === 'place' && stateFips && !placeTrendsFetched) {
return <div className="p-8 text-slate-600">Loading census map…</div>
}
const knownSlugs = new Set(selectableMetrics.map((m) => m.slug))
if (metric && !knownSlugs.has(metric)) {
const fallback = selectableMetrics[0]?.slug ?? metrics[0]?.slug ?? 'median_household_income'
if (mode === 'place' && stateFips) {
return <Navigate to={mapPathPlace(mapPrefix, stateFips, effectiveVintage, fallback)} replace />
}
if (mode === 'stateCounty' && stateFips) {
return <Navigate to={mapPathState(mapPrefix, stateFips, effectiveVintage, fallback)} replace />
}
return <Navigate to={mapPathUs(mapPrefix, effectiveVintage, fallback)} replace />
}
if (vintage && vintages.length && !vintages.includes(vintage)) {
const q = searchParams.toString()
if (mode === 'place' && stateFips) {
return <Navigate to={mapPathPlace(mapPrefix, stateFips, effectiveVintage, metricSlug, q)} replace />
}
if (mode === 'stateCounty' && stateFips) {
return <Navigate to={mapPathState(mapPrefix, stateFips, effectiveVintage, metricSlug, q)} replace />
}
return <Navigate to={mapPathUs(mapPrefix, effectiveVintage, metricSlug, q)} replace />
}
const singleVintage = vintages.length <= 1
const mapToolbarDrillNav =
(mode === 'place' || mode === 'stateCounty') && stateFips ? (
<div className="flex flex-wrap items-center gap-2 shrink-0">
<Link
to={mapPathUs(mapPrefix, effectiveVintage, metricSlug, searchParams.toString())}
className="inline-flex items-center gap-2 rounded-md bg-[#354F52] px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-[#2d4245] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-2 focus-visible:ring-offset-white"
>
<ArrowLeftIcon className="h-4 w-4 shrink-0" aria-hidden />
Back to US map
</Link>
{mode === 'stateCounty' && placeStates.includes(stateFips) ? (
<Link
to={mapPathPlace(mapPrefix, stateFips, effectiveVintage, metricSlug, searchParams.toString())}
className="inline-flex items-center gap-1.5 rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-sm hover:bg-slate-50"
>
Cities / places
</Link>
) : null}
{mode === 'place' ? (
<Link
to={mapPathState(mapPrefix, stateFips, effectiveVintage, metricSlug, searchParams.toString())}
className="inline-flex items-center gap-1.5 rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-sm hover:bg-slate-50"
>
Counties
</Link>
) : null}
</div>
) : null
const nestedInDataExplorer = location.pathname.includes('/data-explorer/map')
const wrapExplorerMap = (child: JSX.Element) =>
nestedInDataExplorer ? (
<div className="min-w-0 rounded-lg border border-slate-200/70 bg-white/40 p-1.5 sm:p-2">{child}</div>
) : (
child
)
return (
<div
className={
nestedInDataExplorer ? 'mx-auto w-full min-w-0 space-y-2.5' : 'mx-auto w-full max-w-[1600px] p-4 md:p-6'
}
>
{/* Mobile-only sticky reset chip — on small screens the "Back to US map"
link is buried in the filter bar and scrolls off; this anchored pill
keeps the way out always reachable. Hidden on >=md where the inline
green button is already on-screen. */}
{(mode === 'place' || mode === 'stateCounty') && stateFips ? (
<div className="sticky top-0 z-30 -mx-1 mb-2 flex items-center gap-2 border-b border-slate-200/80 bg-white/95 px-2 py-1.5 shadow-sm backdrop-blur md:hidden">
<Link
to={mapPathUs(mapPrefix, effectiveVintage, metricSlug, searchParams.toString())}
className="inline-flex items-center gap-1.5 rounded-md bg-[#354F52] px-2.5 py-1.5 text-xs font-semibold text-white shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-2"
aria-label="Back to US map"
>
<ArrowLeftIcon className="h-4 w-4 shrink-0" aria-hidden />
US map
</Link>
{stateName ? (
<span className="min-w-0 truncate text-[12px] font-medium text-slate-700">
{stateName}
{mode === 'place' ? ' · places' : ' · counties'}
</span>
) : null}
</div>
) : null}
{!nestedInDataExplorer ? (
<header className="mb-3 max-w-[60rem] border-b border-slate-200/80 pb-3">
<h1 className="text-xl font-semibold text-slate-900">Census explorer</h1>
<p className="mt-1 max-w-[60rem] text-xs leading-snug text-slate-600">
Explore American Community Survey (5-year) estimates on the map. Pick a question and year, then click a
state to open county or city views when that data is bundled for download.
</p>
</header>
) : null}
{!censusOnboardingDismissed ? (
<div className={nestedInDataExplorer ? 'mb-2 rounded-lg border border-sky-200/90 bg-sky-50/90 p-2 sm:p-2.5' : 'mb-4'}>
<div
className="flex flex-col gap-2 rounded-lg border border-sky-200 bg-sky-50/80 px-3 py-2.5 text-sm text-slate-800 shadow-sm sm:flex-row sm:items-center sm:justify-between"
role="status"
>
<p className="min-w-0 leading-snug">
<span aria-hidden>👋 </span>
Pick what you want to explore above, choose a year, then <strong>click any state</strong> on the map. Use the
ranking panel on the right to jump to a state — bars are numbered #1–#10 for this metric.
</p>
<button
type="button"
onClick={dismissCensusOnboarding}
className="shrink-0 self-start rounded-md border border-sky-300 bg-white px-3 py-1.5 text-xs font-semibold text-sky-950 shadow-sm hover:bg-sky-100/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-2 sm:self-center"
>
Got it
</button>
</div>
</div>
) : null}
<CensusMapAdvancedMapOptionsFlyout
open={advancedMapOptionsOpen}
onClose={() => {
setAdvancedMapOptionsOpen(false)
setAdvancedFocusSection(null)
}}
focusSection={advancedFocusSection}
onConsumedFocusSection={() => setAdvancedFocusSection(null)}
metricFullHelp={metricFullHelp}
viz={viz}
setViz={setViz}
scale={scale}
setScale={setScale}
valueMode={valueMode}
setValueMode={setValueMode}
yearControls={
mode === 'us'
? {
vintages,
displayVintage,
singleVintage,
showPlay,
playing,
setPlaying,
onVintageChange,
onBeginPlay: () => setAnimIndex(0),
yearHelp: `${CENSUS_MAP_UI_HELP.year}\n\n${metricFullHelp}`,
}
: undefined
}
/>
{mode === 'us' && wrapExplorerMap(
<div className="flex min-w-0 flex-col gap-3">
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-end sm:gap-3">
<div className="min-w-0 flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-sm sm:max-w-md">
<CensusMetricToolbarControl
metricFullHelp={metricFullHelp}
metrics={selectableMetrics}
metricSlug={metricSlug}
onPickMetric={onMetricChange}
unconstrainedWidth
/>
</div>
<div className="min-w-0 flex-1 sm:max-w-lg">
<MapAddressSearch
onPick={(r: MapAddressResult) => {
const fips = r.stateCode ? USPS_TO_FIPS2[r.stateCode] ?? null : null
setPinnedAddress({
displayName: r.displayName,
shortLabel: r.shortLabel,
lat: r.lat,
lon: r.lon,
stateCode: r.stateCode,
stateFips: fips,
county: r.county,
city: r.city,
})
}}
onClear={() => setPinnedAddress(null)}
/>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-[auto_minmax(0,1fr)_minmax(300px,26rem)] gap-3 items-start">
<div className="hidden xl:block xl:sticky xl:top-4">
<CensusMapLeftRail
activeSection={advancedMapOptionsOpen ? advancedFocusSection : null}
yearBadge={displayVintage}
onOpen={(section: CensusMapRailSection) => {
setAdvancedFocusSection(section)
setAdvancedMapOptionsOpen(true)
}}
/>
</div>
<div
className="flex min-w-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm"
role="region"
aria-labelledby="census-explorer-map-title-us"
>
<CensusMapHeadingStrip
titleId="census-explorer-map-title-us"
title={narrativePack.mapTitle}
insight={narrativePack.mapTitleInsight}
selectionActive={mapHeadingSelectionActive}
/>
<CensusMapExplainerDetails
subtitle={narrativePack.mapSubtitle}
calloutLines={narrativePack.mapCallouts}
/>
<div className="p-2">
{!statePayload ? (
<div className="h-[480px] flex flex-col items-center justify-center gap-2 px-4 text-center text-slate-500 text-sm">
<span>Loading state map…</span>
<span className="text-xs text-slate-400">
If this hangs, run export (needs <code className="text-[11px]">state_metrics.json</code>).
</span>
</div>
) : (
<>
<div className="relative w-full">
<div
ref={usMapInnerRef}
className="w-full overflow-x-auto relative"
onMouseMove={(e) => updateUsMapTip(e.clientX, e.clientY)}
onMouseLeave={() => {
setUsMapTipPos(null)
}}
>
<ComposableMap
key={`census-us-map-${metricSlug}-${viz}-${scale}`}
projection="geoAlbersUsa"
projectionConfig={{ scale: 1000 }}
width={960}
height={520}
>
<Geographies geography={manifest.state_topo_cdn || STATES_TOPO}>
{({ geographies, projection }) => {
const usBarPinFips =
leaderboardPinnedId != null && leaderboardPinnedId !== ''
? normalizeStateFips(leaderboardPinnedId) ?? leaderboardPinnedId
: null
return (
<>
{geographies.map((geo) => {
const sid = normalizeStateFips(geo.id) ?? String(geo.id)
const row = statePayload.values[sid]
const name = (row as { NAME?: string } | undefined)?.NAME
const isBubble = viz === 'bubble'
const fill = isBubble ? 'rgba(248,250,252,0.94)' : stateFill(sid)
const isPinned = usBarPinFips != null && sid === usBarPinFips
const stroke = isPinned ? '#b45309' : isBubble ? '#64748b' : '#94a3b8'
const sw = isPinned ? 2.35 : 0.55
return (
<Geography
key={geo.rsmKey}
geography={geo}
style={{
default: {
outline: 'none',
cursor: 'default',
fill,
stroke,
strokeWidth: sw,
transition: CENSUS_CHORO_FILL_TRANSITION,
},
hover: {
outline: 'none',
cursor: 'pointer',
fill: isBubble ? 'rgba(226,232,240,0.98)' : '#64748b',
stroke: isPinned ? '#92400e' : stroke,
strokeWidth: isPinned ? 2.5 : sw,
transition:
'fill 0.2s cubic-bezier(0.65, 0, 0.35, 1), stroke 0.2s cubic-bezier(0.65, 0, 0.35, 1)',
},
pressed: {
outline: 'none',
fill,
stroke,
strokeWidth: sw,
},
}}
onMouseEnter={(e) => {
cancelScheduledHoverClear()
const disp = stateDisplayById[sid] ?? null
setHoverRegion({
id: sid,
name: typeof name === 'string' ? name : sid,
value: disp,
})
updateUsMapTip(e.clientX, e.clientY)
}}
onMouseLeave={() => scheduleHoverRegionClear()}
onClick={() => onStateClick(sid)}
/>
)
})}
{viz === 'bubble' &&
geographies.map((geo) => {
const sid = normalizeStateFips(geo.id) ?? String(geo.id)
const num = stateDisplayById[sid] ?? null
if (num == null) return null
const geom = geo.geometry
if (!geom) return null
let centroidPt
try {
centroidPt = geoCentroid({
type: 'Feature',
properties: {},
geometry: geom,
} as GeoJSON.Feature)
} catch {
return null
}
const pair = toLonLatPair(centroidPt)
if (!pair) return null
const xy = safeProjectScreen(projection, pair)
if (!xy) return null
const r = bubbleRadiusPx(num, stateBubbleExtent.min, stateBubbleExtent.max, scale, 4, 20)
const bt =
metricToDisplayT(num, stateBubbleExtent.min, stateBubbleExtent.max, scale) ?? 0
const isPinnedBubble = usBarPinFips != null && sid === usBarPinFips
return (
<g
key={`bubble-${geo.rsmKey}`}
transform={`translate(${xy[0]},${xy[1]})`}
style={{ pointerEvents: 'none' }}
>
<circle
r={r}
fill={bubbleFillFromT(bt, 0.86)}
stroke={isPinnedBubble ? '#b45309' : '#fff'}
strokeWidth={isPinnedBubble ? 2.4 : 0.6}
/>
</g>
)
})}
{pinnedAddress
? (() => {
const xy = safeProjectScreen(projection, [pinnedAddress.lon, pinnedAddress.lat])
if (!xy) return null
return (
<g
key="address-pin"
transform={`translate(${xy[0]},${xy[1]})`}
style={{ pointerEvents: 'none' }}
>
<circle r={2} fill="#0f172a" />
</g>
)
})()
: null}
</>
)
}}
</Geographies>
</ComposableMap>
{hoverRegion && usMapTipPos ? (
<div
className="absolute z-20 max-w-[280px] rounded-lg border border-slate-600 bg-slate-950 px-3 py-2.5 text-sm text-white shadow-2xl pointer-events-none"
style={{ left: usMapTipPos.x, top: usMapTipPos.y }}
>
<div className="font-semibold leading-snug text-white">{hoverRegion.name}</div>
<div className="mt-0.5 text-slate-100 tabular-nums leading-snug">
{formatMetricValueCompact(metricSlug, hoverRegion.value, metrics, valueMode)}
{(() => {
const stRank = stateRankByGeoid.get(hoverRegion.id)
if (!stRank) return null
return (
<span className="text-slate-300">
{' '}
· Ranked #{stRank.rank} of {stRank.total}
</span>
)
})()}
</div>
<div className="mt-1 text-[11px] leading-snug text-slate-400">
{currentMetricMeta?.label ?? metricSlug}
</div>
<div className="mt-1.5 text-xs font-medium text-slate-300">
Click for county-level map
</div>
</div>
) : null}
</div>
</div>
</>
)}
</div>
{viz === 'filled' && (
<div className="border-t border-slate-100 px-3 pt-3 pb-2">
<ChoroplethLegend
min={stateChoroExtent.min}
max={stateChoroExtent.max}
scale={scale}
format={fmt}
valueMode={valueMode}
extentPoolsAllVintages={stateChoroPooledForLegend != null}
metricHelp={metricFullHelp}
semantics={choroLegendSemantics}
letterGradeLegend={giniRawLetterLegend}
/>
</div>
)}
{viz === 'bubble' && (
<div className="border-t border-slate-100 px-3 pt-3 pb-2">
<BubbleLegend
min={stateBubbleExtent.min}
max={stateBubbleExtent.max}
scale={scale}
format={fmt}
metricHelp={metricFullHelp}
letterGradeLegend={giniRawLetterLegend}
/>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-100 px-3 py-2 text-xs text-slate-500">
<span>
U.S. Census Bureau ACS 5-year · state estimates (
<code className="text-[10px]">state_metrics.json</code>)
</span>
<span>
{stateChoroPooledForLegend != null
? `Color scale range (~4th–96th pct., all years): ${fmt(stateChoroExtent.min)} — ${fmt(stateChoroExtent.max)}`
: `Color scale range (~4th–96th pct.): ${fmt(stateChoroExtent.min)} — ${fmt(stateChoroExtent.max)}`}
</span>
</div>
</div>
<aside className="flex flex-col gap-4 xl:sticky xl:top-4">
{pinnedAddress ? (() => {
const fips = pinnedAddress.stateFips
const stRow = fips ? statePayload?.values?.[fips] : undefined
const stName =
(stRow as { NAME?: string } | undefined)?.NAME ??
(pinnedAddress.stateCode ? STATE_CODE_TO_NAME[pinnedAddress.stateCode] : null) ??
pinnedAddress.stateCode ??
'Selected location'
const rawVal =
stRow && (stRow as Record<string, unknown>)[metricSlug] != null
? ((stRow as Record<string, unknown>)[metricSlug] as number)
: null
const metricLabel = currentMetricMeta?.label ?? metricSlug
const rank = fips ? stateRankByGeoid.get(fips) : null
return (
<div className="rounded-lg border border-rose-200 bg-white p-3 shadow-sm">
<div className="flex items-start gap-2">
<span
className="mt-0.5 inline-block h-3 w-3 shrink-0 rounded-full border-2 border-white bg-rose-500 shadow ring-2 ring-rose-200"
aria-hidden
/>
<div className="min-w-0 flex-1">
<div className="text-[10px] font-semibold uppercase tracking-wide text-rose-700">
Pinned address
</div>
<div className="mt-0.5 truncate text-sm font-semibold text-slate-900" title={pinnedAddress.displayName}>
{pinnedAddress.shortLabel || pinnedAddress.displayName}
</div>
{pinnedAddress.county || pinnedAddress.city ? (
<div className="mt-0.5 text-[11px] text-slate-500">
{[pinnedAddress.city, pinnedAddress.county && `${pinnedAddress.county} County`, stName]
.filter(Boolean)
.join(' · ')}
</div>
) : null}
</div>
<button
type="button"
onClick={() => setPinnedAddress(null)}
className="rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-1"
aria-label="Clear pin"
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
{rawVal != null ? (
<div className="mt-2.5 rounded-md border border-slate-100 bg-slate-50/80 px-2.5 py-2">
<div className="text-[10px] font-medium uppercase tracking-wide text-slate-500">
{stName} · {metricLabel}
</div>
<div className="mt-0.5 text-base font-semibold text-slate-900 tabular-nums">
{formatMetricValue(metricSlug, rawVal, metrics, valueMode)}
</div>
{rank ? (
<div className="mt-0.5 text-[11px] text-slate-500">
Ranked #{rank.rank} of {rank.total} states
</div>
) : null}
</div>
) : (
<div className="mt-2.5 rounded-md border border-dashed border-slate-200 bg-slate-50/60 px-2.5 py-2 text-[11px] text-slate-500">
State value not available for this metric/year.
</div>
)}
{fips ? (
<button
type="button"
onClick={() => navigate(mapPathState(fips, displayVintage, metricSlug))}
className="mt-2.5 inline-flex w-full items-center justify-center gap-1.5 rounded-md bg-[#354F52] px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-[#2c4346] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-2"
>
Open {stName} county view →
</button>
) : null}
</div>
)
})() : null}
{stateTrends && usTrendSubject ? (
<div
onMouseEnter={() => {
trendAsideCapturingHoverRef.current = true
cancelScheduledHoverClear()
}}
onMouseLeave={() => {
trendAsideCapturingHoverRef.current = false
scheduleHoverRegionClear()
}}
>
{(() => {
const trendPts = trendPointsFromSeries(
stateTrends.vintages,
stateTrends.by_state[usTrendSubject.id]?.[metricSlug] as Record<string, unknown> | undefined,
)
return (
<AcTrendChart
title={buildCensusTrendChartTitle(
usTrendSubject.name,
metricSlug,
metrics.find((m) => m.slug === metricSlug)?.label ?? metricSlug,
trendPts,
)}
subtitle={narrativePack.trendChartSubtitle}
readingLines={narrativePack.trendChartCallouts}
chartTitleId="census-explorer-trend-chart-us"
points={trendPts}
format={fmt}
metricHelp={metricFullHelp}
metricTooltipLabel={currentMetricMeta?.label ?? metricSlug}
/>
)
})()}
<p className="mt-2 text-[10px] leading-snug text-slate-500">
Move the pointer onto this panel before leaving the map to keep the chart, or click a state in the
ranking list to lock its trend until you click that bar again.
</p>
</div>
) : null}
<motion.div
className="rounded-lg border border-slate-200 bg-white p-3 shadow-sm relative"
initial={false}
animate={{ opacity: trendChartOpenUs ? 0 : 1 }}
transition={trendFadeTransition}
style={{ pointerEvents: trendChartOpenUs ? 'none' : undefined }}
>
<div className="mb-2 flex gap-2 border-b border-slate-100 pb-2">
<ChartBarSquareIcon className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" aria-hidden />
<div className="flex min-w-0 flex-1 items-start gap-1.5">
<h3 className="min-w-0 flex-1 text-sm font-semibold leading-snug text-slate-900 [overflow-wrap:anywhere]">
{narrativePack.leaderboardSectionTitle}
</h3>
<InfoHelpTrigger
topic="Leaderboard strip"
align="left"
help={`${metricFullHelp}\n\nClick a bar to highlight that state on the map (click again to clear); click the map to drill down.`}
buttonClassName="shrink-0 self-start rounded p-0.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
/>
</div>
</div>
<div className="relative w-full pr-1">
<CensusRaceBarChart
className="min-h-0"
rows={barData.map((r) => ({
id: r.geoid,
label: r.name,
fullName: r.fullName,
value: r.value!,
}))}
formatValue={(v) => formatMetricValue(metricSlug, v, metrics, valueMode)}
formatBarEnd={(v) => formatMetricValueCompact(metricSlug, v, metrics, valueMode)}
formatAxisTick={formatAxisTick}
playing={playing}
vintageYear={displayVintage}
yearHelp={CENSUS_MAP_UI_HELP.year}
winnerCaption={censusMetricWinnerCaption(metricSlug)}
nationalContextNote={nationalContextNote}
winnerRankLabel={currentMetricMeta?.label ?? metricSlug}
winnerMetricHelp={metricFullHelp}
readingCalloutLines={narrativePack.barChartCallouts}
dataQualityNote={censusStaleDataNote}
selectedRowId={leaderboardPinnedId}
onRowClick={toggleLeaderboardPin}
/>
</div>
</motion.div>
<div className="rounded-lg border border-slate-200 bg-white shadow-sm overflow-hidden flex flex-col max-h-[min(420px,50vh)]">
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b border-slate-100 bg-slate-50/90">
<TableCellsIcon className="h-4 w-4 text-slate-600 shrink-0" />
<LabelWithInfo
label="All states"
help={`${metricFullHelp}\n\n${CENSUS_MAP_UI_HELP.allGeographiesTable}`}
/>
<span className="text-[10px] text-slate-500 ml-auto">{sortedStateRows.length} rows</span>
</div>
<div className="overflow-auto flex-1">
<table className="min-w-full text-xs">
<thead className="sticky top-0 bg-white shadow-sm z-10">
<tr className="text-left text-slate-500 border-b border-slate-200">
<th className="px-2 py-2 font-medium">
<button type="button" className="hover:text-slate-900" onClick={() => toggleTableSort('geoid')}>
FIPS {tableSort.key === 'geoid' ? (tableSort.dir === 'asc' ? '↑' : '↓') : ''}
</button>
</th>
<th className="px-2 py-2 font-medium">
<button type="button" className="hover:text-slate-900" onClick={() => toggleTableSort('name')}>
Name {tableSort.key === 'name' ? (tableSort.dir === 'asc' ? '↑' : '↓') : ''}
</button>
</th>
<th className="px-2 py-2 font-medium text-right">
<button type="button" className="hover:text-slate-900" onClick={() => toggleTableSort('value')}>
Value {tableSort.key === 'value' ? (tableSort.dir === 'asc' ? '↑' : '↓') : ''}
</button>
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{sortedStateRows.map((row) => (
<tr
key={row.geoid}
className="hover:bg-slate-50 cursor-pointer"
onClick={() => onStateClick(row.geoid)}
>
<td className="px-2 py-1.5 font-mono text-slate-600">{row.geoid}</td>
<td className="px-2 py-1.5 text-slate-800 leading-snug">{row.name}</td>
<td className="px-2 py-1.5 text-right tabular-nums text-slate-800">
{formatMetricValue(metricSlug, row.value, metrics, valueMode)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</aside>
</div>
</div>
)}
{mode === 'stateCounty' && stateFips && wrapExplorerMap(
<div className="flex min-w-0 flex-col gap-3">
<CensusExplorerFilterBar
leadingSlot={mapToolbarDrillNav}
metricFullHelp={metricFullHelp}
metricChoices={selectableMetrics}
metricSlug={metricSlug}
onMetricChange={onMetricChange}
vintages={vintages}
displayVintage={displayVintage}
singleVintage={singleVintage}
showPlay={showPlay}
playing={playing}
setPlaying={setPlaying}
onVintageChange={onVintageChange}
onBeginPlay={() => setAnimIndex(0)}
yearHelp={`${CENSUS_MAP_UI_HELP.year}\n\n${metricFullHelp}`}
onOpenAdvanced={(section) => {
setAdvancedFocusSection(section)
setAdvancedMapOptionsOpen(true)
}}
/>
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_minmax(300px,26rem)] gap-3 items-start">
<div
className="flex min-w-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm"
role="region"
aria-labelledby="census-explorer-map-title-county"
>
<CensusMapHeadingStrip
titleId="census-explorer-map-title-county"
title={narrativePack.mapTitle}
insight={narrativePack.mapTitleInsight}
selectionActive={mapHeadingSelectionActive}
/>
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 bg-white px-3 py-1.5 text-xs text-slate-600">
{!countyPayload && countyPayloadLoading && <span className="text-slate-500">Loading metrics…</span>}
{countyTopoLoading && <span className="text-slate-500">Loading boundaries…</span>}
{countyPayloadError && (
<span className="text-amber-800 text-sm">
Missing <code className="text-xs">county_metrics.json</code> for this year — run census export
with county ACS cached.
</span>
)}
{countyPayload && countyTopo && !stateCountyGeo && (
<span className="text-amber-800 text-sm">No county features for this state (check state FIPS).</span>
)}
</div>
<CensusMapExplainerDetails
subtitle={narrativePack.mapSubtitle}
calloutLines={narrativePack.mapCallouts}
/>
<div className="h-[min(78vh,620px)] w-full bg-white relative z-0">
{stateCountyGeo && (
<MapContainer
center={[37.8, -86.8]}
zoom={7}
minZoom={5}
maxZoom={13}
className="h-full w-full census-choropleth-map"
scrollWheelZoom={false}
style={{ height: '100%', width: '100%', minHeight: 400 }}
>
{viz === 'filled' && (
<GeoJSON
key={`${metricSlug}-${scale}-county-filled`}
data={stateCountyGeo}
style={countyFilledGeoJsonStyle}
onEachFeature={(feature, layer) => {
const p = feature.properties as Record<string, unknown>
const gid = countyGeoidFromFeature(feature as GeoJSON.Feature)
const name = String(p?.NAME ?? gid ?? '')
const disp = countyDisplayByGeoid[gid] ?? null
const rk = countyRankByGeoid.get(gid)
const rankLine = rk
? `<br/><span style="font-size:11px;color:#475569;">Ranked #${rk.rank} of ${rk.total}</span>`
: ''
layer.bindPopup(
`<div><strong>${name}</strong><br/>${metricValueTooltipLine(
currentMetricMeta?.label ?? metricSlug,
metricSlug,
disp,
metrics,
valueMode,
)}${rankLine}</div>`,
)
layer.on('mouseover', () => {
setCountyHover({ id: gid, name, value: disp })
})
layer.on('mouseout', () => setCountyHover(null))
}}
/>
)}
{viz === 'bubble' &&
stateCountyGeo.features.map((f, idx) => {
const p = f.properties as Record<string, unknown> | null
const id = countyGeoidFromFeature(f as GeoJSON.Feature) || String(p?.GEOID ?? idx)
const num = countyDisplayByGeoid[id] ?? null
if (num == null || f.geometry == null) return null
const ll = featureLatLng(f)
if (!ll) return null
// Smaller cap than the legend (4..22) on purpose: state-fit views
// jam many counties into a narrow frame, and a 16px-radius disk
// covers a whole county at that scale. 2..9 keeps neighbors visible.
const r = bubbleRadiusPx(num, countyBubbleExtent.min, countyBubbleExtent.max, scale, 2, 9)
const bt =
metricToDisplayT(num, countyBubbleExtent.min, countyBubbleExtent.max, scale) ?? 0
const name = String(p?.NAME ?? id)
const isHL = leaderboardPinnedId != null && id === leaderboardPinnedId
return (
<CircleMarker
key={id}
center={[ll.lat, ll.lng]}
radius={r}
pathOptions={{
color: isHL ? '#b45309' : '#fff',
weight: isHL ? 3 : 1,
fillColor: bubbleFillFromT(bt, 0.82),
fillOpacity: 1,
}}
>
<LeafletTooltip
direction="top"
offset={[0, -6]}
opacity={1}
className="!bg-white !text-slate-900 !border !border-slate-300 !rounded-lg !px-2.5 !py-2 !shadow-lg"
>
<div className="text-xs text-slate-900">
<div className="font-semibold text-slate-950">{name}</div>
<div className="tabular-nums text-slate-800">
{formatMetricValueCompact(metricSlug, num, metrics, valueMode)}
</div>
{(() => {
const rk = countyRankByGeoid.get(id)
if (!rk) return null
return (
<div className="mt-0.5 text-[10px] font-medium text-slate-600">
Ranked #{rk.rank} of {rk.total}
</div>
)
})()}
</div>
</LeafletTooltip>
</CircleMarker>
)
})}
{viz === 'bubble' && (
<GeoJSON
data={stateCountyGeo}
interactive={false}
style={{
fillColor: 'transparent',
color: '#64748b',
weight: 0.4,
fillOpacity: 0,
}}
/>
)}
<DrilldownMapBoundsController data={stateCountyGeo} />
</MapContainer>
)}
</div>
</div>
<aside className="flex flex-col gap-4 xl:sticky xl:top-4">
{viz === 'filled' && (
<ChoroplethLegend
min={countyChoroExtent.min}
max={countyChoroExtent.max}
scale={scale}
format={fmt}
valueMode={valueMode}
extentPoolsAllVintages={countyChoroPooledForLegend != null}
metricHelp={metricFullHelp}
semantics={choroLegendSemantics}
letterGradeLegend={giniRawLetterLegend}
/>
)}
{viz === 'bubble' && (
<BubbleLegend
min={countyBubbleExtent.min}
max={countyBubbleExtent.max}
scale={scale}
format={fmt}
metricHelp={metricFullHelp}
letterGradeLegend={giniRawLetterLegend}
/>
)}
{countyTrends && countyHover && stateFips
? (() => {
const trendPts = trendPointsFromSeries(
countyTrends.vintages,
countyTrends.byGeoid[countyHover.id]?.[metricSlug] as Record<string, unknown> | undefined,
)
return (
<AcTrendChart
title={buildCensusTrendChartTitle(
countyHover.name,
metricSlug,
metrics.find((m) => m.slug === metricSlug)?.label ?? metricSlug,
trendPts,
)}
subtitle={narrativePack.trendChartSubtitle}
readingLines={narrativePack.trendChartCallouts}
chartTitleId="census-explorer-trend-chart-county"
points={trendPts}
format={fmt}
metricHelp={metricFullHelp}
metricTooltipLabel={currentMetricMeta?.label ?? metricSlug}
/>
)
})()
: null}
{stateCountyGeo && (
<>
<motion.div
className="flex min-h-0 flex-col rounded-lg border border-slate-200 bg-white p-3 shadow-sm"
initial={false}
animate={{ opacity: trendChartOpenCounty ? 0 : 1 }}
transition={trendFadeTransition}
style={{ pointerEvents: trendChartOpenCounty ? 'none' : undefined }}
>
<div className="mb-2 flex shrink-0 gap-2 border-b border-slate-100 pb-2">
<ChartBarSquareIcon className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" aria-hidden />
<div className="flex min-w-0 flex-1 items-start gap-1.5">
<h3 className="min-w-0 flex-1 text-sm font-semibold leading-snug text-slate-900 [overflow-wrap:anywhere]">
{narrativePack.leaderboardSectionTitle}
</h3>
<InfoHelpTrigger
topic="Leaderboard strip"
align="left"
help={`${metricFullHelp}\n\nClick a bar to highlight that county on the map (click again to clear).`}
buttonClassName="shrink-0 self-start rounded p-0.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
/>
</div>
</div>
<PlaceBarChart
className="min-h-0 flex-1"
rowsScrollClassName="max-h-[min(20rem,48vh)]"
features={stateCountyGeo.features}
metricSlug={metricSlug}
metrics={metrics}
valueMode={valueMode}
displayByGeoid={countyDisplayByGeoid}
playing={playing}
narrativePack={narrativePack}
geoLevel="county"
pinnedRowId={leaderboardPinnedId}
onTogglePinnedRow={toggleLeaderboardPin}
leaderSilhouetteUsps={stateUsps ?? null}
displayVintage={displayVintage}
/>
</motion.div>
<PlaceTable
features={stateCountyGeo.features}
geoLevel="county"
displayByGeoid={countyDisplayByGeoid}
metricSlug={metricSlug}
metrics={metrics}
valueMode={valueMode}
tableLabel="All counties"
/>
</>
)}
</aside>
</div>
</div>
)}
{mode === 'place' && wrapExplorerMap(
<div className="flex min-w-0 flex-col gap-3">
<CensusExplorerFilterBar
leadingSlot={mapToolbarDrillNav}
metricFullHelp={metricFullHelp}
metricChoices={selectableMetrics}
metricSlug={metricSlug}
onMetricChange={onMetricChange}
vintages={vintages}
displayVintage={displayVintage}
singleVintage={singleVintage}
showPlay={showPlay}
playing={playing}
setPlaying={setPlaying}
onVintageChange={onVintageChange}
onBeginPlay={() => setAnimIndex(0)}
yearHelp={`${CENSUS_MAP_UI_HELP.year}\n\n${metricFullHelp}`}
onOpenAdvanced={(section) => {
setAdvancedFocusSection(section)
setAdvancedMapOptionsOpen(true)
}}
/>
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_minmax(300px,26rem)] gap-3 items-start">
<div
className="flex min-w-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm"
role="region"
aria-labelledby="census-explorer-map-title-place"
>
<CensusMapHeadingStrip
titleId="census-explorer-map-title-place"
title={narrativePack.mapTitle}
insight={narrativePack.mapTitleInsight}
selectionActive={mapHeadingSelectionActive}
/>
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 bg-white px-3 py-1.5 text-xs text-slate-600">
{!placeGeo && !placeGeoError && <span className="text-slate-500">Loading…</span>}
{placeGeoError && (
<span className="text-amber-800 text-sm leading-snug">
Missing <code className="text-xs">place_{stateFips}.geojson</code>
<span className="block mt-1 text-xs font-normal text-amber-900/90">
From repo root: cache place ACS, then export —{' '}
<code className="rounded bg-amber-50 px-1 text-[11px]">
.venv/bin/python scripts/datasources/census/download_census_acs_data.py --geography place
--state {stateFips} --year {effectiveVintage}
</code>{' '}
then{' '}
<code className="rounded bg-amber-50 px-1 text-[11px]">
.venv/bin/python scripts/datasources/census/export_census_map_static.py --year{' '}
{effectiveVintage} --place-states {stateFips}
</code>
</span>
</span>
)}
</div>
<CensusMapExplainerDetails
subtitle={narrativePack.mapSubtitle}
calloutLines={narrativePack.mapCallouts}
/>
<div className="h-[min(78vh,620px)] w-full bg-white relative z-0">
{placeGeoMerged && (
<MapContainer
center={[37.8, -86.8]}
zoom={7}
minZoom={5}
maxZoom={13}
className="h-full w-full census-choropleth-map"
scrollWheelZoom={false}
style={{ height: '100%', width: '100%', minHeight: 400 }}
>
{viz === 'filled' && (
<GeoJSON
key={`${metricSlug}-${scale}-place-filled`}
data={placeGeoMerged}
style={placeFilledGeoJsonStyle}
onEachFeature={(feature, layer) => {
const p = feature.properties as Record<string, unknown>
const gid7 = placeGeoid7FromProperties(p, 0)
const name = String(p?.NAME ?? gid7 ?? '')
const disp = placeDisplayByGeoid[gid7] ?? null
const rk = placeRankByGeoid.get(gid7)
const rankLine = rk
? `<br/><span style="font-size:11px;color:#475569;">Ranked #${rk.rank} of ${rk.total}</span>`
: ''
layer.bindPopup(
`<div><strong>${name}</strong><br/>${metricValueTooltipLine(
currentMetricMeta?.label ?? metricSlug,
metricSlug,
disp,
metrics,
valueMode,
)}${rankLine}</div>`,
)
layer.on('mouseover', () => {
setPlaceHover({ id: gid7, name, value: disp })
})
layer.on('mouseout', () => setPlaceHover(null))
}}
/>
)}
{viz === 'bubble' &&
placeGeoMerged.features.map((f, idx) => {
const p = f.properties as Record<string, unknown> | null
const rawId = String(p?.GEOID ?? idx).replace(/\D/g, '')
const gid7 =
rawId.length <= 7 ? rawId.padStart(7, '0') : rawId.slice(-7).padStart(7, '0')
const num = placeDisplayByGeoid[gid7] ?? null
if (num == null || f.geometry == null) return null
const ll = featureLatLng(f)
if (!ll) return null
const r = bubbleRadiusPx(num, placeBubbleExtent.min, placeBubbleExtent.max, scale, 4, 22)
const bt =
metricToDisplayT(num, placeBubbleExtent.min, placeBubbleExtent.max, scale) ?? 0
const name = String(p?.NAME ?? gid7)
const isHL = leaderboardPinnedId != null && gid7 === leaderboardPinnedId
return (
<CircleMarker
key={gid7}
center={[ll.lat, ll.lng]}
radius={r}
pathOptions={{
color: isHL ? '#b45309' : '#fff',
weight: isHL ? 3 : 1,
fillColor: bubbleFillFromT(bt, 0.82),
fillOpacity: 1,
}}
>
<LeafletTooltip
direction="top"
offset={[0, -6]}
opacity={1}
className="!bg-white !text-slate-900 !border !border-slate-300 !rounded-lg !px-2.5 !py-2 !shadow-lg"
>
<div className="text-xs text-slate-900">
<div className="font-semibold text-slate-950">{name}</div>
<div className="tabular-nums text-slate-800">
{formatMetricValueCompact(metricSlug, num, metrics, valueMode)}
</div>
{(() => {
const rk = placeRankByGeoid.get(gid7)
if (!rk) return null
return (
<div className="mt-0.5 text-[10px] font-medium text-slate-600">
Ranked #{rk.rank} of {rk.total}
</div>
)
})()}
</div>
</LeafletTooltip>
</CircleMarker>
)
})}
{viz === 'bubble' && (
<GeoJSON
data={placeGeoMerged}
interactive={false}
style={{
fillColor: 'transparent',
color: '#64748b',
weight: 0.4,
fillOpacity: 0,
}}
/>
)}
<DrilldownMapBoundsController data={placeGeoMerged} />
</MapContainer>
)}
</div>
</div>
<aside className="flex flex-col gap-4 xl:sticky xl:top-4">
{viz === 'filled' && (
<ChoroplethLegend
min={placeChoroExtent.min}
max={placeChoroExtent.max}
scale={scale}
format={fmt}
valueMode={valueMode}
extentPoolsAllVintages={placeChoroPooledForLegend != null}
metricHelp={metricFullHelp}
semantics={choroLegendSemantics}
letterGradeLegend={giniRawLetterLegend}
/>
)}
{viz === 'bubble' && (
<BubbleLegend
min={placeBubbleExtent.min}
max={placeBubbleExtent.max}
scale={scale}
format={fmt}
metricHelp={metricFullHelp}
letterGradeLegend={giniRawLetterLegend}
/>
)}
{placeTrends && placeTrendSubject
? (() => {
const trendPts = trendPointsFromSeries(
placeTrends.vintages,
placeTrends.byGeoid[placeTrendSubject.id]?.[metricSlug] as Record<string, unknown> | undefined,
)
const stateLabel = stateName && String(stateName).trim() ? String(stateName) : 'this state'
const placeTrendNarr = buildPlaceTrendNarrative(
placeTrendSubject.name,
stateLabel,
narrativePack.trendChartCallouts,
)
return (
<AcTrendChart
title={buildCensusTrendChartTitle(
placeTrendSubject.name,
metricSlug,
metrics.find((m) => m.slug === metricSlug)?.label ?? metricSlug,
trendPts,
)}
subtitle={placeTrendNarr.subtitle}
readingLines={placeTrendNarr.readingLines}
chartTitleId="census-explorer-trend-chart-place"
points={trendPts}
format={fmt}
metricHelp={metricFullHelp}
metricTooltipLabel={currentMetricMeta?.label ?? metricSlug}
/>
)
})()
: null}
{placeGeoMerged && (
<>
<motion.div
className="flex min-h-0 flex-col rounded-lg border border-slate-200 bg-white p-3 shadow-sm"
initial={false}
animate={{ opacity: trendChartOpenPlace ? 0 : 1 }}
transition={trendFadeTransition}
style={{ pointerEvents: trendChartOpenPlace ? 'none' : undefined }}
>
<div className="mb-2 flex shrink-0 gap-2 border-b border-slate-100 pb-2">
<ChartBarSquareIcon className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" aria-hidden />
<div className="flex min-w-0 flex-1 items-start gap-1.5">
<h3 className="min-w-0 flex-1 text-sm font-semibold leading-snug text-slate-900 [overflow-wrap:anywhere]">
{narrativePack.leaderboardSectionTitle}
</h3>
<InfoHelpTrigger
topic="Leaderboard strip"
align="left"
help={`${metricFullHelp}\n\nClick a bar to highlight that place on the map (click again to clear).`}
buttonClassName="shrink-0 self-start rounded p-0.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
/>
</div>
</div>
<PlaceBarChart
className="min-h-0 flex-1"
rowsScrollClassName="max-h-[min(20rem,48vh)]"
features={placeGeoMerged.features}
metricSlug={metricSlug}
metrics={metrics}
valueMode={valueMode}
displayByGeoid={placeDisplayByGeoid}
playing={playing}
narrativePack={narrativePack}
geoLevel="place"
pinnedRowId={leaderboardPinnedId}
onTogglePinnedRow={toggleLeaderboardPin}
leaderSilhouetteUsps={stateUsps ?? null}
displayVintage={displayVintage}
/>
</motion.div>
<PlaceTable
features={placeGeoMerged.features}
geoLevel="place"
displayByGeoid={placeDisplayByGeoid}
metricSlug={metricSlug}
metrics={metrics}
valueMode={valueMode}
/>
</>
)}
</aside>
</div>
</div>
)}
</div>
)
}
type PlaceBarChartProps = {
className?: string
rowsScrollClassName?: string
features: GeoJSON.Feature[]
metricSlug: string
metrics: CensusMetric[]
valueMode: CensusValueMode
displayByGeoid: Record<string, number | null>
playing?: boolean
topN?: number
narrativePack?: CensusNarrativePack | null
geoLevel: 'county' | 'place'
pinnedRowId?: string | null
onTogglePinnedRow?: (id: string) => void
/** State silhouette at top of strip while #1 row may be a county or place. */
leaderSilhouetteUsps?: string | null
displayVintage: string
}
function PlaceBarChart(props: PlaceBarChartProps) {
const {
className = '',
rowsScrollClassName,
features,
metricSlug,
metrics,
valueMode,
displayByGeoid,
playing = false,
topN = CENSUS_TOP_BAR_ROW_LIMIT,
narrativePack,
geoLevel,
pinnedRowId = null,
onTogglePinnedRow,
leaderSilhouetteUsps = null,
displayVintage,
} = props
const rows = useMemo(() => {
return features
.map((f, i) => {
const p = f.properties as Record<string, unknown> | null
const id =
geoLevel === 'county'
? countyGeoidFromFeature(f as GeoJSON.Feature) || `idx_${i}`
: placeGeoid7FromProperties(p, i)
const name = String(p?.NAME ?? p?.GEOID ?? i)
const disp = displayByGeoid[id] ?? null
return { id, name, fullName: name, value: disp }
})
.filter((r) => r.value != null)
.sort((a, b) => compareRankedMetricValues(a.value!, b.value!, metricSlug))
.slice(0, topN)
.map((r) => ({
id: r.id,
label: truncateStateLabel(r.name, 20),
fullName: r.fullName,
value: r.value!,
}))
}, [features, metricSlug, topN, geoLevel, displayByGeoid])
const formatAxisTick = useCallback(
(x: number, span?: number) => formatCensusMapAxisTickForMetric(metricSlug, metrics, x, span, valueMode),
[metricSlug, metrics, valueMode],
)
const metricMeta = useMemo(() => metrics.find((m) => m.slug === metricSlug), [metrics, metricSlug])
const winnerRankLabel = metricMeta?.label ?? metricSlug
const winnerMetricHelp = useMemo(() => censusMetricFullHelp(metricSlug, metricMeta), [metricSlug, metricMeta])
const dataQualityNote = useMemo(() => {
if (valueMode !== 'raw') return null
const raw = features.map((f) => {
const v = (f.properties as Record<string, unknown> | null)?.[metricSlug]
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
return censusMetricStaleDataNote(metricSlug, valueMode, raw)
}, [features, metricSlug, valueMode])
return (
<CensusRaceBarChart
className={className}
rowsScrollClassName={rowsScrollClassName}
rows={rows}
formatValue={(v) => formatMetricValue(metricSlug, v, metrics, valueMode)}
formatBarEnd={(v) => formatMetricValueCompact(metricSlug, v, metrics, valueMode)}
formatAxisTick={formatAxisTick}
playing={playing}
leaderSilhouetteUsps={leaderSilhouetteUsps}
vintageYear={displayVintage}
yearHelp={CENSUS_MAP_UI_HELP.year}
winnerCaption={censusMetricWinnerCaption(metricSlug)}
winnerRankLabel={winnerRankLabel}
winnerMetricHelp={winnerMetricHelp}
readingCalloutLines={narrativePack?.barChartCallouts ?? null}
dataQualityNote={dataQualityNote}
selectedRowId={pinnedRowId}
onRowClick={onTogglePinnedRow}
/>
)
}
type PlaceTableProps = {
features: GeoJSON.Feature[]
geoLevel: 'county' | 'place'
displayByGeoid: Record<string, number | null>
metricSlug: string
metrics: CensusMetric[]
valueMode: CensusValueMode
tableLabel?: string
}
function PlaceTable(props: PlaceTableProps) {
const { features, geoLevel, displayByGeoid, metricSlug, metrics, valueMode, tableLabel = 'All places' } = props
const [sort, setSort] = useState<{ key: 'name' | 'value' | 'geoid'; dir: 'asc' | 'desc' }>(() => ({
key: 'value',
dir: censusMetricRankDirection(metricSlug) === 'lower' ? 'asc' : 'desc',
}))
useEffect(() => {
setSort((prev) =>
prev.key === 'value'
? { key: 'value', dir: censusMetricRankDirection(metricSlug) === 'lower' ? 'asc' : 'desc' }
: prev,
)
}, [metricSlug])
const rows = useMemo(() => {
return features.map((f, i) => {
const p = f.properties as Record<string, unknown> | null
const id =
geoLevel === 'county'
? countyGeoidFromFeature(f as GeoJSON.Feature) || `idx_${i}`
: placeGeoid7FromProperties(p, i)
const name = String(p?.NAME ?? id)
const disp = displayByGeoid[id] ?? null
return { geoid: id, name, value: disp }
})
}, [features, metricSlug, displayByGeoid, geoLevel])
const sorted = useMemo(() => {
const arr = [...rows]
const mul = sort.dir === 'asc' ? 1 : -1
arr.sort((a, b) => {
if (sort.key === 'geoid') return mul * a.geoid.localeCompare(b.geoid)
if (sort.key === 'name') return mul * a.name.localeCompare(b.name)
const av = a.value ?? -Infinity
const bv = b.value ?? -Infinity
return mul * (av - bv)
})
return arr
}, [rows, sort])
const toggle = (key: 'name' | 'value' | 'geoid') => {
setSort((prev) => {
if (prev.key === key) return { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
const valueDir = censusMetricRankDirection(metricSlug) === 'lower' ? 'asc' : 'desc'
return { key, dir: key === 'value' ? valueDir : 'asc' }
})
}
const tableHelp = useMemo(
() =>
`${censusMetricFullHelp(metricSlug, metrics.find((m) => m.slug === metricSlug))}\n\n${CENSUS_MAP_UI_HELP.allGeographiesTable}`,
[metricSlug, metrics],
)
return (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm overflow-hidden flex flex-col max-h-[min(420px,50vh)]">
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b border-slate-100 bg-slate-50/90">
<TableCellsIcon className="h-4 w-4 text-slate-600 shrink-0" />
<LabelWithInfo label={tableLabel} help={tableHelp} />
<span className="text-[10px] text-slate-500 ml-auto">{sorted.length} rows</span>
</div>
<div className="overflow-auto flex-1">
<table className="min-w-full text-xs">
<thead className="sticky top-0 bg-white shadow-sm z-10">
<tr className="text-left text-slate-500 border-b border-slate-200">
<th className="px-2 py-2 font-medium">
<button type="button" className="hover:text-slate-900" onClick={() => toggle('geoid')}>
GEOID {sort.key === 'geoid' ? (sort.dir === 'asc' ? '↑' : '↓') : ''}
</button>
</th>
<th className="px-2 py-2 font-medium">
<button type="button" className="hover:text-slate-900" onClick={() => toggle('name')}>
Name {sort.key === 'name' ? (sort.dir === 'asc' ? '↑' : '↓') : ''}
</button>
</th>
<th className="px-2 py-2 font-medium text-right">
<button type="button" className="hover:text-slate-900" onClick={() => toggle('value')}>
Value {sort.key === 'value' ? (sort.dir === 'asc' ? '↑' : '↓') : ''}
</button>
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{sorted.map((row) => (
<tr key={row.geoid} className="hover:bg-slate-50">
<td className="px-2 py-1.5 font-mono text-slate-600">{row.geoid}</td>
<td className="px-2 py-1.5 text-slate-800 leading-snug">{row.name}</td>
<td className="px-2 py-1.5 text-right tabular-nums text-slate-800">
{formatMetricValue(metricSlug, row.value, metrics, valueMode)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
export default CensusMapPage