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%
2.3 KB · 53 lines tsx
Raw Blame History
1import { line as d3Line } from 'd3-shape';2import { scaleLinear } from 'd3-scale';3import { CHART, MARK } from './palette';4import { extent, periodToX, splitForecast, type SeriesPoint } from './scales';56/**7 * Tiny server-renderable line (no axes) with an end dot. Fixed viewBox so it scales with its box;8 * strokes use `vector-effect: non-scaling-stroke` so lines stay 1.5 px at any size.9 */10export function Sparkline({11  points,12  width = 96,13  height = 28,14  color = CHART.accent,15  className,16  ariaLabel,17  direction,18}: {19  points: SeriesPoint[];20  width?: number;21  height?: number;22  color?: string;23  className?: string;24  ariaLabel?: string;25  /** Colour the end dot by direction (up/down) — the line stays neutral. */26  direction?: 'up' | 'down' | 'flat' | null;27}) {28  const clean = points.filter((p) => p.value != null && Number.isFinite(p.value));29  if (clean.length < 2) return <span className={className} style={{ display: 'inline-block', width, height }} aria-hidden />;30  const xs = clean.map(periodToX);31  const xDom = extent(xs)!;32  const yDom = extent(clean.map((p) => p.value))!;33  const pad = 3;34  const x = scaleLinear().domain(xDom).range([pad, width - pad]);35  const y = scaleLinear()36    .domain(yDom[0] === yDom[1] ? [yDom[0] - 1, yDom[1] + 1] : yDom)37    .range([height - pad, pad]);38  const gen = d3Line<SeriesPoint>()39    .x((p) => x(periodToX(p)))40    .y((p) => y(p.value!));41  const runs = splitForecast(clean);42  const last = clean.filter((p) => !p.is_forecast).at(-1) ?? clean.at(-1)!;43  const dotColor = direction === 'up' ? CHART.up : direction === 'down' ? CHART.down : color;44  return (45    <svg className={className} width={width} height={height} viewBox={`0 0 ${width} ${height}`} role={ariaLabel ? 'img' : undefined} aria-label={ariaLabel} aria-hidden={ariaLabel ? undefined : true} preserveAspectRatio="none">46      {runs.map((r, i) => (47        <path key={i} d={gen(r.points) ?? ''} fill="none" stroke={r.forecast ? CHART.forecast : color} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" strokeDasharray={r.forecast ? '3 3' : undefined} vectorEffect="non-scaling-stroke" opacity={r.forecast ? 0.8 : 0.9} />48      ))}49      <circle cx={x(periodToX(last))} cy={y(last.value!)} r={MARK.dotR - 1} fill={dotColor} stroke={CHART.surface} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />50    </svg>51  );52}53