Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| // @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 as “this 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 | |