spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { useMemo, useState } from 'react';3import { t } from '@/i18n';4import { formatTick, formatValue } from '@/lib/format';5import type { FormatSpec as Spec } from '@/lib/types';6import { ChartFrame, type TableData } from './chart-frame';7import { CHART, MARK } from './palette';8import { DEFAULT_MARGIN, extent } from './scales';9import { ChartTooltip, TooltipRow } from './tooltip';10import { useMeasure } from './use-measure';11import { scaleLinear, scaleLog } from 'd3-scale';1213export interface HistogramMarker {14 id: string;15 label: string;16 value: number;17 /** 'accent' for the selected country, 'ink' for medians. */18 tone?: 'accent' | 'ink' | 'muted';19}2021/**22 * Country distribution histogram: equal-width bins (log-x when `edges` come from a log histogram), one23 * neutral colour, vertical markers (world median, region median, selected country) with labels above the24 * plot, hover tooltip per bin, accessible table. Bins are given by the API (`edges` = n+1 boundaries).25 */26export 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 }) {27 const { ref, width } = useMeasure<HTMLDivElement>(defaultWidth);28 const [hover, setHover] = useState<number | null>(null);29 const m = { ...DEFAULT_MARGIN, top: markers.length ? (markers.length > 2 ? 46 : 34) : 12, bottom: 28, left: 36 };30 const model = useMemo(() => {31 const innerW = Math.max(10, width - m.left - m.right);32 const innerH = Math.max(10, height - m.top - m.bottom);33 const lo = edges[0] ?? 0;34 const hi = edges[edges.length - 1] ?? 1;35 const x = log && lo > 0 ? scaleLog().domain([lo, hi]).range([0, innerW]) : scaleLinear().domain([lo, hi]).range([0, innerW]);36 const maxC = Math.max(1, ...counts);37 const y = scaleLinear().domain([0, maxC]).range([innerH, 0]).nice(4);38 const bars = counts.map((c, i) => {39 const x0 = x(edges[i]!);40 const x1 = x(edges[i + 1]!);41 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]! };42 });43 const xTicks = (log ? (x as ReturnType<typeof scaleLog>).ticks(4) : (x as ReturnType<typeof scaleLinear>).ticks(Math.max(3, Math.floor(innerW / 90)))).filter((v) => v >= lo && v <= hi);44 // Marker label placement: stagger vertically when two labels would collide.45 const placed = markers46 .map((mk) => ({ ...mk, px: Math.min(innerW, Math.max(0, x(mk.value))) }))47 .sort((a, b) => a.px - b.px)48 .map((mk, i, arr) => ({ ...mk, row: Math.min(2, arr.slice(0, i).filter((o) => mk.px - o.px < 90).length) }));49 return { innerW, innerH, x, y, bars, xTicks, yTicks: y.ticks(3), placed, dom: extent(edges) ?? [lo, hi] };50 // eslint-disable-next-line react-hooks/exhaustive-deps51 }, [edges, counts, log, markers, width, height]);5253 const total = counts.reduce((a, b) => a + b, 0);54 const summary = t('chart.summary.histogram', { n: total, min: formatValue(model.dom[0], spec), max: formatValue(model.dom[1], spec) });55 const table: TableData = useMemo(56 () => ({57 columns: [{ key: 'range', label: spec.name ?? t('common.value') }, { key: 'n', label: t('chart.histogram.countries'), numeric: true }],58 rows: model.bars.map((b) => ({ range: `${formatValue(b.lo, spec)} – ${formatValue(b.hi, spec)}`, n: String(b.c) })),59 }),60 [model.bars, spec],61 );62 const hb = hover != null ? model.bars[hover] : null;6364 return (65 <ChartFrame title={title} subtitle={subtitle} summary={summary} table={total ? table : undefined} className={className} minHeight={height}>66 <div ref={ref} className="relative w-full" style={{ height }}>67 {total === 0 ? (68 <div className="grid h-full place-items-center text-sm text-ink-3">{t('chart.noData')}</div>69 ) : (70 <svg className="ca-chart" width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={summary}>71 <title>{typeof title === 'string' ? title : spec.name ?? ''}</title>72 <desc>{summary}</desc>73 <g transform={`translate(${m.left},${m.top})`}>74 <g className="grid">75 {model.yTicks.map((tk) => (76 <line key={tk} x1={0} x2={model.innerW} y1={model.y(tk)} y2={model.y(tk)} />77 ))}78 </g>79 {model.bars.map((b) => (80 <rect key={b.i} x={b.x} y={b.y} width={b.w} height={b.h} fill="var(--series-1)" fillOpacity={hover === b.i ? 1 : 0.75} rx={1} onPointerEnter={() => setHover(b.i)} onPointerLeave={() => setHover(null)} onPointerDown={() => setHover(b.i)} />81 ))}82 {model.placed.map((mk) => (83 <g key={mk.id} transform={`translate(${mk.px},0)`}>84 <line y1={-4} y2={model.innerH} stroke={mk.tone === 'accent' ? CHART.accent : mk.tone === 'muted' ? CHART.ruleStrong : CHART.ink} strokeWidth={mk.tone === 'accent' ? 2 : 1.25} strokeDasharray={mk.tone === 'muted' ? '3 3' : undefined} />85 <text y={-8 - mk.row * 12} textAnchor={mk.px > 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 }}>86 {mk.label} · {formatValue(mk.value, spec)}87 </text>88 </g>89 ))}90 <g className="axis">91 <line className="baseline" x1={0} x2={model.innerW} y1={model.innerH} y2={model.innerH} />92 {model.xTicks.map((tk) => (93 <text key={tk} x={model.x(tk)} y={model.innerH + 16} textAnchor="middle">94 {formatTick(tk, spec)}95 </text>96 ))}97 {model.yTicks.map((tk) => (98 <text key={tk} x={-6} y={model.y(tk)} dy="0.32em" textAnchor="end">99 {tk}100 </text>101 ))}102 {unitLabel ? (103 <text x={model.innerW} y={model.innerH + 27} textAnchor="end" className="label">104 {unitLabel}105 </text>106 ) : null}107 </g>108 </g>109 </svg>110 )}111 {hb ? (112 <ChartTooltip x={m.left + hb.x + hb.w / 2} y={m.top + hb.y} width={width}>113 <div className="mb-0.5 text-2xs text-ink-3">114 {formatValue(hb.lo, spec)} – {formatValue(hb.hi, spec)}115 </div>116 <TooltipRow label={t('chart.histogram.countries')} value={String(hb.c)} />117 </ChartTooltip>118 ) : null}119 </div>120 <span className="sr-only">{MARK.line}</span>121 </ChartFrame>122 );123}124