profileShare

rasmusjy / tasteprint

Read-only snapshot

No repository description.

main default branch 169 files Expires Sep 13, 2026, 9:06 AM
WorldMap.tsx 3,508 bytes
1 import { useMemo } from "react";
2 import { useNavigate } from "react-router-dom";
3 import { geoNaturalEarth1, geoPath } from "d3-geo";
4 import { feature } from "topojson-client";
5 import type { FeatureCollection, Geometry } from "geojson";
6 import world from "world-atlas/countries-110m.json";
7 import type { DestinationProgress } from "../types";
8
9 const COUNTRY_NUMERIC: Record<string, string> = {
10 EE: "233",
11 GE: "268",
12 IN: "356",
13 IT: "380",
14 JP: "392",
15 KR: "410",
16 MX: "484",
17 MA: "504",
18 PE: "604",
19 PT: "620",
20 TH: "764",
21 VN: "704"
22 };
23
24 const countries = feature(
25 world as never,
26 world.objects.countries as never
27 ) as unknown as FeatureCollection<Geometry>;
28
29 const projection = geoNaturalEarth1().fitExtent([[10, 12], [950, 478]], countries);
30 const path = geoPath(projection);
31
32 interface WorldMapProps {
33 destinations: DestinationProgress[];
34 interactive?: boolean;
35 className?: string;
36 }
37
38 export function WorldMap({ destinations, interactive = true, className = "" }: WorldMapProps) {
39 const navigate = useNavigate();
40 const byNumericCode = useMemo(() => new Map(
41 destinations.map(item => [COUNTRY_NUMERIC[item.destination.code], item])
42 ), [destinations]);
43
44 return (
45 <div className={`world-map ${className}`}>
46 <svg viewBox="0 0 960 490" role="img" aria-label="Your world food coverage map">
47 <g>
48 {countries.features.map((country, index) => {
49 const item = byNumericCode.get(String(country.id));
50 const d = path(country);
51 if (!d) return null;
52 const active = Boolean(item);
53 const opacity = item ? Math.max(0.3, item.coverage / 100) : 1;
54 return (
55 <path
56 key={`${country.id ?? "country"}-${index}`}
57 d={d}
58 className={active ? "map-country map-country-tracked" : "map-country"}
59 fill={item ? item.destination.accentColor : undefined}
60 fillOpacity={item ? opacity : undefined}
61 tabIndex={interactive && active ? 0 : undefined}
62 role={interactive && active ? "link" : undefined}
63 aria-label={item ? `${item.destination.name}, ${item.coverage}% covered` : undefined}
64 onClick={() => interactive && item && navigate(`/app/destinations/${item.destination.code}`)}
65 onKeyDown={event => {
66 if (interactive && item && (event.key === "Enter" || event.key === " ")) {
67 event.preventDefault();
68 navigate(`/app/destinations/${item.destination.code}`);
69 }
70 }}
71 >
72 {item && <title>{item.destination.name}: {item.coverage}%</title>}
73 </path>
74 );
75 })}
76 </g>
77 {destinations.filter(item => item.coverage > 0).map(item => {
78 const point = projection([item.destination.centerLng, item.destination.centerLat]);
79 if (!point) return null;
80 return (
81 <g key={item.destination.code} className="map-pin" transform={`translate(${point[0]} ${point[1]})`}>
82 <circle r="7" />
83 <circle r="2.4" />
84 </g>
85 );
86 })}
87 </svg>
88 <div className="map-legend" aria-hidden="true">
89 <span><i className="legend-untried" /> Not yet</span>
90 <span><i className="legend-started" /> Started</span>
91 <span><i className="legend-deep" /> Deep taste</span>
92 </div>
93 </div>
94 );
95 }
96