// Generate the About page's dot-grid world map (public/images/about/worldmap-dots.svg) // from world-atlas land-110m (Natural Earth, public domain). Equirectangular // projection, Antarctica cropped. Also prints the team-city pin positions as // percentages of the SVG box, to be baked into src/site/pages/about.html. // // node scripts/build-worldmap.mjs // // Deps (devDependencies): world-atlas, topojson-client. import fs from "node:fs"; import { createRequire } from "node:module"; import { feature } from "topojson-client"; const require = createRequire(import.meta.url); const topo = require("world-atlas/land-110m.json"); const land = feature(topo, topo.objects.land); // FeatureCollection of MultiPolygon // Projection window: full longitudes, latitudes 84N..56S (arctic coastlines // intact, Antarctica cut). const LON0 = -180, LON1 = 180, LAT0 = 84, LAT1 = -56; const W = 1000; const H = Math.round(((LAT0 - LAT1) / (LON1 - LON0)) * W); // 361 const px = (lon) => ((lon - LON0) / (LON1 - LON0)) * W; const py = (lat) => ((LAT0 - lat) / (LAT0 - LAT1)) * H; // Unwrap ring longitudes so antimeridian-crossing polygons (Fiji) don't create // a phantom edge across the whole map: keep each vertex within 180deg of the // previous one, letting the ring run past +/-180 instead of jumping. function unwrap(ring) { const out = [ring[0].slice()]; for (let i = 1; i < ring.length; i++) { let x = ring[i][0]; const prev = out[i - 1][0]; while (x - prev > 180) x -= 360; while (x - prev < -180) x += 360; out.push([x, ring[i][1]]); } return out; } const POLYS = []; for (const f of land.features) { const polys = f.geometry.type === "Polygon" ? [f.geometry.coordinates] : f.geometry.coordinates; for (const poly of polys) POLYS.push(poly.map(unwrap)); } // Ray-cast point-in-ring (lon/lat space). Tests lon and lon+/-360 so points // still hit rings that were unwrapped past the antimeridian. function inRing(lon, lat, ring) { let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const [xi, yi] = ring[i], [xj, yj] = ring[j]; if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside; } return inside; } function inRingWrapped(lon, lat, ring) { return inRing(lon, lat, ring) || inRing(lon + 360, lat, ring) || inRing(lon - 360, lat, ring); } function inLand(lon, lat) { for (const poly of POLYS) { if (!inRingWrapped(lon, lat, poly[0])) continue; let inHole = false; for (let h = 1; h < poly.length; h++) if (inRingWrapped(lon, lat, poly[h])) { inHole = true; break; } if (!inHole) return true; } return false; } // Dot grid: ~1.5 deg step -> ~4.2px spacing in the 1000px box (fine pitch; // the map renders at full container width). const STEP = 1.5, R = 1.05; // The 0.07deg jitter keeps sample rows off exact polygon-vertex latitudes, // where the ray cast can mis-count and paint a solid row across the ocean. const EPS = 0.07; let dots = []; for (let lat = LAT1 + STEP / 2 + EPS; lat < LAT0; lat += STEP) { for (let lon = LON0 + STEP / 2 + EPS; lon < LON1; lon += STEP) { if (inLand(lon, lat)) dots.push(``); } } // Full-alpha dots: the page dims the via CSS opacity, so the same file // doubles as a fully-opaque mask for the signal-wave overlay. const svg = ``; fs.writeFileSync("public/images/about/worldmap-dots.svg", svg); console.log(`worldmap-dots.svg ${dots.length} dots ${(svg.length / 1024).toFixed(0)}KB viewBox 0 0 ${W} ${H}`); // Team cities. Prints the pin position (x/y % of the SVG box) and the viewBox // px,py (for authoring arc paths) to bake into src/site/pages/about.html. const CITIES = [ ["Los Angeles", 34.05, -118.24, "America/Los_Angeles"], ["San Francisco", 37.77, -122.42, "America/Los_Angeles"], ["Vancouver", 49.28, -123.12, "America/Vancouver"], ["Vermont", 44.26, -72.58, "America/New_York"], ["South Carolina", 34.00, -81.03, "America/New_York"], ["Toronto", 43.65, -79.38, "America/Toronto"], ["Buenos Aires", -34.60, -58.38, "America/Argentina/Buenos_Aires"], ["Scotland", 55.95, -3.19, "Europe/London"], ["London", 51.51, -0.13, "Europe/London"], ["Amsterdam", 52.37, 4.90, "Europe/Amsterdam"], ["Helsinki", 60.17, 24.94, "Europe/Helsinki"], ["Hong Kong", 22.32, 114.17, "Asia/Hong_Kong"], ["Tokyo", 35.68, 139.65, "Asia/Tokyo"], ["Auckland", -36.85, 174.76, "Pacific/Auckland"], ]; for (const [name, lat, lon, tz] of CITIES) { const x = ((px(lon) / W) * 100).toFixed(1), y = ((py(lat) / H) * 100).toFixed(1); console.log(`${name.padEnd(15)} --x:${x}% --y:${y}% px ${px(lon).toFixed(0)},${py(lat).toFixed(0)} ${tz}`); }