'use client'; import { useRouter } from 'next/navigation'; import { useCallback, useId, useState, type ReactNode } from 'react'; import { t } from '@/i18n'; import { cn } from '@/lib/cn'; import { formatValue } from '@/lib/format'; import { MAP_HEIGHT, MAP_WIDTH } from '@/lib/map-geo'; import { routes } from '@/lib/site'; import type { FormatSpec as Spec } from '@/lib/types'; import { seqVar } from './palette'; export interface ChoroplethFeature { iso3: string | null; name: string; slug: string | null; flag: string | null; d: string; value: number | null; cls: number | null; } /** Map a class index (0..k-1) onto the 7-step sequential ramp. */ export function stepFor(cls: number, k: number): number { if (k <= 1) return 4; const start = k >= 6 ? 1 : 2; const end = 7; return Math.round(start + (cls / (k - 1)) * (end - start)); } /** Legend items (class → label) from API quantile breaks; shared by every choropleth wrapper. */ export function legendFromBreaks(breaks: number[], min: number | null, max: number | null, spec: Spec): Array<{ cls: number; label: string }> { const k = breaks.length + 1; return Array.from({ length: k }, (_, i) => { const lo = i === 0 ? min : breaks[i - 1]!; const hi = i === k - 1 ? max : breaks[i]!; return { cls: i, label: `${formatValue(lo, spec)} – ${formatValue(hi, spec)}` }; }); } /** Class index 0..k for a value against sorted quantile breaks. */ export function classFor(value: number, breaks: number[]): number { let i = 0; while (i < breaks.length && value >= breaks[i]!) i++; return i; } /** * Interactive SVG world map: hover (mouse) shows the floating label; on touch the first tap selects and * shows the label with an "Open" link, a click with a mouse navigates (or calls `onSelect` when given). * Explicit hatched fill for no data. `selectedId` outlines one country; `renderLabel` customises the tooltip. */ export function ChoroplethView({ features, sphere, legend, k, spec, summary, title, height, compact, className, onSelect, selectedId, renderLabel, showLegend = true, showHint = true, fillFor, }: { features: ChoroplethFeature[]; sphere: string; legend: Array<{ cls: number; label: string }>; k: number; spec: Spec; summary: string; title: string; height?: number; compact?: boolean; className?: string; /** When given, clicking / "Open" calls this instead of navigating to the country page. */ onSelect?: (f: ChoroplethFeature) => void; selectedId?: string | null; renderLabel?: (f: ChoroplethFeature) => ReactNode; showLegend?: boolean; showHint?: boolean; /** Override the class → colour mapping (e.g. a diverging ramp). */ fillFor?: (f: ChoroplethFeature) => string; }) { const router = useRouter(); const id = useId(); const [active, setActive] = useState<{ f: ChoroplethFeature; x: number; y: number; sticky: boolean } | null>(null); const place = useCallback((e: React.PointerEvent, f: ChoroplethFeature, sticky: boolean) => { const box = e.currentTarget.ownerSVGElement?.parentElement?.getBoundingClientRect(); if (!box) return; setActive({ f, x: e.clientX - box.left, y: e.clientY - box.top, sticky }); }, []); const open = (f: ChoroplethFeature) => { if (onSelect) { onSelect(f); setActive(null); return; } if (f.slug) router.push(routes.country(f.slug)); }; const fill = (f: ChoroplethFeature) => (fillFor ? fillFor(f) : f.cls != null ? seqVar(stepFor(f.cls, k)) : `url(#${id}-hatch)`); const selected = selectedId ? features.find((f) => f.iso3 === selectedId) ?? null : null; return (
setActive((a) => (a?.sticky ? a : null))}> {title} {summary} {features.map((f, i) => { const isActive = active?.f === f; const interactive = !!f.slug || (!!onSelect && !!f.iso3); return ( { if (e.pointerType === 'mouse') place(e, f, false); }} onPointerDown={(e) => { if (e.pointerType !== 'mouse') { e.preventDefault(); place(e, f, true); } }} onClick={(e) => { // Mouse: open. Touch: the label carries the action (first tap selects). if ((e.nativeEvent as PointerEvent).pointerType === 'mouse' || (e as unknown as { detail: number }).detail === 0) open(f); }} onKeyDown={(e) => { if (e.key === 'Enter' && interactive) open(f); }} onFocus={(e) => { const b = e.currentTarget.getBBox(); setActive({ f, x: ((b.x + b.width / 2) / MAP_WIDTH) * (e.currentTarget.ownerSVGElement?.clientWidth ?? MAP_WIDTH), y: (b.y / MAP_HEIGHT) * (e.currentTarget.ownerSVGElement?.clientHeight ?? MAP_HEIGHT), sticky: false }); }} > {`${f.name}: ${formatValue(f.value, spec)}`} ); })} {selected ? : null} {active ? (
{active.f.flag ? {active.f.flag} : null} {active.f.name}
{renderLabel ? renderLabel(active.f) :
{formatValue(active.f.value, spec)}
} {active.sticky && (active.f.slug || onSelect) ? ( ) : null}
) : null}
{showLegend ? (
{!compact ? {title} : null}
    {legend.map((l) => (
  • {l.label}
  • ))}
  • {t('chart.legend.noData')}
{showHint ? ( <> {t('chart.map.hoverHint')} {t('chart.map.tapHint')} ) : null}
) : null}
); }