Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| 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 ( | |
| <div className={`flex min-h-0 w-full min-w-0 max-w-full flex-col ${className}`}> | |
| {readingCalloutLines?.length ? ( | |
| <div className="mb-2 w-full shrink-0"> | |
| <button | |
| type="button" | |
| className="mb-2 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>{readingCalloutTitle ?? '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="w-full rounded-md border border-amber-100/90 bg-amber-50/45 px-2.5 py-2" | |
| role="region" | |
| aria-label={readingCalloutTitle ?? 'How to read this chart'} | |
| > | |
| <ul className="space-y-1 text-xs text-slate-800 leading-snug"> | |
| {readingCalloutLines.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} | |
| {dataQualityNote ? ( | |
| <div | |
| className="mb-2 w-full shrink-0 rounded-md border border-rose-200 bg-rose-50/95 px-2.5 py-2 text-sm leading-snug text-rose-950/95 shadow-sm" | |
| role="alert" | |
| > | |
| <span className="text-xs font-semibold uppercase tracking-wide text-rose-900">Data quality — </span> | |
| {dataQualityNote} | |
| </div> | |
| ) : null} | |
| {winner ? ( | |
| <div className="mb-3 flex w-full min-w-0 shrink-0 flex-col gap-2 border-b border-slate-200 pb-3 sm:flex-row sm:items-start sm:justify-between sm:gap-3"> | |
| <div className="flex min-w-0 flex-1 flex-row items-start gap-3"> | |
| {heroUrl ? ( | |
| <div className="group relative w-[min(5.5rem,28%)] shrink-0 sm:w-20"> | |
| <div className="flex h-[4.5rem] w-full items-center justify-center rounded-lg border border-slate-200/90 bg-gradient-to-b from-slate-50 to-slate-100/90 px-1.5 shadow-sm ring-1 ring-slate-900/5"> | |
| <img | |
| src={heroUrl} | |
| alt={heroAlt} | |
| className={`max-h-[3.75rem] max-w-full object-contain opacity-90 drop-shadow-[0_2px_4px_rgba(53,79,82,0.12)] transition-[transform,filter] duration-300 ease-out ${ | |
| reduced ? '' : 'group-hover:scale-[1.03] group-hover:drop-shadow-[0_3px_8px_rgba(53,79,82,0.18)]' | |
| }`} | |
| loading="lazy" | |
| decoding="async" | |
| /> | |
| </div> | |
| </div> | |
| ) : null} | |
| <div className="min-w-0 flex-1"> | |
| <div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5"> | |
| <p className="min-w-0 flex-1 text-sm font-extrabold leading-snug tracking-tight text-slate-900 [overflow-wrap:anywhere]"> | |
| <span className="text-slate-950">{winner.fullName ?? winner.label}</span>{' '} | |
| <span className="font-extrabold text-slate-800">ranks #1 for {rankWhat}</span> | |
| </p> | |
| {winnerMetricHelp ? ( | |
| <InfoHelpTrigger | |
| topic="Top rank on this metric" | |
| align="left" | |
| help={winnerMetricHelp} | |
| buttonClassName="shrink-0 rounded p-0.5 text-slate-500 hover:bg-slate-100 hover:text-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#354F52]" | |
| /> | |
| ) : null} | |
| </div> | |
| {winnerCaption ? ( | |
| <div className="mt-1 flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5"> | |
| <span className="min-w-0 text-[10px] font-semibold leading-snug text-slate-600"> | |
| {winnerCaption} | |
| </span> | |
| <span className="shrink-0 whitespace-nowrap font-extrabold tabular-nums text-xs leading-none text-slate-800"> | |
| {formatValue(winner.value)} | |
| </span> | |
| </div> | |
| ) : ( | |
| <div className="mt-1 whitespace-nowrap font-extrabold tabular-nums text-xs leading-none text-slate-800"> | |
| {formatValue(winner.value)} | |
| </div> | |
| )} | |
| {nationalContextNote ? ( | |
| <p className="mt-1.5 text-[10px] leading-snug text-slate-600">{nationalContextNote}</p> | |
| ) : null} | |
| </div> | |
| </div> | |
| {vintageYear != null && vintageYear !== '' ? ( | |
| <div className="census-map-year-badge flex shrink-0 flex-row items-center gap-1.5 self-start rounded-xl border border-slate-300 bg-white px-2.5 py-1 shadow-md sm:ml-auto"> | |
| <div className="flex flex-row items-baseline gap-2"> | |
| <span className="text-[9px] font-bold uppercase tracking-wider text-slate-500">Year</span> | |
| <span className="text-xl font-extrabold tabular-nums leading-none text-slate-900">{vintageYear}</span> | |
| </div> | |
| {yearHelp ? <InfoHelpTrigger help={yearHelp} topic="ACS year" align="right" /> : null} | |
| </div> | |
| ) : null} | |
| </div> | |
| ) : null} | |
| <div | |
| className={ | |
| rowsScrollClassName?.trim() | |
| ? `flex min-h-0 flex-col gap-2 overflow-x-hidden overflow-y-auto overscroll-contain py-0.5 [scrollbar-gutter:stable] ${rowsScrollClassName.trim()}` | |
| : 'flex flex-col gap-2 overflow-x-hidden overflow-y-visible py-0.5' | |
| } | |
| > | |
| {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 ( | |
| <motion.div | |
| key={r.id} | |
| layout={!reduced} | |
| title={tip} | |
| aria-pressed={onRowClick ? selected : undefined} | |
| transition={rowTransition} | |
| role={onRowClick ? 'button' : undefined} | |
| tabIndex={onRowClick ? 0 : undefined} | |
| onClick={onRowClick ? () => 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 }} | |
| > | |
| <div className="justify-self-end text-[10px] font-extrabold tabular-nums leading-none text-slate-500"> | |
| #{rankN} | |
| </div> | |
| <div className="min-w-0 justify-self-stretch text-right text-[11px] font-semibold leading-snug text-slate-900 break-words hyphens-auto [overflow-wrap:anywhere]"> | |
| <span className="inline-flex min-w-0 flex-col items-end gap-0.5"> | |
| <span>{r.label}</span> | |
| {selected ? ( | |
| <span className="text-[9px] font-bold uppercase tracking-wide text-amber-800">Selected</span> | |
| ) : null} | |
| </span> | |
| </div> | |
| <div className="relative h-[22px] min-h-[22px] min-w-0 w-full max-w-full overflow-hidden rounded-md bg-slate-100 ring-1 ring-slate-200/80"> | |
| <motion.div | |
| layout={false} | |
| className="h-full rounded-r-md bg-[#3d5c55] shadow-inner" | |
| initial={false} | |
| animate={{ width: `${Math.max(0, Math.min(100, pct))}%` }} | |
| transition={widthTransition} | |
| /> | |
| </div> | |
| <div className="min-w-0 justify-self-end text-right text-[8.5px] font-bold tabular-nums leading-none text-slate-700"> | |
| {fmtEnd(r.value)} | |
| </div> | |
| </motion.div> | |
| ) | |
| })} | |
| </div> | |
| <div | |
| className="mt-2 grid w-full max-w-full min-w-0 shrink-0 gap-x-2 border-t border-slate-200 bg-white pt-2" | |
| style={{ gridTemplateColumns: gridCols }} | |
| > | |
| <div aria-hidden className="min-w-0" /> | |
| <div aria-hidden className="min-w-0" /> | |
| <div className="relative min-h-[2.35rem] min-w-0 max-w-full"> | |
| <div className="pointer-events-none absolute inset-x-0 top-0 border-b border-slate-500" /> | |
| {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 ( | |
| <div key={i} className="absolute top-0 flex flex-col" style={{ ...style, alignItems }}> | |
| <span className="h-2 w-px shrink-0 bg-slate-600" aria-hidden /> | |
| <span | |
| className={`mt-1 max-w-[3.75rem] text-[9px] font-semibold tabular-nums leading-tight text-slate-600 ${textClass}`} | |
| > | |
| {formatAxisTick(t, valueSpan)} | |
| </span> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| <div aria-hidden /> | |
| </div> | |
| </div> | |
| ) | |
| } | |