| @@ -1,4 +1,82 @@ |
| 1 | | // Phase 2: choropleth map rendering, gradient coloring |
| 2 | | export function initMap(container, countries, isoLookup) {} |
| 3 | | export function applyGradient(metricKey) {} |
| 4 | | export function setClickHandler(fn) {} |
| 1 | import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm"; |
| 2 | import * as topojson from "https://cdn.jsdelivr.net/npm/topojson-client@3/+esm"; |
| 3 | |
| 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 | } |