spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { scaleSqrt } from 'd3-scale';3import { useMemo, useState } from 'react';4import { t } from '@/i18n';5import { formatTick, formatValue } from '@/lib/format';6import type { FormatSpec as Spec } from '@/lib/types';7import { ChartFrame } from './chart-frame';8import { CHART, MARK } from './palette';9import { DEFAULT_MARGIN, extent, yScale } from './scales';10import { ChartTooltip, TooltipRow } from './tooltip';11import { useMeasure } from './use-measure';1213export interface ScatterPoint {14 id: string;15 label: string;16 x: number | null;17 y: number | null;18 size?: number | null;19 href?: string;20 highlight?: boolean;21}2223/**24 * Scatter / bubble. One colour for all points (nominal, no series), highlighted points in the accent;25 * labels only for highlighted points and the extremes (never every point). Nearest-point hover with a26 * 24 px hit radius.27 */28export function Scatter({ points, xSpec, ySpec, logX, logY, height = 300, title, className, labelCount = 4 }: { points: ScatterPoint[]; xSpec: Spec; ySpec: Spec; logX?: boolean; logY?: boolean; height?: number; title?: React.ReactNode; className?: string; labelCount?: number }) {29 const { ref, width } = useMeasure<HTMLDivElement>(560);30 const [hover, setHover] = useState<string | null>(null);31 const m = { ...DEFAULT_MARGIN, left: 52, bottom: 32 };32 const model = useMemo(() => {33 const clean = points.filter((p) => p.x != null && p.y != null) as Array<ScatterPoint & { x: number; y: number }>;34 const innerW = Math.max(10, width - m.left - m.right);35 const innerH = Math.max(10, height - m.top - m.bottom);36 const xd = extent(clean.map((p) => p.x)) ?? [0, 1];37 const yd = extent(clean.map((p) => p.y)) ?? [0, 1];38 const x = yScale(xd, [0, innerW], { log: logX, includeZero: !logX });39 const y = yScale(yd, [innerH, 0], { log: logY, includeZero: !logY });40 const sizes = clean.map((p) => p.size ?? null).filter((v): v is number => v != null);41 const r = sizes.length ? scaleSqrt().domain([0, Math.max(...sizes)]).range([3, 18]) : null;42 const labelled = new Set<string>();43 for (const p of clean) if (p.highlight) labelled.add(p.id);44 const byY = [...clean].sort((a, b) => b.y - a.y);45 const byX = [...clean].sort((a, b) => b.x - a.x);46 for (const p of [byY[0], byY[byY.length - 1], byX[0], byX[byX.length - 1]]) if (p && labelled.size < labelCount + 2) labelled.add(p.id);47 return { clean, x, y, r, innerW, innerH, labelled };48 }, [points, width, height, logX, logY, labelCount, m.left, m.right, m.top, m.bottom]);4950 const summary = t('chart.summary.scatter', { x: xSpec.name ?? 'x', y: ySpec.name ?? 'y', n: model.clean.length });51 const hp = hover ? model.clean.find((p) => p.id === hover) : null;52 const onMove = (e: React.PointerEvent<SVGRectElement>) => {53 const rect = e.currentTarget.getBoundingClientRect();54 const px = e.clientX - rect.left;55 const py = e.clientY - rect.top;56 let best: string | null = null;57 let bd = 24 * 24;58 for (const p of model.clean) {59 const dx = model.x(p.x) - px;60 const dy = model.y(p.y) - py;61 const d = dx * dx + dy * dy;62 if (d < bd) {63 bd = d;64 best = p.id;65 }66 }67 setHover(best);68 };69 return (70 <ChartFrame title={title} summary={summary} className={className} minHeight={height} table={{ columns: [{ key: 'label', label: '' }, { key: 'x', label: xSpec.name ?? 'x', numeric: true }, { key: 'y', label: ySpec.name ?? 'y', numeric: true }], rows: model.clean.map((p) => ({ label: p.label, x: formatValue(p.x, xSpec), y: formatValue(p.y, ySpec) })) }}>71 <div ref={ref} className="relative" style={{ height }}>72 <svg className="ca-chart" width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={summary}>73 <g transform={`translate(${m.left},${m.top})`}>74 <g className="grid">75 {model.y.ticks(4).map((tk) => (76 <line key={tk} x1={0} x2={model.innerW} y1={model.y(tk)} y2={model.y(tk)} />77 ))}78 </g>79 <g className="axis">80 <line className="baseline" x1={0} x2={model.innerW} y1={model.innerH} y2={model.innerH} />81 {model.x.ticks(5).map((tk) => (82 <text key={tk} x={model.x(tk)} y={model.innerH + 16} textAnchor="middle">83 {formatTick(tk, xSpec)}84 </text>85 ))}86 {model.y.ticks(4).map((tk) => (87 <text key={tk} x={-6} y={model.y(tk)} dy="0.32em" textAnchor="end">88 {formatTick(tk, ySpec)}89 </text>90 ))}91 <text x={model.innerW} y={model.innerH + 28} textAnchor="end" className="label">92 {xSpec.name}93 </text>94 </g>95 {model.clean.map((p) => (96 <circle key={p.id} className="ring" cx={model.x(p.x)} cy={model.y(p.y)} r={model.r ? model.r(p.size ?? 0) : MARK.dotR} fill={p.highlight ? CHART.accent : 'var(--series-1)'} fillOpacity={p.highlight ? 1 : 0.7} />97 ))}98 {model.clean99 .filter((p) => model.labelled.has(p.id))100 .map((p) => (101 <text key={`l-${p.id}`} className={`label${p.highlight ? ' label-strong' : ''}`} x={model.x(p.x) + 7} y={model.y(p.y)} dy="0.32em">102 {p.label}103 </text>104 ))}105 <rect x={0} y={0} width={model.innerW} height={model.innerH} fill="transparent" onPointerMove={onMove} onPointerDown={onMove} onPointerLeave={() => setHover(null)} style={{ touchAction: 'pan-y' }} />106 </g>107 </svg>108 {hp ? (109 <ChartTooltip x={m.left + model.x(hp.x)} y={m.top + model.y(hp.y)} width={width}>110 <div className="mb-0.5 font-medium text-ink">{hp.label}</div>111 <TooltipRow label={xSpec.name ?? 'x'} value={formatValue(hp.x, xSpec)} />112 <TooltipRow label={ySpec.name ?? 'y'} value={formatValue(hp.y, ySpec)} />113 </ChartTooltip>114 ) : null}115 </div>116 </ChartFrame>117 );118}119