Horizon / src /components /WorldMap.jsx
NKessler's picture
Upload 6270 files
4083225 verified
Raw
History Blame Contribute Delete
4.94 kB
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 (
<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);