spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import Link from 'next/link';3import { useMemo, useState } from 'react';4import { t } from '@/i18n';5import { cn } from '@/lib/cn';6import { fixed, formatValue, grouped } from '@/lib/format';7import { routes } from '@/lib/site';8import { useUrlState } from '@/lib/url-state';9import type { FormatSpec } from '@/lib/types';10import type { PeerPoint, PeersResponse } from '@/lib/types-analytics';11import { BubbleChart } from '@/components/charts/bubble-chart';12import { Segmented } from '@/components/controls/indicator-select';1314/**15 * Above / below expected: pair selector (URL-synced), bubble chart with the robust fitted line, the ten largest16 * positive and negative residuals. Wording stays descriptive: "above / below the fitted line".17 */18export function PeersView({ data }: { data: PeersResponse }) {19 const { set } = useUrlState();20 const [selected, setSelected] = useState<string | null>(null);21 const xs: FormatSpec = { format: data.x.format, unit: data.x.unit, unit_short: data.x.unit_short, precision: data.x.precision, name: data.x.short_name ?? data.x.name };22 const ys: FormatSpec = { format: data.y.format, unit: data.y.unit, unit_short: data.y.unit_short, precision: data.y.precision, name: data.y.short_name ?? data.y.name };23 const points = useMemo(() => data.points.filter((p) => p.x != null && p.y != null).map((p) => ({ id: p.id, label: p.name ?? p.id, flag: p.flag, x: p.x, y: p.y, region: p.region })), [data.points]);24 const highlight = useMemo(() => [...data.above.slice(0, 5), ...data.below.slice(0, 5)].map((p) => p.id).concat(selected ? [selected] : []), [data.above, data.below, selected]);25 const pairKey = `${data.x.slug}|${data.y.slug}`;26 const pairs = data.pairs.map((p) => ({ value: `${p.x}|${p.y}`, label: p.label }));27 const current = pairs.find((p) => p.value === pairKey) ? pairKey : pairs[0]?.value ?? pairKey;2829 return (30 <div className="min-w-0">31 <div className="flex flex-wrap items-center gap-2">32 <label className="inline-flex h-11 max-w-full items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-10">33 <span className="text-2xs uppercase tracking-wide text-ink-3">{t('peers.pair')}</span>34 <select35 value={current}36 onChange={(e) => {37 const [x, y] = e.target.value.split('|');38 set({ x, y }, 0);39 }}40 className="min-w-0 max-w-[18rem] flex-1 truncate bg-transparent text-ink outline-none"41 aria-label={t('peers.pair')}42 >43 {pairs.map((p) => (44 <option key={p.value} value={p.value}>45 {p.label}46 </option>47 ))}48 </select>49 </label>50 <Segmented value={data.method === 'ols' ? 'ols' : 'theil-sen'} onChange={(v) => set({ method: v }, 0)} label={t('peers.method')} size="sm" options={[{ value: 'theil-sen', label: t('peers.method.theilSen') }, { value: 'ols', label: t('peers.method.ols') }]} />51 <span className="tnum text-xs text-ink-3">52 {t('peers.stats', { n: grouped(data.n), year: data.year_used ?? '', r2: data.fit?.r2 != null ? fixed(data.fit.r2, 2) : t('common.na') })}53 </span>54 </div>5556 <div className="mt-4">57 <BubbleChart points={points} xSpec={xs} ySpec={ys} logX={data.fit?.log_x ?? false} fit={data.fit ? { slope: data.fit.slope, intercept: data.fit.intercept, logX: data.fit.log_x, logY: false, label: t('peers.fitLine') } : null} highlight={highlight} labelCount={0} height={460} animate={false} onSelect={setSelected} defaultWidth={960} />58 </div>59 <p className="mt-2 text-xs text-ink-3">{data.note}</p>6061 <div className="mt-6 grid gap-x-10 gap-y-6 lg:grid-cols-2">62 <ResidualList title={t('peers.above')} rows={data.above} spec={ys} tone="inc" onPick={setSelected} selected={selected} />63 <ResidualList title={t('peers.below')} rows={data.below} spec={ys} tone="dec" onPick={setSelected} selected={selected} />64 </div>6566 <div className="mt-8 max-w-prose border-t border-rule pt-4 text-sm leading-relaxed text-ink-2">67 <h3 className="mb-1 text-sm font-semibold text-ink">{t('peers.methodology')}</h3>68 <p>{data.methodology}</p>69 <p className="mt-2 font-medium text-ink">{t('peers.disclaimer')}</p>70 </div>71 </div>72 );73}7475function ResidualList({ title, rows, spec, tone, onPick, selected }: { title: string; rows: PeerPoint[]; spec: FormatSpec; tone: 'inc' | 'dec'; onPick: (id: string) => void; selected: string | null }) {76 const max = Math.max(1, ...rows.map((r) => Math.abs(r.residual_z ?? 0)));77 return (78 <div className="min-w-0">79 <h3 className={cn('mb-2 text-sm font-semibold', tone === 'inc' ? 'text-inc' : 'text-dec')}>{title}</h3>80 <ol className="divide-y divide-rule border-y border-rule">81 {rows.map((r, i) => (82 <li key={r.id} className={cn('grid grid-cols-[1.5rem_minmax(0,1fr)_5rem_4rem] items-center gap-x-2 py-1.5 text-sm', selected === r.id && 'bg-accent-soft/40')}>83 <span className="tnum text-xs text-ink-3">{i + 1}</span>84 <button type="button" onClick={() => onPick(r.id)} className="flex min-h-[36px] min-w-0 items-center gap-1.5 text-left">85 <span aria-hidden>{r.flag}</span>86 <span className="min-w-0">87 <Link href={routes.country(r.slug ?? r.id)} className="link-quiet block truncate font-medium text-ink">88 {r.name}89 </Link>90 <span className="tnum block text-2xs text-ink-3">91 {t('peers.actualVsExpected', { actual: formatValue(r.y, spec), expected: formatValue(r.expected, spec) })}92 </span>93 </span>94 </button>95 <span className="relative h-2 overflow-hidden rounded-xs bg-surface-2" aria-hidden>96 <span className={cn('absolute inset-y-0 left-0 rounded-xs', tone === 'inc' ? 'bg-inc' : 'bg-dec')} style={{ width: `${(Math.abs(r.residual_z ?? 0) / max) * 100}%` }} />97 </span>98 <span className={cn('tnum whitespace-nowrap text-right text-sm font-medium', tone === 'inc' ? 'text-inc' : 'text-dec')}>99 {r.residual != null ? `${r.residual > 0 ? '+' : '−'}${formatValue(Math.abs(r.residual), spec)}` : t('common.na')}100 </span>101 </li>102 ))}103 </ol>104 </div>105 );106}107