| 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 }; |
|
|
| |
| let worldFeaturesCache = null; |
| let worldFeaturesPromise = null; |
|
|
| |
| 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; |
| |
| 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 ( |
| <svg |
| ref={svgRef} |
| viewBox={`0 0 ${VIEW.w} ${VIEW.h}`} |
| preserveAspectRatio="xMidYMid meet" |
| style={{ width: "100%", height: "100%", display: "block", cursor: dragRef.current ? "grabbing" : "grab" }} |
| onMouseDown={onMouseDown} |
| onMouseMove={onMouseMove} |
| onMouseUp={endDrag} |
| onMouseLeave={() => { |
| endDrag(); |
| onCountryLeave?.(); |
| }} |
| onWheel={onWheel} |
| > |
| <defs> |
| <radialGradient id="mapVignette" cx="50%" cy="50%" r="70%"> |
| <stop offset="60%" stopColor="rgba(0,0,0,0)" /> |
| <stop offset="100%" stopColor="rgba(0,0,0,0.55)" /> |
| </radialGradient> |
| </defs> |
| |
| <rect width={VIEW.w} height={VIEW.h} fill="var(--bg)" /> |
| |
| <g transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}> |
| {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 ( |
| <path |
| key={numeric + "-" + geo.properties.name} |
| d={pathGen(geo)} |
| fill={fill} |
| stroke={isSelected ? "var(--accent)" : "var(--border)"} |
| strokeWidth={isSelected ? 1 / transform.k : 0.4 / transform.k} |
| vectorEffect="non-scaling-stroke" |
| onMouseEnter={(ev) => onCountryEnter?.(country, ev)} |
| onMouseMove={(ev) => onCountryMove?.(ev)} |
| onMouseLeave={onCountryLeave} |
| onClick={(ev) => onCountryClick?.(country, ev)} |
| style={{ |
| cursor: onCountryClick ? "pointer" : "default", |
| transition: "fill 250ms ease", |
| }} |
| /> |
| ); |
| })} |
| </g> |
| |
| <rect |
| width={VIEW.w} |
| height={VIEW.h} |
| fill="url(#mapVignette)" |
| pointerEvents="none" |
| /> |
| </svg> |
| ); |
| } |
|
|
| export default memo(WorldMap); |
|
|