import { Box, Typography } from '@mui/material' import { Tooltip } from '../../../components/tooltip/tooltip' import { ErrorOutline as ErrorIcon } from '@mui/icons-material' import { styles } from './styles' import { DEFAULT_LEGEND_LABELS, type LegendLabels } from '../../provider/labels' import type { LegendRampVariable } from '../../stores' export interface LegendRampUIProps { data: LegendRampVariable /** Override the renderer's user-facing strings. */ labels?: Pick } /** * Horizontal position (0–100) of `avg` on the bar — linear between the first * and last stop values, clamped to the edges. `null` when there's no scale * (fewer than 2 stops or a zero-width range). */ function avgPercent(data: LegendRampVariable): number | null { const { avg, stops } = data if (avg == null || stops.length < 2) return null const v0 = stops[0]!.value const vn = stops[stops.length - 1]!.value if (v0 === vn) return null const pct = ((avg - v0) / (vn - v0)) * 100 return Math.min(100, Math.max(0, pct)) } /** * Color-ramp legend. `colors` and `stops` are both ordered low→high and are * never reversed, so step colors and their labels stay aligned. * * Continuous mode renders a gradient with the endpoint labels. Discrete mode * **requires `stops.length === colors.length + 1`** — stops are the boundaries * between color steps (including both outer edges) and each label is centered * on its boundary line. A discrete ramp with any other stop count is an * incoherent state and renders a compact inline error instead of the bar. * * @experimental This API is new and may change in a future release. */ export function LegendRampUI({ data, labels = DEFAULT_LEGEND_LABELS, }: LegendRampUIProps) { const { colors, stops, isContinuous, attribute, formatValue = String } = data const avgPct = avgPercent(data) const first = stops[0] const last = stops[stops.length - 1] const gradient = `linear-gradient(90deg, ${colors.join(', ')})` // Discrete ramps need one stop per color boundary (colors.length + 1). // `isBoundary` is therefore equivalent to "valid discrete". const isBoundary = !isContinuous && stops.length === colors.length + 1 const invalidDiscrete = !isContinuous && !isBoundary // Boundary range for assistive tech (valid discrete only reaches here). const stepAriaLabel = (index: number): string => `${stops[index]?.label ?? ''} – ${stops[index + 1]?.label ?? ''}` const attributeHeader = attribute && ( {labels.colorBasedOn} {attribute} ) if (invalidDiscrete) { return ( {attributeHeader} {`Discrete ramp needs ${colors.length + 1} stops (one per color boundary) but got ${stops.length}.`} ) } return ( {attributeHeader} {isContinuous ? ( ) : ( colors.map((color, index) => ( )) )} {avgPct != null && data.avg != null && ( {/* Inline style: per-instance dynamic value, no emotion class churn. */} )} {isBoundary ? ( {stops.map((stop, index) => { // Interior labels center on their boundary line; the outer two // clamp inside the bar (first left-aligned, last right-aligned) // so the row never overflows. const isLast = index === stops.length - 1 const transform = index === 0 ? undefined : isLast ? 'translateX(-100%)' : 'translateX(-50%)' return ( {stop.label} ) })} ) : ( // Continuous gradients show only the range endpoints. {first?.label} {last?.label} )} ) }