spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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