import { useId, useMemo, useState } from 'react' import type { CSSProperties } from 'react' import { motion, useReducedMotion } from 'framer-motion' import { formatCensusMapAxisTick } from '../utils/censusMapTransforms' import { STATE_CODE_TO_NAME } from '../utils/stateMapping' import { defaultStateSilhouettePublicSrc, stateSilhouettePublicSrc, } from '../utils/wikimediaStateSilhouette' import { InfoHelpTrigger } from './InfoHelpTrigger' export type CensusRaceBarRow = { id: string label: string fullName?: string value: number } function extentForRows(rows: CensusRaceBarRow[]): { min: number; max: number } { const vals = rows.map((r) => r.value).filter((x) => Number.isFinite(x)) if (!vals.length) return { min: 0, max: 1 } const rawMin = Math.min(...vals) const rawMax = Math.max(...vals) const min = Math.min(0, rawMin) const max = Math.max(0, rawMax) if (min === max) return { min, max: min + 1 } return { min, max } } function barWidthPercent(value: number, min: number, max: number): number { const span = max - min || 1 return ((value - min) / span) * 100 } function tickPositionPercent(tickValue: number, min: number, max: number): number { const span = max - min || 1 return ((tickValue - min) / span) * 100 } const AXIS_TICK_COUNT = 6 function leaderboardSilhouetteSrc(usps: string | null): string | null { return stateSilhouettePublicSrc(usps, 'locator') ?? defaultStateSilhouettePublicSrc() } function leaderboardSilhouetteAlt(usps: string | null): string { if (!usps || usps.length !== 2) { return 'United States map silhouette (Wikimedia Commons).' } const name = STATE_CODE_TO_NAME[usps.toUpperCase()] ?? usps return `${name} highlighted on United States map (Wikimedia Commons).` } type CensusRaceBarChartProps = { rows: CensusRaceBarRow[] formatValue: (v: number) => string formatBarEnd?: (v: number) => string formatAxisTick?: (n: number, valueSpan?: number) => string playing?: boolean /** * Hero silhouette above the #1 row. ``null`` = United States; two-letter USPS = that state. * Omit on the national US map to default to the USA silhouette. */ leaderSilhouetteUsps?: string | null /** Shown beside the winner row (e.g. selected map year). */ vintageYear?: string | null /** Tooltip for the year badge (ACS window labeling). */ yearHelp?: string | null /** Subtitle under the #1 headline explaining rank direction for the metric. */ winnerCaption?: string | null /** e.g. national benchmark line shown under the top value when ``valueMode`` is raw. */ nationalContextNote?: string | null /** Human-readable metric name in the #1 headline (e.g. “Median household income”). */ winnerRankLabel?: string | null /** Full metric dictionary + ranking copy for the (i) next to the #1 headline. */ winnerMetricHelp?: string | null readingCalloutTitle?: string | null readingCalloutLines?: string[] | null /** Optional alert when bundled values fail basic sanity checks (stale export column). */ dataQualityNote?: string | null className?: string /** When set, the matching row is outlined (paired with map highlight from the parent). */ selectedRowId?: string | null /** Click a bar to select/deselect; parent syncs highlight on the map. */ onRowClick?: (rowId: string) => void /** * When set, only the ranked bar rows scroll (winner headline, year badge, and axis stay fixed). * Use for long county/place lists; omit on U.S. state strip. */ rowsScrollClassName?: string } export function CensusRaceBarChart({ rows, formatValue, formatBarEnd, formatAxisTick = formatCensusMapAxisTick, playing = false, leaderSilhouetteUsps, vintageYear = null, yearHelp = null, winnerCaption = null, nationalContextNote = null, winnerRankLabel = null, winnerMetricHelp = null, readingCalloutTitle = 'How to read this chart', readingCalloutLines = null, dataQualityNote = null, className = '', selectedRowId = null, onRowClick, rowsScrollClassName, }: CensusRaceBarChartProps) { const reduced = useReducedMotion() const readingPanelId = useId() const [readingOpen, setReadingOpen] = useState(false) const { min, max } = useMemo(() => extentForRows(rows), [rows]) const valueSpan = max - min const ticks = useMemo(() => { return Array.from({ length: AXIS_TICK_COUNT }, (_, i) => min + (i / (AXIS_TICK_COUNT - 1)) * (max - min)) }, [min, max]) const winner = rows[0] const silhouetteUsps = leaderSilhouetteUsps !== undefined ? leaderSilhouetteUsps : null const rankWhat = winnerRankLabel?.trim() ? winnerRankLabel.trim() : 'this metric' const heroUrl = leaderboardSilhouetteSrc(silhouetteUsps) const heroAlt = leaderboardSilhouetteAlt(silhouetteUsps) const rowTransition = reduced ? { duration: 0.12, ease: 'easeOut' as const } : playing ? { type: 'spring' as const, stiffness: 260, damping: 34, mass: 0.82 } : { type: 'spring' as const, stiffness: 400, damping: 36, mass: 0.72 } const fmtEnd = formatBarEnd ?? formatValue const widthTransition = reduced ? { duration: 0.12, ease: 'easeOut' as const } : playing ? { type: 'spring' as const, stiffness: 200, damping: 28, mass: 0.88 } : { type: 'spring' as const, stiffness: 340, damping: 32, mass: 0.78 } /** Rank #, place label, bar, value — keeps the track wide while making order scannable. */ const gridCols = '2rem minmax(4.5rem, 6.75rem) minmax(0, 1fr) 2.35rem' return (
{winner.fullName ?? winner.label}{' '} ranks #1 for {rankWhat}
{winnerMetricHelp ? ({nationalContextNote}
) : null}