SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
6.9 KB · 124 lines tsx
Raw Blame History
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