Files
nym/explorer-v2/src/components/worldMap/WorldMap.tsx
T
2025-04-16 15:27:53 +03:00

312 lines
9.3 KiB
TypeScript

"use client";
import { scaleLinear } from "d3-scale";
import * as React from "react";
import {
ComposableMap,
Geographies,
Geography,
ZoomableGroup,
} from "react-simple-maps";
import { Tooltip } from "react-tooltip";
import { fetchWorldMapCountries } from "@/app/api";
import AddIcon from "@mui/icons-material/Add";
import RemoveIcon from "@mui/icons-material/Remove";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { IconButton, Skeleton, Typography } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { useQuery } from "@tanstack/react-query";
import type { CountryDataResponse } from "../../app/api/types";
import MAP_TOPOJSON from "../../assets/world-110m.json";
import ExplorerCard from "../cards/ExplorerCard";
export const WorldMap = (): JSX.Element => {
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const [position, setPosition] = React.useState<{
coordinates: [number, number];
zoom: number;
}>({ coordinates: [0, 0], zoom: 1 });
const {
data: countries = [],
isLoading: isLoadingCountries,
isError: isCountriesError,
} = useQuery({
queryKey: ["nymNodesCountries"],
queryFn: fetchWorldMapCountries,
staleTime: 60 * 60 * 1000, // 1 hour
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const [tooltipContent, setTooltipContent] = React.useState<string>("");
React.useEffect(() => {
const handleMouseLeave = () => setTooltipContent("");
return () => {
handleMouseLeave();
};
}, []);
const colorScale = React.useMemo(() => {
if (countries) {
const heighestNumberOfNodes = Math.max(
...Object.values(countries).map((country) => country.nodes)
);
return scaleLinear<string, string>()
.domain([
0,
1,
heighestNumberOfNodes / 4,
heighestNumberOfNodes / 2,
heighestNumberOfNodes,
])
.range(
isDarkMode
? [
theme.palette.pine[950],
"#0F5A2E", // Dark green
"#147A3D", // Medium green
"#1A994C", // Light green
theme.palette.accent.main,
]
: [
theme.palette.pine[300],
"#0F5A2E", // Dark green
"#147A3D", // Medium green
"#1A994C", // Light green
theme.palette.accent.main,
]
)
.unknown(isDarkMode ? theme.palette.pine[950] : theme.palette.pine[25]);
}
return () =>
isDarkMode ? theme.palette.pine[950] : theme.palette.pine[25];
}, [countries, theme.palette.pine, theme.palette.accent, isDarkMode]);
if (isLoadingCountries) {
return (
<ExplorerCard label="Nym Nodes in the World" sx={{ width: "100%" }}>
<Skeleton variant="text" height={500} />
</ExplorerCard>
);
}
if (isCountriesError) {
return (
<ExplorerCard label="Nym Nodes in the World" sx={{ width: "100%" }}>
<Typography
variant="h5"
sx={{
color: isDarkMode ? "base.white" : "pine.950",
letterSpacing: 0.7,
}}
>
Failed to load data
</Typography>
<Skeleton variant="text" height={500} />
</ExplorerCard>
);
}
return (
<ExplorerCard label="Nym Nodes in the World" sx={{ width: "100%" }}>
<div
style={{
position: "relative",
width: "100%",
overflow: "hidden",
margin: "0 auto",
}}
>
<ComposableMap
data-tip=""
style={{
backgroundColor: isDarkMode ? "#000000" : theme.palette.pine[25],
width: "100%",
height: "auto",
}}
viewBox="0, 50, 800, 350"
projection="geoMercator"
projectionConfig={{
scale: 100,
}}
>
<ZoomableGroup
center={position.coordinates}
zoom={position.zoom}
minZoom={1}
maxZoom={8}
translateExtent={[
[-800, -400],
[800, 400],
]}
onMoveEnd={({
coordinates,
zoom,
}: {
coordinates: [number, number];
zoom: number;
}) => {
setPosition({ coordinates, zoom });
}}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Geographies geography={MAP_TOPOJSON}>
{({ geographies }: { geographies: GeoJSON.Feature[] }) =>
geographies.map((geo) => {
const d = Array.isArray(countries)
? { nodes: 0 }
: (countries as CountryDataResponse)[
geo.properties?.ISO_A3 as string
] || { nodes: 0 };
return (
<Geography
key={`${geo.properties?.ISO_A3 || ""}-${geo.id}-${
geo.properties?.NAME_LONG || ""
}`}
geography={geo}
fill={colorScale(d?.nodes || 0)}
stroke={
theme.palette.mode === "dark"
? theme.palette.pine[800]
: theme.palette.pine[200]
}
strokeWidth={0.2}
data-tooltip-id="map-tooltip"
onMouseEnter={() => {
const { NAME_LONG } = geo.properties as {
NAME_LONG: string;
};
setTooltipContent(`${NAME_LONG} | ${d?.nodes || 0}`);
}}
onMouseLeave={() => {
setTooltipContent("");
}}
style={{
hover: countries
? {
fill: theme.palette.accent.main,
outline: "white",
cursor: "pointer",
}
: undefined,
default: {
outline: "none",
},
pressed: {
outline: "none",
},
}}
/>
);
})
}
</Geographies>
</ZoomableGroup>
</ComposableMap>
<div
style={{
position: "absolute",
top: 10,
left: 10,
zIndex: 1000,
display: "flex",
flexDirection: "column",
gap: "4px",
backgroundColor: isDarkMode
? "rgba(0,0,0,0.5)"
: "rgba(255,255,255,0.5)",
padding: "4px",
borderRadius: "4px",
}}
>
<IconButton
size="small"
onClick={() =>
setPosition((prev) => ({
...prev,
zoom: Math.min(prev.zoom + 0.5, 8),
}))
}
sx={{
backgroundColor: isDarkMode
? "rgba(255,255,255,0.1)"
: "rgba(0,0,0,0.1)",
"&:hover": {
backgroundColor: isDarkMode
? "rgba(255,255,255,0.2)"
: "rgba(0,0,0,0.2)",
},
}}
>
<AddIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() =>
setPosition((prev) => ({
...prev,
zoom: Math.max(prev.zoom - 0.5, 1),
}))
}
sx={{
backgroundColor: isDarkMode
? "rgba(255,255,255,0.1)"
: "rgba(0,0,0,0.1)",
"&:hover": {
backgroundColor: isDarkMode
? "rgba(255,255,255,0.2)"
: "rgba(0,0,0,0.2)",
},
}}
>
<RemoveIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => setPosition({ coordinates: [0, 0], zoom: 1 })}
sx={{
backgroundColor: isDarkMode
? "rgba(255,255,255,0.1)"
: "rgba(0,0,0,0.1)",
"&:hover": {
backgroundColor: isDarkMode
? "rgba(255,255,255,0.2)"
: "rgba(0,0,0,0.2)",
},
}}
>
<RestartAltIcon fontSize="small" />
</IconButton>
</div>
</div>
<Tooltip
id="map-tooltip"
content={tooltipContent}
float={true}
style={{
fontSize: "12px",
padding: "4px 8px",
backgroundColor:
theme.palette.mode === "dark"
? theme.palette.pine[800]
: theme.palette.pine[200],
color:
theme.palette.mode === "dark"
? theme.palette.base.white
: theme.palette.pine[950],
borderRadius: "4px",
zIndex: 9999,
}}
/>
</ExplorerCard>
);
};