'use client'; import { ArrowDown, ArrowUp, Download, Plus, Scale, X } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; import { t } from '@/i18n'; import { clientAnalytics } from '@/lib/client-api-analytics'; import { cn } from '@/lib/cn'; import { FINDER_OPS, finderStateQuery, type FinderFilterInput, type FinderOp, type FinderState } from '@/lib/finder-query'; import { formatValue, grouped } from '@/lib/format'; import { INCOME_GROUPS, WB_REGIONS } from '@/lib/regions'; import { routes } from '@/lib/site'; import type { FormatSpec } from '@/lib/types'; import type { FinderResponse } from '@/lib/types-analytics'; import { IndicatorSelect, Segmented, type IndicatorOption } from '@/components/controls/indicator-select'; import { useProvenance } from '@/components/data/provenance-context'; import type { BaseFeature } from '@/components/indicators/indicator-map'; import { MAP_HEIGHT, MAP_WIDTH } from '@/lib/map-geo'; interface Preset { id: string; filters: FinderFilterInput[]; } const PRESETS: Preset[] = [ { id: 'rich-large', filters: [{ slug: 'gdp-per-capita', op: 'gt', value: 40000 }, { slug: 'population', op: 'gt', value: 10_000_000 }] }, { id: 'green-connected', filters: [{ slug: 'renewable-electricity-share', op: 'gt', value: 50 }, { slug: 'internet-users', op: 'gt', value: 80 }] }, { id: 'ageing', filters: [{ slug: 'median-age', op: 'gt', value: 42 }] }, { id: 'young-growing', filters: [{ slug: 'median-age', op: 'lt', value: 25 }, { slug: 'population-growth', op: 'gt', value: 2 }] }, { id: 'long-lives-low-co2', filters: [{ slug: 'life-expectancy', op: 'gt', value: 80 }, { slug: 'co2-per-capita', op: 'lt', value: 5 }] }, ]; function stepFor(spec: FormatSpec, value: number): number { if (spec.format === 'percent' || spec.format === 'years' || spec.format === 'ratio' || spec.format === 'per_1000') return 1; const mag = Math.pow(10, Math.max(0, Math.floor(Math.log10(Math.max(1, Math.abs(value)))) - 1)); return mag; } /** Query builder over /finder: filters live in the URL exactly as the API reads them. */ export function FinderView({ indicators, features, sphere, initial, initialState, specs }: { indicators: IndicatorOption[]; features: BaseFeature[]; sphere: string; initial: FinderResponse | null; initialState: FinderState; specs: Record }) { const router = useRouter(); const { open: openProv } = useProvenance(); const [state, setState] = useState(initialState); const [data, setData] = useState(initial); const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle'); const abort = useRef(null); const first = useRef(true); // Debounced fetch + URL sync whenever the state changes. useEffect(() => { if (first.current) { first.current = false; return; } const q = finderStateQuery(state); const timer = setTimeout(() => { router.replace(`${routes.finder()}${q}`, { scroll: false }); if (!state.filters.length) { setData(null); setStatus('idle'); return; } abort.current?.abort(); const ctrl = new AbortController(); abort.current = ctrl; setStatus('loading'); clientAnalytics .finder(state.filters, { mode: state.mode, region: state.region, income: state.income, sort: state.sort, limit: 218 }, ctrl.signal) .then((r) => { setData(r); setStatus('idle'); }) .catch((e) => { if ((e as Error).name !== 'AbortError') setStatus('error'); }); }, 350); return () => clearTimeout(timer); }, [state, router]); const update = (i: number, patch: Partial) => setState((s) => ({ ...s, filters: s.filters.map((f, k) => (k === i ? { ...f, ...patch } : f)) })); const remove = (i: number) => setState((s) => ({ ...s, filters: s.filters.filter((_, k) => k !== i) })); const add = () => { const used = new Set(state.filters.map((f) => f.slug)); const next = ['gdp-per-capita', 'population', 'life-expectancy', 'median-age', 'internet-users', 'co2-per-capita'].find((s) => !used.has(s) && indicators.some((i) => i.slug === s)) ?? indicators[0]?.slug; if (!next) return; setState((s) => ({ ...s, filters: [...s.filters, { slug: next, op: 'gt' as FinderOp, value: 0 }].slice(0, 8) })); }; const specFor = (slug: string): FormatSpec => specs[slug] ?? { format: 'number', name: indicators.find((i) => i.slug === slug)?.name ?? slug }; const matches = data?.items ?? []; const matchSet = useMemo(() => new Set(matches.map((m) => m.country.id)), [matches]); const sortSlug = state.sort?.split(':')[0] ?? state.filters[0]?.slug ?? null; const sortDir = (state.sort?.split(':')[1] as 'asc' | 'desc' | undefined) ?? 'desc'; const toggleSort = (slug: string) => setState((s) => ({ ...s, sort: sortSlug === slug && sortDir === 'desc' ? `${slug}:asc` : `${slug}:desc` })); const downloadHref = matches.length && state.filters.length ? routes.compareDownload(matches.slice(0, 20).map((m) => m.country.id), Array.from(new Set(state.filters.map((f) => f.slug))).slice(0, 20)) : null; const compareHref = matches.length >= 2 ? routes.compare(...matches.slice(0, 8).map((m) => m.country.slug ?? m.country.id.toLowerCase())) : null; return (

{t('finder.title')}

{t('finder.lede')}

{/* Presets */}
{t('finder.presets')}
    {PRESETS.map((p) => (
  • ))}
{/* Builder */}
    {state.filters.map((f, i) => { const spec = specFor(f.slug); return (
  1. update(i, { slug })} size="sm" label={i === 0 ? t('finder.indicator') : undefined} />
    {f.op === 'between' ? ( <> {t('finder.value2')} ) : null}
  2. ); })}
setState((s) => ({ ...s, mode: m }))} options={[{ value: 'and' as const, label: t('finder.mode.and') }, { value: 'or' as const, label: t('finder.mode.or') }]} label={t('finder.mode')} size="sm" />
{/* Results */}

{status === 'loading' ? t('finder.loading') : status === 'error' ? t('finder.error') : data ? t('finder.results', { n: grouped(data.n_matching), m: grouped(data.n_evaluated) }) : ''}

{downloadHref ? ( {t('finder.download')} ) : null} {compareHref ? ( {t('finder.compare', { n: Math.min(8, matches.length) })} ) : null}
{!state.filters.length ?

{t('finder.empty')}

: null} {state.filters.length && data && data.n_matching === 0 && status === 'idle' ?

{t('finder.none')}

: null} {data && matches.length ? (
{data.filters.map((f) => ( ))} {matches.map((m) => ( {data.filters.map((f) => { const v = m.values[f.indicator.slug]; const spec = specFor(f.indicator.slug); const matched = m.matched.includes(f.indicator.slug); return ( ); })} ))}
{t('finder.results', { n: data.n_matching, m: data.n_evaluated })}
{t('finder.table.country')}
{m.country.flag} {m.country.name} {v && v.value != null ? ( ) : ( {t('common.noData')} )}

{t('finder.latestNote')}

{t('finder.map')} {features.map((f, i) => { const on = f.iso3 ? matchSet.has(f.iso3) : false; return ; })}
{t('finder.map')}
) : null}
); }