spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { Minus, Plus, RotateCcw } from 'lucide-react';3import { useCallback, useEffect, useId, useMemo, useRef, useState, type PointerEvent as RPointerEvent } from 'react';4import { t } from '@/i18n';5import { cn } from '@/lib/cn';6import { MAP_HEIGHT, MAP_WIDTH } from '@/lib/map-geo';7import type { BaseFeature } from '@/components/indicators/indicator-map';8import { seqVar } from '@/components/charts/palette';9import { pathBBox, stepFor } from './geo';1011export interface MapHover {12 iso3: string;13 x: number;14 y: number;15 sticky: boolean;16}1718const MIN_K = 1;19const MAX_K = 8;2021/**22 * Zoomable, pannable Equal Earth world map (SVG). Paths are memoised once; only the fill class per country changes23 * with the year. Wheel zooms around the cursor, drag pans, two pointers pinch; +/− and reset buttons; `focusId`24 * flies to a country's bounding box. Mouse hover reports a floating position; touch taps select (`onSelect`).25 * The transform is applied straight to the <g> during gestures (rAF) and mirrored to React state at rest.26 */27export function MapCanvas({28 features,29 sphere,30 classOf,31 k,32 selectedId,33 focusId,34 onHover,35 onSelect,36 onOpen,37 className,38 labelOf,39}: {40 features: BaseFeature[];41 sphere: string;42 /** ISO3 → class index (0..k−1) or null for no data. */43 classOf: Map<string, number | null>;44 k: number;45 selectedId: string | null;46 focusId: string | null;47 onHover: (h: MapHover | null) => void;48 onSelect: (iso3: string | null) => void;49 /** Double click / double tap → open the country page. */50 onOpen?: (iso3: string) => void;51 className?: string;52 labelOf: (iso3: string) => string;53}) {54 const id = useId();55 const wrap = useRef<HTMLDivElement>(null);56 const gRef = useRef<SVGGElement>(null);57 const view = useRef({ k: 1, tx: 0, ty: 0 });58 const [zoom, setZoom] = useState(1);59 const raf = useRef<number | null>(null);60 const pointers = useRef(new Map<number, { x: number; y: number }>());61 const gesture = useRef<{ startDist: number; startK: number; startTx: number; startTy: number; cx: number; cy: number; lastX: number; lastY: number; moved: boolean } | null>(null);62 const lastTap = useRef<{ iso: string; t: number } | null>(null);63 const bboxes = useMemo(() => new Map(features.filter((f) => f.iso3).map((f) => [f.iso3!, pathBBox(f.d)])), [features]);6465 const apply = useCallback(() => {66 raf.current = null;67 const g = gRef.current;68 if (!g) return;69 const { k: kk, tx, ty } = view.current;70 g.setAttribute('transform', `translate(${tx.toFixed(2)},${ty.toFixed(2)}) scale(${kk.toFixed(4)})`);71 g.style.setProperty('--k', String(kk));72 }, []);73 const schedule = useCallback(() => {74 if (raf.current == null) raf.current = requestAnimationFrame(apply);75 }, [apply]);7677 /** Geometry of the `meet`-fitted viewBox inside the wrapper: CSS px per user unit and the centring offsets. */78 const fit = useCallback(() => {79 const el = wrap.current;80 if (!el) return { s: 1, ox: 0, oy: 0, left: 0, top: 0 };81 const r = el.getBoundingClientRect();82 const s = Math.min(r.width / MAP_WIDTH, r.height / MAP_HEIGHT) || 1;83 return { s, ox: (r.width - MAP_WIDTH * s) / 2, oy: (r.height - MAP_HEIGHT * s) / 2, left: r.left, top: r.top };84 }, []);85 /** Scale factor from CSS pixels to SVG user units. */86 const unitsPerPx = useCallback(() => 1 / fit().s, [fit]);8788 const clampView = useCallback(() => {89 const v = view.current;90 v.k = Math.min(MAX_K, Math.max(MIN_K, v.k));91 // keep the map covering the viewport: translation bounds92 const maxTx = 0;93 const minTx = MAP_WIDTH - MAP_WIDTH * v.k;94 const maxTy = MAP_HEIGHT * 0.25 * (v.k - 1);95 const minTy = MAP_HEIGHT - MAP_HEIGHT * v.k - MAP_HEIGHT * 0.25 * (v.k - 1);96 if (v.k <= 1) {97 v.tx = 0;98 v.ty = 0;99 } else {100 v.tx = Math.min(maxTx, Math.max(minTx, v.tx));101 v.ty = Math.min(maxTy, Math.max(minTy, v.ty));102 }103 }, []);104105 const zoomAt = useCallback(106 (factor: number, ux: number, uy: number) => {107 const v = view.current;108 const nk = Math.min(MAX_K, Math.max(MIN_K, v.k * factor));109 const f = nk / v.k;110 v.tx = ux - (ux - v.tx) * f;111 v.ty = uy - (uy - v.ty) * f;112 v.k = nk;113 clampView();114 schedule();115 setZoom(v.k);116 },117 [clampView, schedule],118 );119120 const toUnits = useCallback(121 (clientX: number, clientY: number) => {122 const f = fit();123 return { ux: (clientX - f.left - f.ox) / f.s, uy: (clientY - f.top - f.oy) / f.s };124 },125 [fit],126 );127128 // Wheel zoom (non-passive so we can prevent the page from scrolling while over the map).129 useEffect(() => {130 const el = wrap.current;131 if (!el) return;132 const onWheel = (e: WheelEvent) => {133 e.preventDefault();134 const { ux, uy } = toUnits(e.clientX, e.clientY);135 zoomAt(Math.exp(-e.deltaY * 0.0015), ux, uy);136 };137 el.addEventListener('wheel', onWheel, { passive: false });138 return () => el.removeEventListener('wheel', onWheel);139 }, [toUnits, zoomAt]);140141 // Fly to a country.142 useEffect(() => {143 if (!focusId) return;144 const b = bboxes.get(focusId);145 if (!b) return;146 const v = view.current;147 const target = Math.min(MAX_K, Math.max(1.6, Math.min((MAP_WIDTH * 0.35) / Math.max(b.w, 1), (MAP_HEIGHT * 0.35) / Math.max(b.h, 1))));148 const cx = b.x + b.w / 2;149 const cy = b.y + b.h / 2;150 const start = { ...v };151 const end = { k: target, tx: MAP_WIDTH / 2 - cx * target, ty: MAP_HEIGHT / 2 - cy * target };152 const t0 = performance.now();153 const dur = 480;154 const g = gRef.current;155 if (g) g.style.transition = 'none';156 const step = (now: number) => {157 const p = Math.min(1, (now - t0) / dur);158 const e = 1 - Math.pow(1 - p, 3);159 v.k = start.k + (end.k - start.k) * e;160 v.tx = start.tx + (end.tx - start.tx) * e;161 v.ty = start.ty + (end.ty - start.ty) * e;162 clampView();163 apply();164 if (p < 1) requestAnimationFrame(step);165 else setZoom(v.k);166 };167 requestAnimationFrame(step);168 }, [focusId, bboxes, apply, clampView]);169170 // Portrait viewports (phones): start at 1.5× so countries are legible; landscape keeps the whole world.171 useEffect(() => {172 const el = wrap.current;173 if (!el) return;174 const r = el.getBoundingClientRect();175 if (r.height / Math.max(1, r.width) > (MAP_HEIGHT / MAP_WIDTH) * 1.4 && view.current.k === 1) {176 const v = view.current;177 v.k = 1.5;178 v.tx = MAP_WIDTH / 2 - (MAP_WIDTH / 2) * v.k;179 v.ty = MAP_HEIGHT / 2 - (MAP_HEIGHT / 2) * v.k;180 clampView();181 apply();182 setZoom(v.k);183 }184 // eslint-disable-next-line react-hooks/exhaustive-deps185 }, []);186187 const reset = () => {188 view.current = { k: 1, tx: 0, ty: 0 };189 schedule();190 setZoom(1);191 };192193 const downIso = useRef<string | null>(null);194 const touch = useRef(false);195 const onPointerDown = (e: RPointerEvent<HTMLDivElement>) => {196 (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);197 touch.current = e.pointerType !== 'mouse';198 if (touch.current) onHover(null);199 downIso.current = pointers.current.size === 0 ? ((e.target as Element).closest?.('path[data-iso]') as SVGPathElement | null)?.dataset.iso ?? null : null;200 pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });201 const pts = Array.from(pointers.current.values());202 const v = view.current;203 if (pts.length === 1) {204 gesture.current = { startDist: 0, startK: v.k, startTx: v.tx, startTy: v.ty, cx: e.clientX, cy: e.clientY, lastX: e.clientX, lastY: e.clientY, moved: false };205 } else if (pts.length === 2) {206 const [a, b] = pts as [{ x: number; y: number }, { x: number; y: number }];207 gesture.current = { startDist: Math.hypot(a.x - b.x, a.y - b.y), startK: v.k, startTx: v.tx, startTy: v.ty, cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2, lastX: (a.x + b.x) / 2, lastY: (a.y + b.y) / 2, moved: true };208 }209 };210 const onPointerMove = (e: RPointerEvent<HTMLDivElement>) => {211 if (!pointers.current.has(e.pointerId)) return;212 pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });213 const g = gesture.current;214 if (!g) return;215 const pts = Array.from(pointers.current.values());216 const s = unitsPerPx();217 const v = view.current;218 if (pts.length >= 2) {219 const [a, b] = pts as [{ x: number; y: number }, { x: number; y: number }];220 const dist = Math.hypot(a.x - b.x, a.y - b.y);221 const mx = (a.x + b.x) / 2;222 const my = (a.y + b.y) / 2;223 const f = g.startDist > 0 ? dist / g.startDist : 1;224 const nk = Math.min(MAX_K, Math.max(MIN_K, g.startK * f));225 const { ux, uy } = toUnits(g.cx, g.cy);226 const ratio = nk / g.startK;227 v.tx = ux - (ux - g.startTx) * ratio + (mx - g.cx) * s;228 v.ty = uy - (uy - g.startTy) * ratio + (my - g.cy) * s;229 v.k = nk;230 g.moved = true;231 } else {232 const dx = e.clientX - g.lastX;233 const dy = e.clientY - g.lastY;234 if (Math.abs(e.clientX - g.cx) + Math.abs(e.clientY - g.cy) > 4) g.moved = true;235 if (v.k > 1 || g.moved) {236 v.tx += dx * s;237 v.ty += dy * s;238 }239 g.lastX = e.clientX;240 g.lastY = e.clientY;241 }242 clampView();243 schedule();244 };245 const endGesture = (e: RPointerEvent<HTMLDivElement>) => {246 pointers.current.delete(e.pointerId);247 if (pointers.current.size === 0) {248 const moved = gesture.current?.moved ?? false;249 gesture.current = null;250 setZoom(view.current.k);251 if (moved) lastTap.current = null;252 else if (downIso.current) countryTap(downIso.current);253 downIso.current = null;254 } else if (pointers.current.size === 1) {255 const [p] = Array.from(pointers.current.values()) as [{ x: number; y: number }];256 const v = view.current;257 gesture.current = { startDist: 0, startK: v.k, startTx: v.tx, startTy: v.ty, cx: p.x, cy: p.y, lastX: p.x, lastY: p.y, moved: true };258 }259 };260261 const place = useCallback(262 (e: RPointerEvent<SVGPathElement>, iso: string, sticky: boolean) => {263 const box = wrap.current?.getBoundingClientRect();264 if (!box) return;265 onHover({ iso3: iso, x: e.clientX - box.left, y: e.clientY - box.top, sticky });266 },267 [onHover],268 );269270 const countryTap = (iso: string) => {271 const now = performance.now();272 if (lastTap.current && lastTap.current.iso === iso && now - lastTap.current.t < 380) {273 lastTap.current = null;274 onOpen?.(iso);275 return;276 }277 lastTap.current = { iso, t: now };278 onSelect(iso);279 };280281 return (282 <div ref={wrap} className={cn('relative h-full w-full touch-none select-none overflow-hidden', className)} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} onPointerLeave={() => onHover(null)}>283 <svg viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} className="h-full w-full" role="img" aria-label={t('explorer.map.aria')} preserveAspectRatio="xMidYMid meet">284 <defs>285 <pattern id={`${id}-hatch`} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">286 <rect width="6" height="6" fill="var(--nodata)" />287 <line x1="0" y1="0" x2="0" y2="6" stroke="var(--rule-strong)" strokeWidth="1.5" />288 </pattern>289 </defs>290 <g ref={gRef} style={{ ['--k' as string]: 1 }}>291 <path d={sphere} fill="var(--map-water)" stroke="var(--rule)" strokeWidth={1} vectorEffect="non-scaling-stroke" />292 <g stroke="var(--map-stroke)" strokeWidth={0.7} strokeLinejoin="round">293 {features.map((f, i) => {294 const cls = f.iso3 ? classOf.get(f.iso3) ?? null : null;295 const sel = f.iso3 != null && f.iso3 === selectedId;296 return (297 <path298 key={f.iso3 ?? `${f.name}-${i}`}299 d={f.d}300 fill={cls != null ? seqVar(stepFor(cls, k)) : `url(#${id}-hatch)`}301 className={cn('outline-none transition-[fill] duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent', f.iso3 && 'cursor-pointer', sel && 'stroke-ink [stroke-width:1.6]')}302 vectorEffect="non-scaling-stroke"303 tabIndex={f.iso3 ? 0 : -1}304 role={f.iso3 ? 'button' : undefined}305 aria-label={f.iso3 ? labelOf(f.iso3) : undefined}306 aria-pressed={sel || undefined}307 onPointerMove={(e) => {308 if (e.pointerType === 'mouse' && f.iso3 && !gesture.current?.moved) place(e, f.iso3, false);309 }}310 onPointerEnter={(e) => {311 if (e.pointerType === 'mouse' && f.iso3) place(e, f.iso3, false);312 }}313 data-iso={f.iso3 ?? undefined}314 onKeyDown={(e) => {315 if ((e.key === 'Enter' || e.key === ' ') && f.iso3) {316 e.preventDefault();317 onSelect(f.iso3);318 }319 }}320 onFocus={(e) => {321 if (!f.iso3 || touch.current) return;322 const b = e.currentTarget.getBoundingClientRect();323 const box = wrap.current?.getBoundingClientRect();324 if (box) onHover({ iso3: f.iso3, x: b.left - box.left + b.width / 2, y: b.top - box.top, sticky: false });325 }}326 />327 );328 })}329 </g>330 </g>331 </svg>332 <div className="absolute bottom-3 right-3 flex flex-col gap-1" role="group" aria-label={t('explorer.map.zoom')}>333 <button type="button" onClick={() => zoomAt(1.5, MAP_WIDTH / 2, MAP_HEIGHT / 2)} disabled={zoom >= MAX_K} className="tap grid place-items-center rounded-sm border border-rule bg-surface/95 text-ink shadow-pop hover:bg-surface-2 disabled:opacity-40 md:min-h-[36px] md:min-w-[36px]" aria-label={t('explorer.map.zoomIn')}>334 <Plus size={16} aria-hidden />335 </button>336 <button type="button" onClick={() => zoomAt(1 / 1.5, MAP_WIDTH / 2, MAP_HEIGHT / 2)} disabled={zoom <= MIN_K} className="tap grid place-items-center rounded-sm border border-rule bg-surface/95 text-ink shadow-pop hover:bg-surface-2 disabled:opacity-40 md:min-h-[36px] md:min-w-[36px]" aria-label={t('explorer.map.zoomOut')}>337 <Minus size={16} aria-hidden />338 </button>339 <button type="button" onClick={reset} disabled={zoom === 1} className="tap grid place-items-center rounded-sm border border-rule bg-surface/95 text-ink shadow-pop hover:bg-surface-2 disabled:opacity-40 md:min-h-[36px] md:min-w-[36px]" aria-label={t('explorer.map.reset')}>340 <RotateCcw size={15} aria-hidden />341 </button>342 </div>343 </div>344 );345}346