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 (
{label}
)
}
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 (
{metricHelp ? (
) : (
What the colors mean
)}
{semantics ? (
<>
{semantics.lowEnd}
←→
{semantics.highEnd}
{semantics.gradientHint}
>
) : null}
{letterGradeLegend ? (
{letterGradeLegend}
) : null}
{legendHelpOpen ? (
{legendInfoBody}
{legendFootnote}
) : null}
)
}
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 (