/** * Inspired from plausible: https://github.com/plausible/analytics/blob/1df08a25b4a536c9cc1e03855ddcfeac1d1cf6e5/assets/js/dashboard/stats/locations/map.tsx */ import { cn } from "@app/lib/cn"; import worldJson from "visionscarto-world-atlas/world/110m.json"; import * as topojson from "topojson-client"; import * as d3 from "d3"; import { useRef, type ComponentRef, useState, useEffect, useMemo } from "react"; import { useTheme } from "next-themes"; import { COUNTRY_CODE_LIST } from "@app/lib/countryCodeList"; import { useTranslations } from "next-intl"; type CountryData = { alpha_3: string; name: string; count: number; code: string; }; export type WorldMapProps = { data: Pick[]; label: { singular: string; plural: string; }; }; export function WorldMap({ data, label }: WorldMapProps) { const svgRef = useRef>(null); const [tooltip, setTooltip] = useState<{ x: number; y: number; hoveredCountryAlpha3Code: string | null; }>({ x: 0, y: 0, hoveredCountryAlpha3Code: null }); const { theme, systemTheme } = useTheme(); const t = useTranslations(); useEffect(() => { if (!svgRef.current) return; const svg = drawInteractiveCountries(svgRef.current, setTooltip); return () => { svg.selectAll("*").remove(); }; }, []); const displayNames = new Intl.DisplayNames(navigator.language, { type: "region", fallback: "code" }); const maxValue = Math.max(...data.map((item) => item.count)); const dataByCountryCode = useMemo(() => { const byCountryCode = new Map(); for (const country of data) { const countryISOData = COUNTRY_CODE_LIST[country.code]; if (countryISOData) { byCountryCode.set(countryISOData.alpha3, { ...country, name: displayNames.of(country.code)!, alpha_3: countryISOData.alpha3 }); } } return byCountryCode; }, [data]); useEffect(() => { if (svgRef.current) { const palette = colorScales[theme ?? "light"] ?? colorScales[systemTheme ?? "light"]; const getColorForValue = d3 .scaleLinear() .domain([0, maxValue]) .range(palette); colorInCountriesWithValues( svgRef.current, getColorForValue, dataByCountryCode ); } }, [theme, systemTheme, maxValue, dataByCountryCode]); const hoveredCountryData = tooltip.hoveredCountryAlpha3Code ? dataByCountryCode.get(tooltip.hoveredCountryAlpha3Code) : undefined; return (
{!!hoveredCountryData && ( )}
); } interface MapTooltipProps { name: string; value: string; label: string; x: number; y: number; } function MapTooltip({ name, value, label, x, y }: MapTooltipProps) { return (
{name}
{value} {label}
); } const width = 475; const height = 335; const sharedCountryClass = cn("transition-colors"); const colorScales: Record = { dark: ["#4F4444", "#f36117"], light: ["#FFF5F3", "#f36117"] }; const countryClass = cn( sharedCountryClass, "stroke-1", "fill-[#fafafa]", "stroke-[#E7DADA]", "dark:fill-[#323236]", "dark:stroke-[#18181b]" ); const highlightedCountryClass = cn( sharedCountryClass, "stroke-2", "fill-[#f4f4f5]", "stroke-[#f36117]", "dark:fill-[#3f3f46]" ); function setupProjetionPath() { const projection = d3 .geoMercator() .scale(75) .translate([width / 2, height / 1.5]); const path = d3.geoPath().projection(projection); return path; } /** @returns the d3 selected svg element */ function drawInteractiveCountries( element: SVGSVGElement, setTooltip: React.Dispatch< React.SetStateAction<{ x: number; y: number; hoveredCountryAlpha3Code: string | null; }> > ) { const path = setupProjetionPath(); const data = parseWorldTopoJsonToGeoJsonFeatures(); const svg = d3.select(element); svg.selectAll("path") .data(data) .enter() .append("path") .attr("class", countryClass) .attr("d", path as never) .on("mouseover", function (event, country) { const [x, y] = d3.pointer(event, svg.node()?.parentNode); setTooltip({ x, y, hoveredCountryAlpha3Code: country.properties.a3 }); // brings country to front this.parentNode?.appendChild(this); d3.select(this).attr("class", highlightedCountryClass); }) .on("mousemove", function (event) { const [x, y] = d3.pointer(event, svg.node()?.parentNode); setTooltip((currentState) => ({ ...currentState, x, y })); }) .on("mouseout", function () { setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null }); d3.select(this).attr("class", countryClass); }); return svg; } type WorldJsonCountryData = { properties: { name: string; a3: string } }; function parseWorldTopoJsonToGeoJsonFeatures(): Array { const collection = topojson.feature( // @ts-expect-error strings in worldJson not recongizable as the enum values declared in library worldJson, worldJson.objects.countries ); // @ts-expect-error topojson.feature return type incorrectly inferred as not a collection return collection.features; } /** * Used to color the countries * @returns the svg elements represeting countries */ function colorInCountriesWithValues( element: SVGSVGElement, getColorForValue: d3.ScaleLinear, dataByCountryCode: Map ) { function getCountryByCountryPath(countryPath: unknown) { return dataByCountryCode.get( (countryPath as unknown as WorldJsonCountryData).properties.a3 ); } const svg = d3.select(element); return svg .selectAll("path") .style("fill", (countryPath) => { const country = getCountryByCountryPath(countryPath); if (!country?.count) { return null; } return getColorForValue(country.count); }) .style("cursor", (countryPath) => { const country = getCountryByCountryPath(countryPath); if (!country?.count) { return null; } return "pointer"; }); }