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