Skip to content

Commit

Permalink
feat: custom worldmap component
Browse files Browse the repository at this point in the history
Signed-off-by: Henry Gressmann <mail@henrygressmann.de>
  • Loading branch information
explodingcamera committed Nov 25, 2024
1 parent 8bdfc46 commit ea884d5
Show file tree
Hide file tree
Showing 8 changed files with 207 additions and 110 deletions.
2 changes: 1 addition & 1 deletion data/licenses-npm.json

Large diffs are not rendered by default.

4 changes: 1 addition & 3 deletions web/astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export default defineConfig({
vite: {
server: { proxy },
preview: { proxy },
// css: { transformer: "lightningcss" },
plugins: [
license({
thirdParty: {
Expand All @@ -27,8 +26,7 @@ export default defineConfig({
template: (dependencies) => JSON.stringify(dependencies),
},
},
// biome-ignore lint/suspicious/noExplicitAny: type is correct
}) as any,
}),
],
},
integrations: [react()],
Expand Down
Binary file modified web/bun.lockb
Binary file not shown.
22 changes: 13 additions & 9 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,36 +12,40 @@
"@astrojs/react": "^3.6.3",
"@explodingcamera/css": "^0.0.4",
"@fontsource-variable/outfit": "^5.1.0",
"@icons-pack/react-simple-icons": "^10.1.0",
"@icons-pack/react-simple-icons": "^10.2.0",
"@nivo/line": "^0.88.0",
"@picocss/pico": "^2.0.6",
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.1",
"@tanstack/react-query": "^5.61.0",
"@tanstack/react-query": "^5.61.3",
"@uidotdev/usehooks": "^2.4.1",
"d3-geo": "^3.1.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"date-fns": "^4.1.0",
"fets": "^0.8.4",
"fuzzysort": "^3.1.0",
"lightningcss": "^1.28.1",
"geojson": "^0.5.0",
"little-date": "^1.0.0",
"lucide-react": "^0.460.0",
"lucide-react": "0.461.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-simple-maps": "^3.0.0",
"react-tag-autocomplete": "^7.4.0",
"react-tooltip": "^5.28.0"
"react-tooltip": "^5.28.0",
"topojson-client": "^3.1.0"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@types/bun": "^1.1.13",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-simple-maps": "^3.0.6",
"@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5",
"astro": "^4.16.14",
"rollup-plugin-license": "^3.5.3",
"typescript": "^5.7.2"
},
"trustedDependencies": ["@biomejs/biome", "esbuild", "sharp"],
"packageManager": "bun@1.1.36"
"packageManager": "bun@1.1.36",
"trustedDependencies": ["@biomejs/biome", "esbuild", "sharp"]
}
6 changes: 3 additions & 3 deletions web/src/components/project.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import styles from "./project.module.css";
import _map from "./worldmap.module.css";
import _map from "./worldmap/map.module.css";

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

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

