Skip to content

Commit ea884d5

Browse files
feat: custom worldmap component
Signed-off-by: Henry Gressmann <mail@henrygressmann.de>
1 parent 8bdfc46 commit ea884d5

File tree

8 files changed

+207
-110
lines changed

8 files changed

+207
-110
lines changed

data/licenses-npm.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

web/astro.config.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export default defineConfig({
1717
vite: {
1818
server: { proxy },
1919
preview: { proxy },
20-
// css: { transformer: "lightningcss" },
2120
plugins: [
2221
license({
2322
thirdParty: {
@@ -27,8 +26,7 @@ export default defineConfig({
2726
template: (dependencies) => JSON.stringify(dependencies),
2827
},
2928
},
30-
// biome-ignore lint/suspicious/noExplicitAny: type is correct
31-
}) as any,
29+
}),
3230
],
3331
},
3432
integrations: [react()],

web/bun.lockb

-5.75 KB
Binary file not shown.

web/package.json

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,36 +12,40 @@
1212
"@astrojs/react": "^3.6.3",
1313
"@explodingcamera/css": "^0.0.4",
1414
"@fontsource-variable/outfit": "^5.1.0",
15-
"@icons-pack/react-simple-icons": "^10.1.0",
15+
"@icons-pack/react-simple-icons": "^10.2.0",
1616
"@nivo/line": "^0.88.0",
1717
"@picocss/pico": "^2.0.6",
1818
"@radix-ui/react-accordion": "^1.2.1",
1919
"@radix-ui/react-dialog": "^1.1.2",
2020
"@radix-ui/react-tabs": "^1.1.1",
21-
"@tanstack/react-query": "^5.61.0",
21+
"@tanstack/react-query": "^5.61.3",
2222
"@uidotdev/usehooks": "^2.4.1",
23+
"d3-geo": "^3.1.1",
24+
"d3-selection": "^3.0.0",
25+
"d3-zoom": "^3.0.0",
2326
"date-fns": "^4.1.0",
2427
"fets": "^0.8.4",
2528
"fuzzysort": "^3.1.0",
26-
"lightningcss": "^1.28.1",
29+
"geojson": "^0.5.0",
2730
"little-date": "^1.0.0",
28-
"lucide-react": "^0.460.0",
31+
"lucide-react": "0.461.0",
2932
"react": "^18.3.1",
3033
"react-dom": "^18.3.1",
31-
"react-simple-maps": "^3.0.0",
3234
"react-tag-autocomplete": "^7.4.0",
33-
"react-tooltip": "^5.28.0"
35+
"react-tooltip": "^5.28.0",
36+
"topojson-client": "^3.1.0"
3437
},
3538
"devDependencies": {
3639
"@biomejs/biome": "1.9.4",
3740
"@types/bun": "^1.1.13",
3841
"@types/react": "^18.3.12",
3942
"@types/react-dom": "^18.3.1",
40-
"@types/react-simple-maps": "^3.0.6",
43+
"@types/topojson-client": "^3.1.5",
44+
"@types/topojson-specification": "^1.0.5",
4145
"astro": "^4.16.14",
4246
"rollup-plugin-license": "^3.5.3",
4347
"typescript": "^5.7.2"
4448
},
45-
"trustedDependencies": ["@biomejs/biome", "esbuild", "sharp"],
46-
"packageManager": "bun@1.1.36"
49+
"packageManager": "bun@1.1.36",
50+
"trustedDependencies": ["@biomejs/biome", "esbuild", "sharp"]
4751
}

web/src/components/project.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import styles from "./project.module.css";
2-
import _map from "./worldmap.module.css";
2+
import _map from "./worldmap/map.module.css";
33

44
import { useLocalStorage } from "@uidotdev/usehooks";
55
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
@@ -16,7 +16,7 @@ import { SelectMetrics } from "./project/metric";
1616
import { ProjectHeader } from "./project/project";
1717
import { SelectRange } from "./project/range";
1818

