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 (
{readingCalloutLines?.length ? (
{readingOpen ? (
    {readingCalloutLines.map((line, i) => (
  • · {line}
  • ))}
) : null}
) : null} {dataQualityNote ? (
Data quality — {dataQualityNote}
) : null} {winner ? (
{heroUrl ? (
{heroAlt}
) : null}

{winner.fullName ?? winner.label}{' '} ranks #1 for {rankWhat}

{winnerMetricHelp ? ( ) : null}
{winnerCaption ? (
{winnerCaption} {formatValue(winner.value)}
) : (
{formatValue(winner.value)}
)} {nationalContextNote ? (

{nationalContextNote}

) : null}
{vintageYear != null && vintageYear !== '' ? (
Year {vintageYear}
{yearHelp ? : null}
) : null}
) : null}
{rows.map((r, rowIdx) => { const pct = barWidthPercent(r.value, min, max) const rankN = rowIdx + 1 const tip = `#${rankN} — ${rankWhat}: ${formatValue(r.value)} — ${r.fullName ?? r.label}` const selected = selectedRowId != null && selectedRowId === r.id return ( onRowClick(r.id) : undefined} onKeyDown={ onRowClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onRowClick(r.id) } } : undefined } className={`grid w-full max-w-full min-w-0 shrink-0 items-center gap-x-2 rounded-md outline-none ${ onRowClick ? 'cursor-pointer hover:bg-slate-50/90 focus-visible:ring-2 focus-visible:ring-[#354F52] focus-visible:ring-offset-1' : '' } ${ selected ? 'bg-amber-50 ring-2 ring-amber-600/90 ring-inset shadow-[inset_0_0_0_1px_rgba(217,119,6,0.25)]' : '' }`} style={{ gridTemplateColumns: gridCols }} >
#{rankN}
{r.label} {selected ? ( Selected ) : null}
{fmtEnd(r.value)}
) })}
{ticks.map((t, i) => { const n = ticks.length const isFirst = i === 0 const isLast = i === n - 1 const leftPct = tickPositionPercent(t, min, max) const style: CSSProperties = isFirst ? { left: 0, transform: 'none' } : isLast ? { left: '100%', transform: 'translateX(-100%)' } : { left: `${leftPct}%`, transform: 'translateX(-50%)' } const alignItems: 'flex-start' | 'flex-end' | 'center' = isFirst ? 'flex-start' : isLast ? 'flex-end' : 'center' const textClass = isFirst ? 'text-left' : isLast ? 'text-right' : 'text-center' return (
{formatAxisTick(t, valueSpan)}
) })}
) }