import { memo, useEffect, useMemo, useRef, useState } from "react"; import { geoEqualEarth, geoPath } from "d3-geo"; import { feature } from "topojson-client"; const WORLD_TOPO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"; const VIEW = { w: 1100, h: 620 }; // TopoJSON fetched once per session (survives route remounts). let worldFeaturesCache = null; let worldFeaturesPromise = null; /** D3 world map: pan/zoom, `colorFor(numericCode)`, selection + hover callbacks. */ function WorldMap({ colorFor, selectedNumeric = null, onCountryEnter, onCountryMove, onCountryLeave, onCountryClick, }) { const [countries, setCountries] = useState(null); const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 }); const dragRef = useRef(null); const svgRef = useRef(null); useEffect(() => { let cancelled = false; if (worldFeaturesCache) { setCountries(worldFeaturesCache); return () => { cancelled = true; }; } if (!worldFeaturesPromise) { worldFeaturesPromise = fetch(WORLD_TOPO_URL) .then((r) => r.json()) .then((topo) => { const fc = feature(topo, topo.objects.countries); worldFeaturesCache = fc.features; return worldFeaturesCache; }) .catch((err) => { worldFeaturesPromise = null; throw err; }); } worldFeaturesPromise.then((features) => { if (!cancelled) setCountries(features); }); return () => { cancelled = true; }; }, []); const pathGen = useMemo(() => { const projection = geoEqualEarth() .scale(190) .translate([VIEW.w / 2, VIEW.h / 2 + 10]); return geoPath(projection); }, []); function onMouseDown(e) { dragRef.current = { startX: e.clientX, startY: e.clientY, tx: transform.x, ty: transform.y, }; } function onMouseMove(e) { if (dragRef.current) { const dx = e.clientX - dragRef.current.startX; const dy = e.clientY - dragRef.current.startY; setTransform((t) => ({ ...t, x: dragRef.current.tx + dx, y: dragRef.current.ty + dy, })); } } function endDrag() { dragRef.current = null; } function onWheel(e) { e.preventDefault(); const delta = -e.deltaY * 0.0015; const nextK = Math.max(0.7, Math.min(8, transform.k * (1 + delta))); if (nextK === transform.k) return; // Zoom toward cursor. const rect = svgRef.current.getBoundingClientRect(); const px = e.clientX - rect.left; const py = e.clientY - rect.top; const ratio = nextK / transform.k; setTransform({ k: nextK, x: px - (px - transform.x) * ratio, y: py - (py - transform.y) * ratio, }); } return ( { endDrag(); onCountryLeave?.(); }} onWheel={onWheel} > {countries?.map((geo) => { const numeric = String(geo.id).padStart(3, "0"); const fill = colorFor(numeric, geo.properties); const isSelected = selectedNumeric === numeric; const country = { numericCode: numeric, name: geo.properties.name, properties: geo.properties, }; return ( onCountryEnter?.(country, ev)} onMouseMove={(ev) => onCountryMove?.(ev)} onMouseLeave={onCountryLeave} onClick={(ev) => onCountryClick?.(country, ev)} style={{ cursor: onCountryClick ? "pointer" : "default", transition: "fill 250ms ease", }} /> ); })} ); } export default memo(WorldMap);