// 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}`);
}