open-navigator / web_app /src /components /CensusMapLegends.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
9.47 kB
import { useId, useState } from 'react'
import { SwatchIcon } from '@heroicons/react/24/outline'
import {
CENSUS_SCALES,
bubbleFillFromT,
bubbleRadiusPx,
colorFromT,
metricToDisplayT,
type CensusScaleId,
} from '../utils/censusMapTransforms'
import type { CensusValueMode } from '../utils/censusMapValueMode'
import type { CensusChoroLegendSemantics } from '../utils/censusDataDictionary'
import { InfoHelpTrigger } from './InfoHelpTrigger'
const CHORO_LEGEND_GRADIENT_STOPS = 17
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>
)
}
export 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
extentPoolsAllVintages?: boolean
metricHelp?: string
semantics?: CensusChoroLegendSemantics | null
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>
)
}
export 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>
)
}