spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { useRouter } from 'next/navigation';3import { useCallback, useId, useState, type ReactNode } from 'react';4import { t } from '@/i18n';5import { cn } from '@/lib/cn';6import { formatValue } from '@/lib/format';7import { MAP_HEIGHT, MAP_WIDTH } from '@/lib/map-geo';8import { routes } from '@/lib/site';9import type { FormatSpec as Spec } from '@/lib/types';10import { seqVar } from './palette';1112export interface ChoroplethFeature {13 iso3: string | null;14 name: string;15 slug: string | null;16 flag: string | null;17 d: string;18 value: number | null;19 cls: number | null;20}2122/** Map a class index (0..k-1) onto the 7-step sequential ramp. */23export function stepFor(cls: number, k: number): number {24 if (k <= 1) return 4;25 const start = k >= 6 ? 1 : 2;26 const end = 7;27 return Math.round(start + (cls / (k - 1)) * (end - start));28}2930/** Legend items (class → label) from API quantile breaks; shared by every choropleth wrapper. */31export function legendFromBreaks(breaks: number[], min: number | null, max: number | null, spec: Spec): Array<{ cls: number; label: string }> {32 const k = breaks.length + 1;33 return Array.from({ length: k }, (_, i) => {34 const lo = i === 0 ? min : breaks[i - 1]!;35 const hi = i === k - 1 ? max : breaks[i]!;36 return { cls: i, label: `${formatValue(lo, spec)} – ${formatValue(hi, spec)}` };37 });38}3940/** Class index 0..k for a value against sorted quantile breaks. */41export function classFor(value: number, breaks: number[]): number {42 let i = 0;43 while (i < breaks.length && value >= breaks[i]!) i++;44 return i;45}4647/**48 * Interactive SVG world map: hover (mouse) shows the floating label; on touch the first tap selects and49 * shows the label with an "Open" link, a click with a mouse navigates (or calls `onSelect` when given).50 * Explicit hatched fill for no data. `selectedId` outlines one country; `renderLabel` customises the tooltip.51 */52export function ChoroplethView({53 features,54 sphere,55 legend,56 k,57 spec,58 summary,59 title,60 height,61 compact,62 className,63 onSelect,64 selectedId,65 renderLabel,66 showLegend = true,67 showHint = true,68 fillFor,69}: {70 features: ChoroplethFeature[];71 sphere: string;72 legend: Array<{ cls: number; label: string }>;73 k: number;74 spec: Spec;75 summary: string;76 title: string;77 height?: number;78 compact?: boolean;79 className?: string;80 /** When given, clicking / "Open" calls this instead of navigating to the country page. */81 onSelect?: (f: ChoroplethFeature) => void;82 selectedId?: string | null;83 renderLabel?: (f: ChoroplethFeature) => ReactNode;84 showLegend?: boolean;85 showHint?: boolean;86 /** Override the class → colour mapping (e.g. a diverging ramp). */87 fillFor?: (f: ChoroplethFeature) => string;88}) {89 const router = useRouter();90 const id = useId();91 const [active, setActive] = useState<{ f: ChoroplethFeature; x: number; y: number; sticky: boolean } | null>(null);9293 const place = useCallback((e: React.PointerEvent<SVGPathElement>, f: ChoroplethFeature, sticky: boolean) => {94 const box = e.currentTarget.ownerSVGElement?.parentElement?.getBoundingClientRect();95 if (!box) return;96 setActive({ f, x: e.clientX - box.left, y: e.clientY - box.top, sticky });97 }, []);9899 const open = (f: ChoroplethFeature) => {100 if (onSelect) {101 onSelect(f);102 setActive(null);103 return;104 }105 if (f.slug) router.push(routes.country(f.slug));106 };107 const fill = (f: ChoroplethFeature) => (fillFor ? fillFor(f) : f.cls != null ? seqVar(stepFor(f.cls, k)) : `url(#${id}-hatch)`);108 const selected = selectedId ? features.find((f) => f.iso3 === selectedId) ?? null : null;109110 return (111 <figure className={cn('min-w-0', className)}>112 <div className="relative w-full" style={{ aspectRatio: `${MAP_WIDTH} / ${MAP_HEIGHT}`, maxHeight: height }} onPointerLeave={() => setActive((a) => (a?.sticky ? a : null))}>113 <svg viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} className="h-full w-full" role="img" aria-label={summary}>114 <title>{title}</title>115 <desc>{summary}</desc>116 <defs>117 <pattern id={`${id}-hatch`} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">118 <rect width="6" height="6" fill="var(--nodata)" />119 <line x1="0" y1="0" x2="0" y2="6" stroke="var(--rule-strong)" strokeWidth="1.5" />120 </pattern>121 </defs>122 <path d={sphere} fill="var(--map-water)" stroke="var(--rule)" strokeWidth={1} />123 <g stroke="var(--map-stroke)" strokeWidth={0.6} strokeLinejoin="round">124 {features.map((f, i) => {125 const isActive = active?.f === f;126 const interactive = !!f.slug || (!!onSelect && !!f.iso3);127 return (128 <path129 key={f.iso3 ?? `${f.name}-${i}`}130 d={f.d}131 fill={fill(f)}132 className={cn(interactive && 'cursor-pointer', 'transition-[fill-opacity] duration-100')}133 fillOpacity={isActive ? 0.75 : 1}134 tabIndex={interactive ? 0 : -1}135 role={interactive ? (onSelect ? 'button' : 'link') : undefined}136 aria-label={`${f.name}: ${formatValue(f.value, spec)}`}137 onPointerMove={(e) => {138 if (e.pointerType === 'mouse') place(e, f, false);139 }}140 onPointerDown={(e) => {141 if (e.pointerType !== 'mouse') {142 e.preventDefault();143 place(e, f, true);144 }145 }}146 onClick={(e) => {147 // Mouse: open. Touch: the label carries the action (first tap selects).148 if ((e.nativeEvent as PointerEvent).pointerType === 'mouse' || (e as unknown as { detail: number }).detail === 0) open(f);149 }}150 onKeyDown={(e) => {151 if (e.key === 'Enter' && interactive) open(f);152 }}153 onFocus={(e) => {154 const b = e.currentTarget.getBBox();155 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 });156 }}157 >158 <title>{`${f.name}: ${formatValue(f.value, spec)}`}</title>159 </path>160 );161 })}162 {selected ? <path d={selected.d} fill="none" stroke="var(--ink)" strokeWidth={1.6} pointerEvents="none" /> : null}163 </g>164 </svg>165 {active ? (166 <div className="pointer-events-none absolute z-10 rounded-sm border border-rule bg-surface px-2.5 py-1.5 text-xs shadow-pop" style={{ left: Math.max(0, Math.min(active.x + 10, (typeof window !== 'undefined' ? window.innerWidth : 9999) - 240)), top: Math.max(0, active.y - 44), maxWidth: 230 }}>167 <div className="flex items-center gap-1.5 font-medium text-ink">168 {active.f.flag ? <span aria-hidden>{active.f.flag}</span> : null}169 <span className="truncate">{active.f.name}</span>170 </div>171 {renderLabel ? renderLabel(active.f) : <div className="tnum text-ink-2">{formatValue(active.f.value, spec)}</div>}172 {active.sticky && (active.f.slug || onSelect) ? (173 <button type="button" className="pointer-events-auto mt-1 min-h-[32px] text-accent underline" onClick={() => open(active.f)}>174 {t('metric.open', { name: active.f.name })} →175 </button>176 ) : null}177 </div>178 ) : null}179 </div>180 {showLegend ? (181 <figcaption className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-2xs text-ink-2">182 {!compact ? <span className="mr-1 font-medium text-ink">{title}</span> : null}183 <ul className="flex flex-wrap items-center gap-x-2.5 gap-y-1">184 {legend.map((l) => (185 <li key={l.cls} className="inline-flex items-center gap-1 tnum">186 <span aria-hidden className="inline-block h-2.5 w-3.5 rounded-xs" style={{ background: seqVar(stepFor(l.cls, k)) }} />187 {l.label}188 </li>189 ))}190 <li className="inline-flex items-center gap-1">191 <span aria-hidden className="no-data-hatch inline-block h-2.5 w-3.5 rounded-xs" />192 {t('chart.legend.noData')}193 </li>194 </ul>195 {showHint ? (196 <>197 <span className="ml-auto hidden text-ink-3 md:inline">{t('chart.map.hoverHint')}</span>198 <span className="ml-auto text-ink-3 md:hidden">{t('chart.map.tapHint')}</span>199 </>200 ) : null}201 </figcaption>202 ) : null}203 </figure>204 );205}206