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%
6.3 KB · 107 lines tsx
Raw Blame History
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