mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-02 00:36:38 +00:00
✨ world map
This commit is contained in:
281
src/components/WorldMap.tsx
Normal file
281
src/components/WorldMap.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* 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
|
||||
).on("click", (_event, countryPath) => {
|
||||
console.log({
|
||||
_event,
|
||||
countryPath
|
||||
});
|
||||
// onCountryClick(countryPath as unknown as WorldJsonCountryData);
|
||||
});
|
||||
}
|
||||
}, [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-[#dae1e7]",
|
||||
"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";
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user