Download src/components/WorldMap.jsx from NKessler/Horizon: direct link, hf CLI and curl.
- Browser
- Download file 4.94 kB
-
https://huggingface.co/NKessler/Horizon/resolve/main/src/components/WorldMap.jsx
- Command line
-
hf download hf://NKessler/Horizon/src/components/WorldMap.jsx
-
curl -L -o WorldMap.jsx https://huggingface.co/NKessler/Horizon/resolve/main/src/components/WorldMap.jsx
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); | |