Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| import { useEffect, useMemo, useState } from 'react' | |
| import { | |
| BuildingLibraryIcon, | |
| ChartBarIcon, | |
| ChevronLeftIcon, | |
| HeartIcon, | |
| MinusIcon, | |
| PlusIcon, | |
| ShieldCheckIcon, | |
| Squares2X2Icon, | |
| UsersIcon, | |
| } from '@heroicons/react/24/outline' | |
| import { InfoHelpTrigger } from './InfoHelpTrigger' | |
| import { groupMetricsByTheme, themeIdForMetric } from '../utils/censusMetricGroups' | |
| /** | |
| * Leading glyph per top-level theme, keyed by the taxonomy's theme id | |
| * (../utils/censusMetricGroups). Anything without an explicit entry — including | |
| * the trailing "More measures" bucket — falls back to a neutral grid icon. | |
| */ | |
| const THEME_ICONS: Record<string, typeof ChartBarIcon> = { | |
| economy: ChartBarIcon, | |
| people: UsersIcon, | |
| health: HeartIcon, | |
| crime: ShieldCheckIcon, | |
| government: BuildingLibraryIcon, | |
| } | |
| interface CensusMetricBrowserPanelProps { | |
| metrics: ReadonlyArray<{ slug: string; label: string }> | |
| metricSlug: string | |
| metricFullHelp: string | |
| onPick: (slug: string) => void | |
| /** Collapse the panel back to the bare rail. */ | |
| onCollapse: () => void | |
| } | |
| /** | |
| * Persistent, collapsible metric browser that sits beside the map's display | |
| * rail. Metrics are organised into two levels — top-level themes (Economy, | |
| * People, Health, …) each holding sub-grouped metrics (Income, Housing, …) — | |
| * from the shared taxonomy (../utils/censusMetricGroups). The theme owning the | |
| * active metric starts expanded so the current selection is always visible. | |
| * Collapsed themes show a "+"; expanded themes show "−". Themes with no metrics | |
| * yet (Crime, Government) still expand, to an empty placeholder. The header | |
| * dropdown remains the small-screen affordance — this panel is desktop-only. | |
| */ | |
| export default function CensusMetricBrowserPanel({ | |
| metrics, | |
| metricSlug, | |
| metricFullHelp, | |
| onPick, | |
| onCollapse, | |
| }: CensusMetricBrowserPanelProps) { | |
| const themes = useMemo(() => groupMetricsByTheme(metrics), [metrics]) | |
| // Track which themes are expanded. The theme holding the active metric is | |
| // opened on mount and whenever the selection jumps to a different theme (e.g. | |
| // via the URL), without stomping a user's manual expand/collapse elsewhere. | |
| const activeThemeId = themeIdForMetric(metricSlug) | |
| const [open, setOpen] = useState<Record<string, boolean>>(() => ({ [activeThemeId]: true })) | |
| useEffect(() => { | |
| setOpen((prev) => (prev[activeThemeId] ? prev : { ...prev, [activeThemeId]: true })) | |
| }, [activeThemeId]) | |
| return ( | |
| <div className="flex h-full w-72 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> | |
| <div className="flex items-center justify-between gap-2 border-b border-slate-200 px-3 py-2.5"> | |
| <span className="inline-flex items-center gap-1.5 text-sm font-semibold text-slate-900"> | |
| Metrics | |
| <InfoHelpTrigger help={metricFullHelp} topic="Metric" align="left" /> | |
| </span> | |
| <button | |
| type="button" | |
| onClick={onCollapse} | |
| className="rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700" | |
| aria-label="Collapse metric panel" | |
| title="Collapse metric panel" | |
| > | |
| <ChevronLeftIcon className="h-4 w-4" /> | |
| </button> | |
| </div> | |
| <div className="min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-contain p-2"> | |
| {themes.map((t) => { | |
| const isOpen = !!open[t.id] | |
| const Icon = THEME_ICONS[t.id] ?? Squares2X2Icon | |
| return ( | |
| <div key={t.id} className="overflow-hidden rounded-lg border border-slate-200"> | |
| <button | |
| type="button" | |
| onClick={() => setOpen((prev) => ({ ...prev, [t.id]: !prev[t.id] }))} | |
| className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm font-semibold text-slate-800 hover:bg-slate-50" | |
| aria-expanded={isOpen} | |
| > | |
| <Icon className="h-[18px] w-[18px] shrink-0 text-slate-500" aria-hidden /> | |
| <span className="min-w-0 flex-1 truncate">{t.title}</span> | |
| {/* Expand/collapse affordance — "+" stays visible when collapsed. */} | |
| {isOpen ? ( | |
| <MinusIcon className="h-4 w-4 shrink-0 text-slate-400" aria-hidden /> | |
| ) : ( | |
| <PlusIcon className="h-4 w-4 shrink-0 text-slate-400" aria-hidden /> | |
| )} | |
| </button> | |
| {isOpen ? ( | |
| <div className="border-t border-slate-200 p-1.5"> | |
| {t.groups.length === 0 ? ( | |
| <p className="px-2 py-2 text-[12px] italic leading-snug text-slate-400"> | |
| No metrics yet | |
| </p> | |
| ) : ( | |
| t.groups.map((g) => ( | |
| <div key={g.id} className="mb-1 last:mb-0"> | |
| <div className="px-2 pb-0.5 pt-1.5 text-[11px] font-semibold uppercase tracking-wider text-slate-400"> | |
| {g.shortTitle ?? g.title} | |
| </div> | |
| <ul> | |
| {g.metrics.map((m) => { | |
| const selected = m.slug === metricSlug | |
| return ( | |
| <li key={m.slug}> | |
| <button | |
| type="button" | |
| onClick={() => onPick(m.slug)} | |
| aria-current={selected ? 'true' : undefined} | |
| className={`block w-full rounded-md px-2 py-2 text-left text-sm leading-snug ${ | |
| selected | |
| ? 'bg-indigo-100 font-medium text-indigo-900' | |
| : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900' | |
| }`} | |
| > | |
| {m.label} | |
| </button> | |
| </li> | |
| ) | |
| })} | |
| </ul> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| ) : null} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| ) | |
| } | |