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.4 KB · 135 lines tsx
Raw Blame History
1'use client';2import { useEffect, useRef, useState } from 'react';3import Link from 'next/link';4import { t } from '@/i18n';5import { clientApi } from '@/lib/client-api';6import { cn } from '@/lib/cn';7import { displayValue, formatPeriod } from '@/lib/format';8import { routes } from '@/lib/site';9import type { MetricValue, SeriesResponse } from '@/lib/types';10import { LineChart } from '@/components/charts/line-chart';11import { pointsFromSeries, pointsFromSpark } from '@/components/charts/scales';12import { ChangeChip } from '@/components/data/change-chip';13import { EmptyState } from '@/components/data/empty-state';14import { payloadFor, type MetricCountry } from '@/components/data/metric';15import { useProvenance } from '@/components/data/provenance-context';16import { RankBadge } from '@/components/data/rank-badge';1718const CHART_H = 220;1920/**21 * One indicator on a topic page: name, latest value + change + rank, full-history LineChart (dashed forecast),22 * source line, Compare / Ranking / Indicator links. `series` (server-fetched) renders immediately; otherwise23 * the chart mounts on scroll (IntersectionObserver) and fetches `/countries/{id}/series/{slug}` client-side.24 * The chart area reserves its height so nothing shifts.25 */26export function IndicatorRow({ metric, country, regionName, series: initialSeries, eager = false }: { metric: MetricValue; country: MetricCountry; regionName?: string | null; series?: SeriesResponse | null; eager?: boolean }) {27  const { open } = useProvenance();28  const m = metric;29  const ref = useRef<HTMLDivElement>(null);30  const [series, setSeries] = useState<SeriesResponse | null | undefined>(initialSeries);31  const [visible, setVisible] = useState(eager || !!initialSeries);32  const [error, setError] = useState(false);3334  useEffect(() => {35    if (visible || !ref.current) return;36    const el = ref.current;37    if (typeof IntersectionObserver === 'undefined') {38      setVisible(true);39      return;40    }41    const io = new IntersectionObserver(42      (entries) => {43        if (entries.some((e) => e.isIntersecting)) {44          setVisible(true);45          io.disconnect();46        }47      },48      { rootMargin: '400px 0px' },49    );50    io.observe(el);51    return () => io.disconnect();52  }, [visible]);5354  useEffect(() => {55    if (!visible || series !== undefined || !m.has_data) return;56    const ctrl = new AbortController();57    clientApi58      .countrySeries(country.id, m.indicator, ctrl.signal)59      .then((r) => setSeries(r))60      .catch((e) => {61        if ((e as Error).name !== 'AbortError') {62          setError(true);63          setSeries(null);64        }65      });66    return () => ctrl.abort();67  }, [visible, series, m.has_data, m.indicator, country.id]);6869  const name = m.indicator_name ?? m.indicator;70  const points = series ? pointsFromSeries(series.values) : pointsFromSpark(m.sparkline);71  const spec = { format: m.format, unit: m.unit, unit_short: m.unit_short, frequency: m.frequency, name, higher_is_better: m.higher_is_better, precision: series?.indicator.precision ?? null };72  const payload = payloadFor(m, country, { name: series?.indicator.name ?? name });7374  return (75    <article id={m.indicator} ref={ref} className="scroll-mt-32 border-t border-rule py-5 md:py-6" aria-labelledby={`${m.indicator}-h`}>76      <div className="grid gap-x-8 gap-y-3 md:grid-cols-[minmax(0,17rem)_1fr] lg:grid-cols-[minmax(0,19rem)_1fr]">77        <div className="min-w-0">78          <h3 id={`${m.indicator}-h`} className="text-base font-semibold leading-snug text-ink">79            <Link href={routes.indicator(m.indicator)} className="link-quiet -my-2 inline-flex min-h-[44px] items-center py-2">80              {series?.indicator.name ?? name}81            </Link>82          </h3>83          {m.unit ? <p className="text-xs text-ink-3">{m.unit}</p> : null}84          {m.has_data ? (85            <button type="button" onClick={() => open(payload)} className="-mx-1 mt-2 flex min-h-[44px] flex-col items-start rounded-sm px-1 text-left hover:bg-surface-2" aria-label={t('common.openProvenance')}>86              <span className="pnum text-2xl font-semibold leading-none text-ink">{displayValue(m.value, spec, m.formatted)}</span>87              <span className="mt-1 flex flex-wrap items-baseline gap-x-2 text-xs text-ink-3">88                <span className="tnum">{formatPeriod(m.period, m.frequency)}</span>89                {m.is_estimate ? <span>{t('common.estimate')}</span> : null}90                <ChangeChip change={m.change} spec={spec} prevPeriod={m.prev?.period} />91              </span>92            </button>93          ) : null}94          <div className="mt-1 min-h-[1rem]">95            <RankBadge rank={m} regionName={regionName} />96          </div>97          <div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs">98            <Link href={routes.compare(country.slug ?? country.id)} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">99              {t('topic.compareLink')}100            </Link>101            <Link href={routes.ranking(m.indicator)} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">102              {t('topic.rankingLink')}103            </Link>104            <Link href={routes.indicator(m.indicator)} className="inline-flex min-h-[44px] items-center text-ink-2 hover:text-accent hover:underline md:min-h-[32px]">105              {t('topic.indicatorLink')}106            </Link>107          </div>108        </div>109        <div className="min-w-0" style={{ minHeight: CHART_H + 40 }}>110          {!m.has_data ? (111            <EmptyState compact title={t('empty.title', { indicator: name, country: country.name })} />112          ) : error ? (113            <EmptyState compact title={t('common.errorHint')} />114          ) : points.length >= 2 ? (115            <LineChart116              series={[{ id: m.indicator, name: country.name, points }]}117              spec={spec}118              subject={`${country.name}'s ${(series?.indicator.short_name ?? name).toLowerCase()}`}119              height={CHART_H}120              provenance={series?.provenance ?? m.provenance}121              payload={payload}122              defaultWidth={720}123              className={cn(!series && 'opacity-90')}124            />125          ) : (126            <div className="grid h-full place-items-center text-sm text-ink-3" style={{ minHeight: CHART_H }}>127              {t('common.loading')}128            </div>129          )}130        </div>131      </div>132    </article>133  );134}135