'use client'; import { Minus, Plus, RotateCcw } from 'lucide-react'; import { useCallback, useEffect, useId, useMemo, useRef, useState, type PointerEvent as RPointerEvent } from 'react'; import { t } from '@/i18n'; import { cn } from '@/lib/cn'; import { MAP_HEIGHT, MAP_WIDTH } from '@/lib/map-geo'; import type { BaseFeature } from '@/components/indicators/indicator-map'; import { seqVar } from '@/components/charts/palette'; import { pathBBox, stepFor } from './geo'; export interface MapHover { iso3: string; x: number; y: number; sticky: boolean; } const MIN_K = 1; const MAX_K = 8; /** * Zoomable, pannable Equal Earth world map (SVG). Paths are memoised once; only the fill class per country changes * with the year. Wheel zooms around the cursor, drag pans, two pointers pinch; +/− and reset buttons; `focusId` * flies to a country's bounding box. Mouse hover reports a floating position; touch taps select (`onSelect`). * The transform is applied straight to the during gestures (rAF) and mirrored to React state at rest. */ export function MapCanvas({ features, sphere, classOf, k, selectedId, focusId, onHover, onSelect, onOpen, className, labelOf, }: { features: BaseFeature[]; sphere: string; /** ISO3 → class index (0..k−1) or null for no data. */ classOf: Map; k: number; selectedId: string | null; focusId: string | null; onHover: (h: MapHover | null) => void; onSelect: (iso3: string | null) => void; /** Double click / double tap → open the country page. */ onOpen?: (iso3: string) => void; className?: string; labelOf: (iso3: string) => string; }) { const id = useId(); const wrap = useRef(null); const gRef = useRef(null); const view = useRef({ k: 1, tx: 0, ty: 0 }); const [zoom, setZoom] = useState(1); const raf = useRef(null); const pointers = useRef(new Map()); const gesture = useRef<{ startDist: number; startK: number; startTx: number; startTy: number; cx: number; cy: number; lastX: number; lastY: number; moved: boolean } | null>(null); const lastTap = useRef<{ iso: string; t: number } | null>(null); const bboxes = useMemo(() => new Map(features.filter((f) => f.iso3).map((f) => [f.iso3!, pathBBox(f.d)])), [features]); const apply = useCallback(() => { raf.current = null; const g = gRef.current; if (!g) return; const { k: kk, tx, ty } = view.current; g.setAttribute('transform', `translate(${tx.toFixed(2)},${ty.toFixed(2)}) scale(${kk.toFixed(4)})`); g.style.setProperty('--k', String(kk)); }, []); const schedule = useCallback(() => { if (raf.current == null) raf.current = requestAnimationFrame(apply); }, [apply]); /** Geometry of the `meet`-fitted viewBox inside the wrapper: CSS px per user unit and the centring offsets. */ const fit = useCallback(() => { const el = wrap.current; if (!el) return { s: 1, ox: 0, oy: 0, left: 0, top: 0 }; const r = el.getBoundingClientRect(); const s = Math.min(r.width / MAP_WIDTH, r.height / MAP_HEIGHT) || 1; return { s, ox: (r.width - MAP_WIDTH * s) / 2, oy: (r.height - MAP_HEIGHT * s) / 2, left: r.left, top: r.top }; }, []); /** Scale factor from CSS pixels to SVG user units. */ const unitsPerPx = useCallback(() => 1 / fit().s, [fit]); const clampView = useCallback(() => { const v = view.current; v.k = Math.min(MAX_K, Math.max(MIN_K, v.k)); // keep the map covering the viewport: translation bounds const maxTx = 0; const minTx = MAP_WIDTH - MAP_WIDTH * v.k; const maxTy = MAP_HEIGHT * 0.25 * (v.k - 1); const minTy = MAP_HEIGHT - MAP_HEIGHT * v.k - MAP_HEIGHT * 0.25 * (v.k - 1); if (v.k <= 1) { v.tx = 0; v.ty = 0; } else { v.tx = Math.min(maxTx, Math.max(minTx, v.tx)); v.ty = Math.min(maxTy, Math.max(minTy, v.ty)); } }, []); const zoomAt = useCallback( (factor: number, ux: number, uy: number) => { const v = view.current; const nk = Math.min(MAX_K, Math.max(MIN_K, v.k * factor)); const f = nk / v.k; v.tx = ux - (ux - v.tx) * f; v.ty = uy - (uy - v.ty) * f; v.k = nk; clampView(); schedule(); setZoom(v.k); }, [clampView, schedule], ); const toUnits = useCallback( (clientX: number, clientY: number) => { const f = fit(); return { ux: (clientX - f.left - f.ox) / f.s, uy: (clientY - f.top - f.oy) / f.s }; }, [fit], ); // Wheel zoom (non-passive so we can prevent the page from scrolling while over the map). useEffect(() => { const el = wrap.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); const { ux, uy } = toUnits(e.clientX, e.clientY); zoomAt(Math.exp(-e.deltaY * 0.0015), ux, uy); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, [toUnits, zoomAt]); // Fly to a country. useEffect(() => { if (!focusId) return; const b = bboxes.get(focusId); if (!b) return; const v = view.current; 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)))); const cx = b.x + b.w / 2; const cy = b.y + b.h / 2; const start = { ...v }; const end = { k: target, tx: MAP_WIDTH / 2 - cx * target, ty: MAP_HEIGHT / 2 - cy * target }; const t0 = performance.now(); const dur = 480; const g = gRef.current; if (g) g.style.transition = 'none'; const step = (now: number) => { const p = Math.min(1, (now - t0) / dur); const e = 1 - Math.pow(1 - p, 3); v.k = start.k + (end.k - start.k) * e; v.tx = start.tx + (end.tx - start.tx) * e; v.ty = start.ty + (end.ty - start.ty) * e; clampView(); apply(); if (p < 1) requestAnimationFrame(step); else setZoom(v.k); }; requestAnimationFrame(step); }, [focusId, bboxes, apply, clampView]); // Portrait viewports (phones): start at 1.5× so countries are legible; landscape keeps the whole world. useEffect(() => { const el = wrap.current; if (!el) return; const r = el.getBoundingClientRect(); if (r.height / Math.max(1, r.width) > (MAP_HEIGHT / MAP_WIDTH) * 1.4 && view.current.k === 1) { const v = view.current; v.k = 1.5; v.tx = MAP_WIDTH / 2 - (MAP_WIDTH / 2) * v.k; v.ty = MAP_HEIGHT / 2 - (MAP_HEIGHT / 2) * v.k; clampView(); apply(); setZoom(v.k); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const reset = () => { view.current = { k: 1, tx: 0, ty: 0 }; schedule(); setZoom(1); }; const downIso = useRef(null); const touch = useRef(false); const onPointerDown = (e: RPointerEvent) => { (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); touch.current = e.pointerType !== 'mouse'; if (touch.current) onHover(null); downIso.current = pointers.current.size === 0 ? ((e.target as Element).closest?.('path[data-iso]') as SVGPathElement | null)?.dataset.iso ?? null : null; pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); const pts = Array.from(pointers.current.values()); const v = view.current; if (pts.length === 1) { 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 }; } else if (pts.length === 2) { const [a, b] = pts as [{ x: number; y: number }, { x: number; y: number }]; 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 }; } }; const onPointerMove = (e: RPointerEvent) => { if (!pointers.current.has(e.pointerId)) return; pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); const g = gesture.current; if (!g) return; const pts = Array.from(pointers.current.values()); const s = unitsPerPx(); const v = view.current; if (pts.length >= 2) { const [a, b] = pts as [{ x: number; y: number }, { x: number; y: number }]; const dist = Math.hypot(a.x - b.x, a.y - b.y); const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2; const f = g.startDist > 0 ? dist / g.startDist : 1; const nk = Math.min(MAX_K, Math.max(MIN_K, g.startK * f)); const { ux, uy } = toUnits(g.cx, g.cy); const ratio = nk / g.startK; v.tx = ux - (ux - g.startTx) * ratio + (mx - g.cx) * s; v.ty = uy - (uy - g.startTy) * ratio + (my - g.cy) * s; v.k = nk; g.moved = true; } else { const dx = e.clientX - g.lastX; const dy = e.clientY - g.lastY; if (Math.abs(e.clientX - g.cx) + Math.abs(e.clientY - g.cy) > 4) g.moved = true; if (v.k > 1 || g.moved) { v.tx += dx * s; v.ty += dy * s; } g.lastX = e.clientX; g.lastY = e.clientY; } clampView(); schedule(); }; const endGesture = (e: RPointerEvent) => { pointers.current.delete(e.pointerId); if (pointers.current.size === 0) { const moved = gesture.current?.moved ?? false; gesture.current = null; setZoom(view.current.k); if (moved) lastTap.current = null; else if (downIso.current) countryTap(downIso.current); downIso.current = null; } else if (pointers.current.size === 1) { const [p] = Array.from(pointers.current.values()) as [{ x: number; y: number }]; const v = view.current; 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 }; } }; const place = useCallback( (e: RPointerEvent, iso: string, sticky: boolean) => { const box = wrap.current?.getBoundingClientRect(); if (!box) return; onHover({ iso3: iso, x: e.clientX - box.left, y: e.clientY - box.top, sticky }); }, [onHover], ); const countryTap = (iso: string) => { const now = performance.now(); if (lastTap.current && lastTap.current.iso === iso && now - lastTap.current.t < 380) { lastTap.current = null; onOpen?.(iso); return; } lastTap.current = { iso, t: now }; onSelect(iso); }; return (
onHover(null)}> {features.map((f, i) => { const cls = f.iso3 ? classOf.get(f.iso3) ?? null : null; const sel = f.iso3 != null && f.iso3 === selectedId; return ( { if (e.pointerType === 'mouse' && f.iso3 && !gesture.current?.moved) place(e, f.iso3, false); }} onPointerEnter={(e) => { if (e.pointerType === 'mouse' && f.iso3) place(e, f.iso3, false); }} data-iso={f.iso3 ?? undefined} onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && f.iso3) { e.preventDefault(); onSelect(f.iso3); } }} onFocus={(e) => { if (!f.iso3 || touch.current) return; const b = e.currentTarget.getBoundingClientRect(); const box = wrap.current?.getBoundingClientRect(); if (box) onHover({ iso3: f.iso3, x: b.left - box.left + b.width / 2, y: b.top - box.top, sticky: false }); }} /> ); })}
); }