export type ProjectQuery = {
project: ProjectResponse;
Expand Down Expand Up @@ -133,7 +133,7 @@ const GeoCard = ({
<article className={cls(cardStyles, styles.geoCard)} data-full-width="true">
<div className={styles.geoMap}>
<Suspense fallback={null}>
<WorldMap data={data ?? []} metric={query.metric} />
<Worldmap data={data ?? []} metric={query.metric} />
</Suspense>
</div>
<div className={styles.geoTable}>
Expand Down
94 changes: 0 additions & 94 deletions web/src/components/worldmap.tsx

This file was deleted.

169 changes: 169 additions & 0 deletions web/src/components/worldmap/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import styles from "./map.module.css";

import { RotateCcwIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Tooltip } from "react-tooltip";

import { type GeoProjection, geoMercator, geoPath } from "d3-geo";
import { select } from "d3-selection";
import { type ZoomBehavior, zoom as d3Zoom, zoomIdentity } from "d3-zoom";

import type { Feature, Geometry } from "geojson";
import * as topo from "topojson-client";
import type { GeometryCollection, Topology } from "topojson-specification";

import geo from "../../../../data/geo.json";
import { type DimensionTableRow, type Metric, metricNames } from "../../api";
import { cls, formatMetricVal } from "../../utils";

const features = topo.feature(geo as unknown as Topology, geo.objects.geo as GeometryCollection).features as Feature<
Geometry,
{
name: string;
iso: string;
}
>[];

type Location = {
name: string;
iso: string;
};

export const Worldmap = ({
metric,
data,
}: {
metric: Metric;
data?: DimensionTableRow[];
}) => {
const svgRef = useRef<SVGSVGElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 400 });
const [moved, setMoved] = useState(false);

const [currentLocation, setCurrentLocation] = useState<Location | null>(null);
const biggest = useMemo(() => data?.reduce((a, b) => (a.value > b.value ? a : b), data[0]), [data]);
const countries = useMemo(() => {
const countries = new Map<string, number>();
for (const row of data ?? []) {
countries.set(row.dimensionValue, row.value);
}
return countries;
}, [data]);

const projection = useRef<GeoProjection>();
if (!projection.current) {
projection.current = geoMercator()
.scale((dimensions.width / dimensions.width) * 125)
// .scale((dimensions.width / 800) * 170)
// .translate([dimensions.width / 2, dimensions.height / 2])
.center([45, 45]);
}
const pathGenerator = geoPath(projection.current);

const zoomBehavior = useRef<ZoomBehavior<SVGSVGElement, unknown>>();
if (!zoomBehavior.current) {
zoomBehavior.current = d3Zoom<SVGSVGElement, unknown>()
.scaleExtent([1, 8]) // Min and max zoom levels
.on("zoom", (event) => {
select(svgRef.current).select("g").attr("transform", event.transform);
setMoved(true);
});
}

useEffect(() => {
if (!svgRef.current || !zoomBehavior.current) return;

// Setup zoom behavior
select(svgRef.current).call(zoomBehavior.current);

const resizeObserver = new ResizeObserver((entries) => {
if (entries[0].contentRect) {
const { width, height } = entries[0].contentRect;
setDimensions({ width, height });
}
});

if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}

return () => {
if (containerRef.current) {
resizeObserver.unobserve(containerRef.current);
}
};
}, []);

return (
<div ref={containerRef} className={styles.worldmap} data-tooltip-float={true} data-tooltip-id="map">
<button
type="button"
className={cls(styles.reset, moved && styles.moved)}
onClick={() => {
setCurrentLocation(null);

if (zoomBehavior.current && svgRef.current) {
select(svgRef.current).call(zoomBehavior.current.transform, zoomIdentity);
setMoved(false);
}
}}
>
<RotateCcwIcon size={18} />
</button>

<svg ref={svgRef} style={{ display: "block" }} viewBox={"0 0 800 500"}>
<title>WoldMap</title>
<g>
{features.map((feature, index) => (
<Landmass
key={index}
feature={feature}
pathGenerator={pathGenerator}
countries={countries}
biggest={biggest}
onSetLocation={setCurrentLocation}
/>
))}
</g>
</svg>
<Tooltip id="map" className={styles.tooltipContainer} classNameArrow={styles.reset} disableStyleInjection>
{currentLocation && (
<div className={styles.tooltip} data-theme="dark">
<h2>{metricNames[metric]}</h2>
<h3>
{currentLocation.name} <span>{formatMetricVal(countries.get(currentLocation.iso) ?? 0, metric)}</span>
</h3>
</div>
)}
</Tooltip>
</div>
);
};

const Landmass = ({
feature,
pathGenerator,
countries,
biggest,
onSetLocation,
}: {
feature: Feature<Geometry, { name: string; iso: string }>;
pathGenerator: ReturnType<typeof geoPath>;
countries: Map<string, number>;
biggest?: DimensionTableRow;
onSetLocation: (location: Location | null) => void;
}) => {
const path = useMemo(() => pathGenerator(feature), [pathGenerator, feature]);
const percent = (countries.get(feature.properties.iso) ?? 0) / (biggest?.value ?? 100);

return (
<path
d={path || ""}
className={styles.geo}
style={{ "--percent": percent } as React.CSSProperties}
onMouseEnter={() => onSetLocation({ name: feature.properties.name, iso: feature.properties.iso })}
onMouseLeave={() => onSetLocation(null)}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@
background: none;
}

button.reset {
all: unset;
cursor: pointer;
transition: opacity 0.3s;
display: flex;
position: absolute;
top: 0.7rem;
left: 0.7rem;
pointer-events: none;
opacity: 0;

&.moved {
pointer-events: auto;
opacity: 0.4;
&:hover {
opacity: 1;
}
}
}

.worldmap {
display: flex;
flex: 1;
Expand Down

0 comments on commit ea884d5

Please sign in to comment.