| @@ -1,4 +1,82 @@ |
| 1 | // Phase 2: choropleth map rendering, gradient coloring |
1 | import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm"; |
| 2 | export function initMap(container, countries, isoLookup) {} |
2 | import * as topojson from "https://cdn.jsdelivr.net/npm/topojson-client@3/+esm"; |
| 3 | export function applyGradient(metricKey) {} |
3 | |
| 4 | export function setClickHandler(fn) {} |
4 | const WIDTH = 960; |
| |
5 | const HEIGHT = 500; |
| |
6 | const DEFAULT_FILL = "rgba(36, 171, 72, 0.8)"; |
| |
7 | const NO_DATA_FILL = "#444"; |
| |
8 | |
| |
9 | // Module-level state shared across exported functions |
| |
10 | let svg, mapGroup, projection, pathGen, features; |
| |
11 | let _countries, _isoLookup; |
| |
12 | let _clickHandler = null; |
| |
13 | |
| |
14 | export async function initMap(container, countries, isoLookup) { |
| |
15 | _countries = countries; |
| |
16 | _isoLookup = isoLookup; |
| |
17 | |
| |
18 | svg = d3.select(container) |
| |
19 | .append("svg") |
| |
20 | .attr("viewBox", `0 0 ${WIDTH} ${HEIGHT}`) |
| |
21 | .attr("preserveAspectRatio", "xMidYMid meet") |
| |
22 | .style("width", "100%") |
| |
23 | .style("height", "100%"); |
| |
24 | |
| |
25 | mapGroup = svg.append("g").attr("id", "map-layer"); |
| |
26 | |
| |
27 | const world = await d3.json("./assets/data/world-110m.json"); |
| |
28 | const geojson = topojson.feature(world, world.objects.countries); |
| |
29 | features = geojson.features; |
| |
30 | |
| |
31 | projection = d3.geoNaturalEarth1().fitSize([WIDTH, HEIGHT], geojson); |
| |
32 | pathGen = d3.geoPath().projection(projection); |
| |
33 | |
| |
34 | mapGroup.selectAll("path") |
| |
35 | .data(features) |
| |
36 | .join("path") |
| |
37 | .attr("class", "country") |
| |
38 | .attr("d", pathGen) |
| |
39 | .attr("fill", d => _fillForFeature(d, null, null)) |
| |
40 | .attr("stroke", "#111") |
| |
41 | .attr("stroke-width", 0.5) |
| |
42 | .on("click", (event, d) => { |
| |
43 | if (_clickHandler) _clickHandler(event, d); |
| |
44 | }); |
| |
45 | |
| |
46 | return { svg, mapGroup, projection }; |
| |
47 | } |
| |
48 | |
| |
49 | export function applyGradient(metricKey) { |
| |
50 | const values = Object.values(_countries) |
| |
51 | .map(c => c[metricKey]) |
| |
52 | .filter(v => typeof v === "number" && !isNaN(v)); |
| |
53 | |
| |
54 | const [min, max] = d3.extent(values); |
| |
55 | const colorScale = d3.scaleSequential() |
| |
56 | .domain([min, max]) |
| |
57 | .interpolator(d3.interpolateYlOrRd); |
| |
58 | |
| |
59 | mapGroup.selectAll("path.country") |
| |
60 | .transition() |
| |
61 | .duration(400) |
| |
62 | .attr("fill", d => _fillForFeature(d, metricKey, colorScale)); |
| |
63 | } |
| |
64 | |
| |
65 | export function resetGradient() { |
| |
66 | mapGroup.selectAll("path.country") |
| |
67 | .transition() |
| |
68 | .duration(400) |
| |
69 | .attr("fill", d => _fillForFeature(d, null, null)); |
| |
70 | } |
| |
71 | |
| |
72 | export function setClickHandler(fn) { |
| |
73 | _clickHandler = fn; |
| |
74 | } |
| |
75 | |
| |
76 | function _fillForFeature(d, metricKey, colorScale) { |
| |
77 | const alpha3 = _isoLookup[+d.id]; |
| |
78 | if (!alpha3 || !_countries[alpha3]) return NO_DATA_FILL; |
| |
79 | if (!metricKey) return DEFAULT_FILL; |
| |
80 | const val = _countries[alpha3][metricKey]; |
| |
81 | return typeof val === "number" && !isNaN(val) ? colorScale(val) : NO_DATA_FILL; |
| |
82 | } |