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%
3.6 KB · 71 lines tsx
Raw Blame History
1'use client';2import { useMemo } from 'react';3import { t } from '@/i18n';4import { formatValue } from '@/lib/format';5import type { FormatSpec as Spec } from '@/lib/types';6import { ChartFrame } from './chart-frame';7import { CHART, MARK, seriesVar } from './palette';8import { yScale } from './scales';9import { useMeasure } from './use-measure';1011export interface SlopeRow {12  id: string;13  label: string;14  a: number | null;15  b: number | null;16  colorIndex?: number;17  highlight?: boolean;18}1920/** Two-period slope chart; labels on both ends, highlighted row in the accent, others in muted ink. */21export function SlopeChart({ rows, spec, yearA, yearB, height = 260, title, className }: { rows: SlopeRow[]; spec: Spec; yearA: number; yearB: number; height?: number; title?: React.ReactNode; className?: string }) {22  const { ref, width } = useMeasure<HTMLDivElement>(480);23  const model = useMemo(() => {24    const clean = rows.filter((r) => r.a != null && r.b != null) as Array<SlopeRow & { a: number; b: number }>;25    const vals = clean.flatMap((r) => [r.a, r.b]);26    const lo = Math.min(...vals);27    const hi = Math.max(...vals);28    const m = { top: 20, bottom: 24, left: 110, right: 110 };29    const innerH = height - m.top - m.bottom;30    const y = yScale([lo, hi], [innerH, 0], { includeZero: false });31    const x0 = m.left;32    const x1 = width - m.right;33    return { clean, y, m, x0, x1, innerH };34  }, [rows, width, height]);35  const summary = t('chart.summary.slope', { y0: yearA, y1: yearB, n: model.clean.length });36  return (37    <ChartFrame title={title} summary={summary} className={className} minHeight={height} table={{ columns: [{ key: 'label', label: '' }, { key: 'a', label: String(yearA), numeric: true }, { key: 'b', label: String(yearB), numeric: true }], rows: model.clean.map((r) => ({ label: r.label, a: formatValue(r.a, spec), b: formatValue(r.b, spec) })) }}>38      <div ref={ref} style={{ height }}>39        <svg className="ca-chart" width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={summary}>40          <g transform={`translate(0,${model.m.top})`}>41            <line x1={model.x0} x2={model.x0} y1={0} y2={model.innerH} stroke={CHART.rule} />42            <line x1={model.x1} x2={model.x1} y1={0} y2={model.innerH} stroke={CHART.rule} />43            <text x={model.x0} y={model.innerH + 16} textAnchor="middle">44              {yearA}45            </text>46            <text x={model.x1} y={model.innerH + 16} textAnchor="middle">47              {yearB}48            </text>49            {model.clean.map((r, i) => {50              const color = r.highlight ? CHART.accent : rows.length > 8 ? CHART.ruleStrong : seriesVar(r.colorIndex ?? i);51              return (52                <g key={r.id}>53                  <line x1={model.x0} x2={model.x1} y1={model.y(r.a)} y2={model.y(r.b)} stroke={color} strokeWidth={MARK.line} strokeLinecap="round" opacity={r.highlight || rows.length <= 8 ? 1 : 0.6} />54                  <circle className="ring" cx={model.x0} cy={model.y(r.a)} r={MARK.dotR} fill={color} />55                  <circle className="ring" cx={model.x1} cy={model.y(r.b)} r={MARK.dotR} fill={color} />56                  <text className={`label${r.highlight ? ' label-strong' : ''}`} x={model.x0 - 8} y={model.y(r.a)} dy="0.32em" textAnchor="end">57                    {r.label} {formatValue(r.a, spec)}58                  </text>59                  <text className={`label${r.highlight ? ' label-strong' : ''}`} x={model.x1 + 8} y={model.y(r.b)} dy="0.32em">60                    {formatValue(r.b, spec)}61                  </text>62                </g>63              );64            })}65          </g>66        </svg>67      </div>68    </ChartFrame>69  );70}71