mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-04 09:03:48 +00:00
276 lines
7.9 KiB
TypeScript
276 lines
7.9 KiB
TypeScript
/**
|
|
* 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<CountryData, "code" | "count">[];
|
|
label: {
|
|
singular: string;
|
|
plural: string;
|
|
};
|
|
};
|
|
|
|
export function WorldMap({ data, label }: WorldMapProps) {
|
|
const svgRef = useRef<ComponentRef<"svg">>(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<string, CountryData>();
|
|
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<string>()
|
|
.domain([0, maxValue])
|
|
.range(palette);
|
|
|
|
colorInCountriesWithValues(
|
|
svgRef.current,
|
|
getColorForValue,
|
|
dataByCountryCode
|
|
);
|
|
}
|
|
}, [theme, systemTheme, maxValue, dataByCountryCode]);
|
|
|
|
const hoveredCountryData = tooltip.hoveredCountryAlpha3Code
|
|
? dataByCountryCode.get(tooltip.hoveredCountryAlpha3Code)
|
|
: undefined;
|
|
|
|
return (
|
|
<div className="mx-auto mt-4 w-full relative">
|
|
<svg
|
|
ref={svgRef}
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
className="w-full"
|
|
/>
|
|
|
|
{!!hoveredCountryData && (
|
|
<MapTooltip
|
|
x={tooltip.x}
|
|
y={tooltip.y}
|
|
name={hoveredCountryData.name}
|
|
value={Intl.NumberFormat(navigator.language).format(
|
|
hoveredCountryData.count
|
|
)}
|
|
label={
|
|
hoveredCountryData.count === 1
|
|
? t(label.singular)
|
|
: t(label.plural)
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface MapTooltipProps {
|
|
name: string;
|
|
value: string;
|
|
label: string;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
function MapTooltip({ name, value, label, x, y }: MapTooltipProps) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"absolute z-50 p-2 translate-x-2 translate-y-2",
|
|
"pointer-events-none rounded-sm",
|
|
"bg-white dark:bg-popover shadow border border-border"
|
|
)}
|
|
style={{
|
|
left: x,
|
|
top: y
|
|
}}
|
|
>
|
|
<div className="font-semibold">{name}</div>
|
|
<strong className="text-primary">{value}</strong> {label}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const width = 475;
|
|
const height = 335;
|
|
const sharedCountryClass = cn("transition-colors");
|
|
|
|
const colorScales: Record<string, [string, string]> = {
|
|
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<WorldJsonCountryData> {
|
|
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<string, string, never>,
|
|
dataByCountryCode: Map<string, CountryData>
|
|
) {
|
|
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";
|
|
});
|
|
}
|