19-
const WorldMap = lazy(() => import("./worldmap").then((module) => ({ default: module.WorldMap })));
19+
const Worldmap = lazy(() => import("./worldmap").then((module) => ({ default: module.Worldmap })));
2020

2121
export type ProjectQuery = {
2222
project: ProjectResponse;
@@ -133,7 +133,7 @@ const GeoCard = ({
133133
<article className={cls(cardStyles, styles.geoCard)} data-full-width="true">
134134
<div className={styles.geoMap}>
135135
<Suspense fallback={null}>
136-
<WorldMap data={data ?? []} metric={query.metric} />
136+
<Worldmap data={data ?? []} metric={query.metric} />
137137
</Suspense>
138138
</div>
139139
<div className={styles.geoTable}>

web/src/components/worldmap.tsx

Lines changed: 0 additions & 94 deletions
This file was deleted.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import styles from "./map.module.css";
2+
3+
import { RotateCcwIcon } from "lucide-react";
4+
import { useEffect, useMemo, useRef, useState } from "react";
5+
import { Tooltip } from "react-tooltip";
6+
7+
import { type GeoProjection, geoMercator, geoPath } from "d3-geo";
8+
import { select } from "d3-selection";
9+
import { type ZoomBehavior, zoom as d3Zoom, zoomIdentity } from "d3-zoom";
10+
11+
import type { Feature, Geometry } from "geojson";
12+
import * as topo from "topojson-client";
13+
import type { GeometryCollection, Topology } from "topojson-specification";
14+
15+
import geo from "../../../../data/geo.json";
16+
import { type DimensionTableRow, type Metric, metricNames } from "../../api";
17+
import { cls, formatMetricVal } from "../../utils";
18+
19+
const features = topo.feature(geo as unknown as Topology, geo.objects.geo as GeometryCollection).features as Feature<
20+
Geometry,
21+
{
22+
name: string;
23+
iso: string;
24+
}
25+
>[];
26+
27+
type Location = {
28+
name: string;
29+
iso: string;
30+
};
31+
32+
export const Worldmap = ({
33+
metric,
34+
data,
35+
}: {
36+
metric: Metric;
37+
data?: DimensionTableRow[];
38+
}) => {
39+
const svgRef = useRef<SVGSVGElement | null>(null);
40+
const containerRef = useRef<HTMLDivElement | null>(null);
41+
const [dimensions, setDimensions] = useState({ width: 800, height: 400 });
42+
const [moved, setMoved] = useState(false);
43+
44+
const [currentLocation, setCurrentLocation] = useState<Location | null>(null);
45+
const biggest = useMemo(() => data?.reduce((a, b) => (a.value > b.value ? a : b), data[0]), [data]);
46+
const countries = useMemo(() => {
47+
const countries = new Map<string, number>();
48+
for (const row of data ?? []) {
49+
countries.set(row.dimensionValue, row.value);
50+
}
51+
return countries;
52+
}, [data]);
53+
54+
const projection = useRef<GeoProjection>();
55+
if (!projection.current) {
56+
projection.current = geoMercator()
57+
.scale((dimensions.width / dimensions.width) * 125)
58+
// .scale((dimensions.width / 800) * 170)
59+
// .translate([dimensions.width / 2, dimensions.height / 2])
60+
.center([45, 45]);
61+
}
62+
const pathGenerator = geoPath(projection.current);
63+
64+
const zoomBehavior = useRef<ZoomBehavior<SVGSVGElement, unknown>>();
65+
if (!zoomBehavior.current) {
66+
zoomBehavior.current = d3Zoom<SVGSVGElement, unknown>()
67+
.scaleExtent([1, 8]) // Min and max zoom levels
68+
.on("zoom", (event) => {
69+
select(svgRef.current).select("g").attr("transform", event.transform);
70+
setMoved(true);
71+
});
72+
}
73+
74+
useEffect(() => {
75+
if (!svgRef.current || !zoomBehavior.current) return;
76+
77+
// Setup zoom behavior
78+
select(svgRef.current).call(zoomBehavior.current);
79+
80+
const resizeObserver = new ResizeObserver((entries) => {
81+
if (entries[0].contentRect) {
82+
const { width, height } = entries[0].contentRect;
83+
setDimensions({ width, height });
84+
}
85+
});
86+
87+
if (containerRef.current) {
88+
resizeObserver.observe(containerRef.current);
89+
}
90+
91+
return () => {
92+
if (containerRef.current) {
93+
resizeObserver.unobserve(containerRef.current);
94+
}
95+
};
96+
}, []);
97+
98+
return (
99+
<div ref={containerRef} className={styles.worldmap} data-tooltip-float={true} data-tooltip-id="map">
100+
<button
101+
type="button"
102+
className={cls(styles.reset, moved && styles.moved)}
103+
onClick={() => {
104+
setCurrentLocation(null);
105+
106+
if (zoomBehavior.current && svgRef.current) {
107+
select(svgRef.current).call(zoomBehavior.current.transform, zoomIdentity);
108+
setMoved(false);
109+
}
110+
}}
111+
>
112+
<RotateCcwIcon size={18} />
113+
</button>
114+
115+
<svg ref={svgRef} style={{ display: "block" }} viewBox={"0 0 800 500"}>
116+
<title>WoldMap</title>
117+
<g>
118+
{features.map((feature, index) => (
119+
<Landmass
120+
key={index}
121+
feature={feature}
122+
pathGenerator={pathGenerator}
123+
countries={countries}
124+
biggest={biggest}
125+
onSetLocation={setCurrentLocation}
126+
/>
127+
))}
128+
</g>
129+
</svg>
130+
<Tooltip id="map" className={styles.tooltipContainer} classNameArrow={styles.reset} disableStyleInjection>
131+
{currentLocation && (
132+
<div className={styles.tooltip} data-theme="dark">
133+
<h2>{metricNames[metric]}</h2>
134+
<h3>
135+
{currentLocation.name} <span>{formatMetricVal(countries.get(currentLocation.iso) ?? 0, metric)}</span>
136+
</h3>
137+
</div>
138+
)}
139+
</Tooltip>
140+
</div>
141+
);
142+
};
143+
144+
const Landmass = ({
145+
feature,
146+
pathGenerator,
147+
countries,
148+
biggest,
149+
onSetLocation,
150+
}: {
151+
feature: Feature<Geometry, { name: string; iso: string }>;
152+
pathGenerator: ReturnType<typeof geoPath>;
153+
countries: Map<string, number>;
154+
biggest?: DimensionTableRow;
155+
onSetLocation: (location: Location | null) => void;
156+
}) => {
157+
const path = useMemo(() => pathGenerator(feature), [pathGenerator, feature]);
158+
const percent = (countries.get(feature.properties.iso) ?? 0) / (biggest?.value ?? 100);
159+
160+
return (
161+
<path
162+
d={path || ""}
163+
className={styles.geo}
164+
style={{ "--percent": percent } as React.CSSProperties}
165+
onMouseEnter={() => onSetLocation({ name: feature.properties.name, iso: feature.properties.iso })}
166+
onMouseLeave={() => onSetLocation(null)}
167+
/>
168+
);
169+
};

web/src/components/worldmap.module.css renamed to web/src/components/worldmap/map.module.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,26 @@
33
background: none;
44
}
55

6+
button.reset {
7+
all: unset;
8+
cursor: pointer;
9+
transition: opacity 0.3s;
10+
display: flex;
11+
position: absolute;
12+
top: 0.7rem;
13+
left: 0.7rem;
14+
pointer-events: none;
15+
opacity: 0;
16+
17+
&.moved {
18+
pointer-events: auto;
19+
opacity: 0.4;
20+
&:hover {
21+
opacity: 1;
22+
}
23+
}
24+
}
25+
626
.worldmap {
727
display: flex;
828
flex: 1;

0 commit comments

Comments
 (0)