'use client'; import { useMemo, useState } from 'react'; import { t } from '@/i18n'; import { formatTick, formatValue } from '@/lib/format'; import type { FormatSpec as Spec } from '@/lib/types'; import { ChartFrame, type TableData } from './chart-frame'; import { CHART, MARK } from './palette'; import { DEFAULT_MARGIN, extent } from './scales'; import { ChartTooltip, TooltipRow } from './tooltip'; import { useMeasure } from './use-measure'; import { scaleLinear, scaleLog } from 'd3-scale'; export interface HistogramMarker { id: string; label: string; value: number; /** 'accent' for the selected country, 'ink' for medians. */ tone?: 'accent' | 'ink' | 'muted'; } /** * Country distribution histogram: equal-width bins (log-x when `edges` come from a log histogram), one * neutral colour, vertical markers (world median, region median, selected country) with labels above the * plot, hover tooltip per bin, accessible table. Bins are given by the API (`edges` = n+1 boundaries). */ export function Histogram({ edges, counts, log = false, markers = [], spec, height = 220, title, subtitle, className, defaultWidth = 640, unitLabel }: { edges: number[]; counts: number[]; log?: boolean; markers?: HistogramMarker[]; spec: Spec; height?: number; title?: React.ReactNode; subtitle?: React.ReactNode; className?: string; defaultWidth?: number; unitLabel?: string }) { const { ref, width } = useMeasure(defaultWidth); const [hover, setHover] = useState(null); const m = { ...DEFAULT_MARGIN, top: markers.length ? (markers.length > 2 ? 46 : 34) : 12, bottom: 28, left: 36 }; const model = useMemo(() => { const innerW = Math.max(10, width - m.left - m.right); const innerH = Math.max(10, height - m.top - m.bottom); const lo = edges[0] ?? 0; const hi = edges[edges.length - 1] ?? 1; const x = log && lo > 0 ? scaleLog().domain([lo, hi]).range([0, innerW]) : scaleLinear().domain([lo, hi]).range([0, innerW]); const maxC = Math.max(1, ...counts); const y = scaleLinear().domain([0, maxC]).range([innerH, 0]).nice(4); const bars = counts.map((c, i) => { const x0 = x(edges[i]!); const x1 = x(edges[i + 1]!); return { i, x: x0, w: Math.max(1, x1 - x0 - 1), y: y(c), h: innerH - y(c), c, lo: edges[i]!, hi: edges[i + 1]! }; }); const xTicks = (log ? (x as ReturnType).ticks(4) : (x as ReturnType).ticks(Math.max(3, Math.floor(innerW / 90)))).filter((v) => v >= lo && v <= hi); // Marker label placement: stagger vertically when two labels would collide. const placed = markers .map((mk) => ({ ...mk, px: Math.min(innerW, Math.max(0, x(mk.value))) })) .sort((a, b) => a.px - b.px) .map((mk, i, arr) => ({ ...mk, row: Math.min(2, arr.slice(0, i).filter((o) => mk.px - o.px < 90).length) })); return { innerW, innerH, x, y, bars, xTicks, yTicks: y.ticks(3), placed, dom: extent(edges) ?? [lo, hi] }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [edges, counts, log, markers, width, height]); const total = counts.reduce((a, b) => a + b, 0); const summary = t('chart.summary.histogram', { n: total, min: formatValue(model.dom[0], spec), max: formatValue(model.dom[1], spec) }); const table: TableData = useMemo( () => ({ columns: [{ key: 'range', label: spec.name ?? t('common.value') }, { key: 'n', label: t('chart.histogram.countries'), numeric: true }], rows: model.bars.map((b) => ({ range: `${formatValue(b.lo, spec)} – ${formatValue(b.hi, spec)}`, n: String(b.c) })), }), [model.bars, spec], ); const hb = hover != null ? model.bars[hover] : null; return (
{total === 0 ? (
{t('chart.noData')}
) : ( {typeof title === 'string' ? title : spec.name ?? ''} {summary} {model.yTicks.map((tk) => ( ))} {model.bars.map((b) => ( setHover(b.i)} onPointerLeave={() => setHover(null)} onPointerDown={() => setHover(b.i)} /> ))} {model.placed.map((mk) => ( model.innerW * 0.8 ? 'end' : mk.px < model.innerW * 0.2 ? 'start' : 'middle'} className={mk.tone === 'accent' ? 'label label-strong' : 'label'} style={{ fill: mk.tone === 'accent' ? CHART.accent : undefined, paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}> {mk.label} · {formatValue(mk.value, spec)} ))} {model.xTicks.map((tk) => ( {formatTick(tk, spec)} ))} {model.yTicks.map((tk) => ( {tk} ))} {unitLabel ? ( {unitLabel} ) : null} )} {hb ? (
{formatValue(hb.lo, spec)} – {formatValue(hb.hi, spec)}
) : null}
{MARK.line}
); }