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%
13.6 KB · 314 lines tsx
Raw Blame History
1'use client';2import { area as d3Area, line as d3Line, stack as d3Stack } from 'd3-shape';3import { useCallback, useMemo, useState, type PointerEvent } from 'react';4import { t } from '@/i18n';5import { formatPeriod, formatTick, formatValue } from '@/lib/format';6import type { FormatSpec as Spec, Provenance } from '@/lib/types';7import type { ProvenancePayload } from '@/components/data/provenance-context';8import { ChartFrame, Legend, type TableData } from './chart-frame';9import { CHART, MARK, seriesVar } from './palette';10import { DEFAULT_MARGIN, extent, lineDomain, periodToX, splitForecast, xYearScale, yScale, yearTicks, type Margin, type SeriesPoint } from './scales';11import { summarizeMulti, summarizeSeries } from './summary';12import { ChartTooltip, TooltipRow } from './tooltip';13import { useMeasure } from './use-measure';1415export interface LineSeries {16  id: string;17  name: string;18  points: SeriesPoint[];19  /** Fixed colour slot (0-based). Defaults to the array index — colour follows the entity, never its rank. */20  colorIndex?: number;21  color?: string;22}2324export interface LineChartProps {25  series: LineSeries[];26  spec: Spec;27  /** Subject for the accessible summary ("Canada's GDP per capita"). */28  subject?: string;29  variant?: 'line' | 'area' | 'stacked';30  log?: boolean;31  height?: number;32  margin?: Partial<Margin>;33  title?: React.ReactNode;34  subtitle?: React.ReactNode;35  provenance?: Provenance | null;36  payload?: ProvenancePayload | null;37  actions?: React.ReactNode;38  /** Direct end-labels for ≤ 4 series. */39  endLabels?: boolean;40  /** Draw a highlighted marker on the latest actual point. */41  endDot?: boolean;42  className?: string;43  defaultWidth?: number;44}4546interface XY {47  x: number;48  y: number;49  p: SeriesPoint;50}5152/**53 * Multi-series line / area / stacked-area chart. Dashed segments for `is_forecast`, optional log scale,54 * crosshair + one tooltip listing every series at the nearest X (pointer events → touch friendly).55 * Index=100 transformations are data-side (pass already-indexed points).56 */57export function LineChart({58  series,59  spec,60  subject,61  variant = 'line',62  log = false,63  height = 240,64  margin: marginIn,65  title,66  subtitle,67  provenance,68  payload,69  actions,70  endLabels = true,71  endDot = true,72  className,73  defaultWidth = 640,74}: LineChartProps) {75  const { ref, width } = useMeasure<HTMLDivElement>(defaultWidth);76  const margin: Margin = { ...DEFAULT_MARGIN, ...marginIn };77  if (endLabels && series.length > 1 && series.length <= 4) margin.right = Math.max(margin.right, 72);78  // Left margin fits the longest y tick label (≈ 6.4 px per character at 11 px) so labels are never clipped.79  // Uses the same domain rule as the chart itself (lineDomain) so the probe ticks are the real ticks.80  if (marginIn?.left == null) {81    const probe = extent(series.flatMap((s) => s.points.map((p) => p.value))) ?? [0, 1];82    const dom = variant === 'stacked' ? ([0, probe[1]] as [number, number]) : log && probe[0] > 0 ? probe : lineDomain(probe, spec);83    const longest = Math.max(...yScale(dom, [0, 1], { log, includeZero: variant === 'stacked' }).ticks(4).map((tk) => formatTick(tk, spec).length), 3);84    margin.left = Math.min(84, Math.max(36, Math.round(longest * 6.4) + 12));85  }86  const [hover, setHover] = useState<number | null>(null); // x-value (fractional year)8788  const model = useMemo(() => {89    const clean = series.map((s) => ({ ...s, points: s.points.filter((p) => p.value != null && Number.isFinite(p.value)) }));90    const allX = clean.flatMap((s) => s.points.map(periodToX));91    const xDom = extent(allX) ?? [2000, 2024];92    const innerW = Math.max(10, width - margin.left - margin.right);93    const innerH = Math.max(10, height - margin.top - margin.bottom);94    const x = xYearScale(xDom, [0, innerW]);9596    let stacks: Array<Array<[number, number, number]>> | null = null; // per series: [x, y0, y1]97    let yDom: [number, number];98    if (variant === 'stacked') {99      // Build a wide table keyed by x.100      const keys = clean.map((s) => s.id);101      const xs = Array.from(new Set(allX)).sort((a, b) => a - b);102      const table = xs.map((xv) => {103        const row: Record<string, number> = { __x: xv };104        for (const s of clean) {105          const p = s.points.find((q) => periodToX(q) === xv);106          row[s.id] = p?.value ?? 0;107        }108        return row;109      });110      const st = d3Stack<Record<string, number>>().keys(keys)(table);111      stacks = st.map((layer) => layer.map((d) => [d.data.__x!, d[0], d[1]] as [number, number, number]));112      const top = Math.max(0, ...stacks.flat().map((d) => d[2]));113      yDom = [0, top];114    } else {115      // Line / area: padded nice extent; zero only when the rule says so (see lineDomain). Log keeps the raw extent.116      const raw = extent(clean.flatMap((s) => s.points.map((p) => p.value))) ?? [0, 1];117      yDom = log && raw[0] > 0 ? raw : lineDomain(raw, spec);118    }119    const y = yScale(yDom, [innerH, 0], { log, includeZero: variant === 'stacked' });120    const xTicks = yearTicks(xDom, Math.max(2, Math.floor(innerW / 90)));121    const yTicks = y.ticks(4);122    const pathFor = d3Line<XY>()123      .x((d) => d.x)124      .y((d) => d.y);125    const areaFor = d3Area<XY>()126      .x((d) => d.x)127      .y0(() => y(Math.max(0, y.domain()[0]!)))128      .y1((d) => d.y);129130    const layers = clean.map((s, i) => {131      const color = s.color ?? seriesVar(s.colorIndex ?? i);132      const runs = splitForecast(s.points).map((r) => ({133        forecast: r.forecast,134        xy: r.points.map((p) => ({ x: x(periodToX(p)), y: y(p.value!), p })),135      }));136      const all = s.points.map((p) => ({ x: x(periodToX(p)), y: y(p.value!), p }));137      const actual = s.points.filter((p) => !p.is_forecast);138      const last = actual[actual.length - 1] ?? null;139      return { s, color, runs, all, last: last ? { x: x(periodToX(last)), y: y(last.value!), p: last } : null };140    });141142    const stackPaths =143      stacks &&144      stacks.map((layer, i) => {145        const a = d3Area<[number, number, number]>()146          .x((d) => x(d[0]))147          .y0((d) => y(d[1]))148          .y1((d) => y(d[2]));149        return { d: a(layer) ?? '', color: clean[i]!.color ?? seriesVar(clean[i]!.colorIndex ?? i) };150      });151152    return { clean, x, y, innerW, innerH, xTicks, yTicks, pathFor, areaFor, layers, stackPaths, xDom };153    // eslint-disable-next-line react-hooks/exhaustive-deps -- only spec.format drives the domain rule154  }, [series, width, height, margin.left, margin.right, margin.top, margin.bottom, variant, log, spec.format]);155156  const onMove = useCallback(157    (e: PointerEvent<SVGRectElement>) => {158      const rect = e.currentTarget.getBoundingClientRect();159      const px = e.clientX - rect.left;160      const xv = model.x.invert(px);161      // snap to nearest existing x162      let best: number | null = null;163      let bestD = Infinity;164      for (const s of model.clean)165        for (const p of s.points) {166          const d = Math.abs(periodToX(p) - xv);167          if (d < bestD) {168            bestD = d;169            best = periodToX(p);170          }171        }172      setHover(best);173    },174    [model],175  );176177  const hoverRows = useMemo(() => {178    if (hover == null) return null;179    return model.layers180      .map((l) => {181        const p = l.s.points.find((q) => periodToX(q) === hover);182        return p ? { name: l.s.name, color: l.color, p } : null;183      })184      .filter((r): r is { name: string; color: string; p: SeriesPoint } => !!r);185  }, [hover, model]);186187  const summary =188    series.length === 1 ? summarizeSeries(subject ?? series[0]!.name, series[0]!.points, spec) : summarizeMulti(series.map((s) => s.name), series.map((s) => s.points));189190  const table: TableData = useMemo(() => {191    const xs = Array.from(new Set(model.clean.flatMap((s) => s.points.map((p) => p.period)))).sort();192    return {193      columns: [{ key: 'period', label: t('common.period') }, ...model.clean.map((s) => ({ key: s.id, label: s.name, numeric: true }))],194      rows: xs.map((period) => {195        const row: Record<string, string> = { period: formatPeriod(period, spec.frequency ?? 'A') };196        for (const s of model.clean) {197          const p = s.points.find((q) => q.period === period);198          row[s.id] = p ? `${formatValue(p.value, spec)}${p.is_forecast ? ' *' : ''}` : t('common.na');199        }200        return row;201      }),202    };203  }, [model, spec]);204205  const hasForecast = series.some((s) => s.points.some((p) => p.is_forecast));206  const hoverX = hover != null ? model.x(hover) : null;207  const legendItems = model.layers.map((l) => ({ label: l.s.name, color: l.color, shape: variant === 'line' ? ('line' as const) : ('rect' as const) }));208  const empty = model.clean.every((s) => s.points.length === 0);209210  return (211    <ChartFrame212      title={title}213      subtitle={subtitle}214      summary={summary}215      provenance={provenance}216      payload={payload}217      table={empty ? undefined : table}218      actions={actions}219      legend={<Legend items={legendItems} />}220      note={hasForecast ? t('chart.forecastNote') : undefined}221      className={className}222      minHeight={height}223    >224      <div ref={ref} className="relative w-full" style={{ height }}>225        {empty ? (226          <div className="grid h-full place-items-center text-sm text-ink-3">{t('chart.noData')}</div>227        ) : (228          <svg className="ca-chart" width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={summary}>229            <title>{typeof title === 'string' ? title : (spec.name ?? subject ?? '')}</title>230            <desc>{summary}</desc>231            <g transform={`translate(${margin.left},${margin.top})`}>232              <g className="grid">233                {model.yTicks.map((tk) => (234                  <line key={tk} x1={0} x2={model.innerW} y1={model.y(tk)} y2={model.y(tk)} />235                ))}236              </g>237              <g className="axis">238                <line className="baseline" x1={0} x2={model.innerW} y1={model.innerH} y2={model.innerH} />239                {model.xTicks.map((yr) => (240                  <text key={yr} x={model.x(yr)} y={model.innerH + 16} textAnchor="middle">241                    {yr}242                  </text>243                ))}244                {model.yTicks.map((tk) => (245                  <text key={tk} x={-6} y={model.y(tk)} dy="0.32em" textAnchor="end">246                    {formatTick(tk, spec)}247                  </text>248                ))}249              </g>250251              {variant === 'stacked' && model.stackPaths252                ? model.stackPaths.map((sp, i) => <path key={i} d={sp.d} fill={sp.color} fillOpacity={0.85} stroke={CHART.surface} strokeWidth={MARK.gap} />)253                : null}254255              {variant !== 'stacked'256                ? model.layers.map((l) => (257                    <g key={l.s.id}>258                      {variant === 'area' ? <path d={model.areaFor(l.all) ?? ''} fill={l.color} fillOpacity={MARK.areaOpacity} /> : null}259                      {l.runs.map((r, i) => (260                        <path key={i} className={`series-line${r.forecast ? ' forecast' : ''}`} d={model.pathFor(r.xy) ?? ''} stroke={l.color} />261                      ))}262                      {endDot && l.last ? (263                        <g>264                          <circle className="ring" cx={l.last.x} cy={l.last.y} r={MARK.dotR + MARK.ringW / 2} fill={l.color} />265                        </g>266                      ) : null}267                      {endLabels && series.length > 1 && series.length <= 4 && l.last ? (268                        <text className="label" x={l.last.x + 8} y={l.last.y} dy="0.32em">269                          {l.s.name}270                        </text>271                      ) : null}272                    </g>273                  ))274                : null}275276              {series.length === 1 && model.layers[0]?.last ? (277                <text className="label label-strong" x={Math.min(model.layers[0].last.x, model.innerW)} y={model.layers[0].last.y - 10} textAnchor={model.layers[0].last.x > model.innerW * 0.8 ? 'end' : 'middle'} style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}>278                  {formatValue(model.layers[0].last.p.value, spec)}279                </text>280              ) : null}281282              {hoverX != null ? <line x1={hoverX} x2={hoverX} y1={0} y2={model.innerH} stroke={CHART.ruleStrong} strokeWidth={1} /> : null}283              {hoverRows?.map((r) => {284                const l = model.layers.find((ly) => ly.s.name === r.name)!;285                return <circle key={r.name} className="ring" cx={model.x(periodToX(r.p))} cy={model.y(r.p.value!)} r={MARK.dotR} fill={l.color} />;286              })}287288              <rect x={0} y={0} width={model.innerW} height={model.innerH} fill="transparent" style={{ touchAction: 'pan-y' }} onPointerMove={onMove} onPointerDown={onMove} onPointerLeave={() => setHover(null)} />289            </g>290          </svg>291        )}292        {hover != null && hoverRows && hoverRows.length > 0 && hoverX != null ? (293          <ChartTooltip x={margin.left + hoverX} y={margin.top} width={width}>294            <div className="mb-0.5 text-2xs text-ink-3">295              {formatPeriod(hoverRows[0]!.p.period, spec.frequency ?? 'A')}296              {hoverRows.some((r) => r.p.is_forecast) ? ` · ${t('chart.tooltipForecast')}` : ''}297            </div>298            {hoverRows.map((r) => (299              <TooltipRow key={r.name} color={series.length > 1 ? r.color : undefined} label={series.length > 1 ? r.name : (spec.name ?? '')} value={formatValue(r.p.value, spec)} />300            ))}301          </ChartTooltip>302        ) : null}303      </div>304    </ChartFrame>305  );306}307308export function AreaChart(props: Omit<LineChartProps, 'variant'>) {309  return <LineChart {...props} variant="area" />;310}311export function StackedArea(props: Omit<LineChartProps, 'variant' | 'log'>) {312  return <LineChart {...props} variant="stacked" />;313}314