SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
14.5 KB · 346 lines tsx
Raw Blame History
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