// Build a heavily-simplified land-polygon dataset for the hero globe. // // Input: Natural Earth 110m countries TopoJSON // (https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json) // // Output: src/lib/landPolygons.ts — an array of rings (each ring is a flat // array [lng0, lat0, lng1, lat1, ...]) representing landmasses. // // Run with: node scripts/build-land-polygons.mjs import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '..'); const INPUT = process.argv[2] ?? '/tmp/opencode/countries-110m.json'; const OUTPUT = path.join(REPO_ROOT, 'src/lib/landPolygons.ts'); const topo = JSON.parse(fs.readFileSync(INPUT, 'utf8')); const layer = topo.objects.countries; const transform = topo.transform; /** Decode a topojson arc into absolute [lng, lat] pairs. */ function decodeArc(arc) { const out = []; let x = 0; let y = 0; for (const [dx, dy] of arc) { x += dx; y += dy; out.push([ x * transform.scale[0] + transform.translate[0], y * transform.scale[1] + transform.translate[1], ]); } return out; } const arcs = topo.arcs.map(decodeArc); /** Resolve a topojson arc index (negative means reversed) into points. */ function resolveArc(i) { if (i < 0) { const arc = arcs[~i]; return arc.slice().reverse(); } return arcs[i]; } /** Build a ring from an array of arc indices. */ function buildRing(arcIndices) { const ring = []; for (let i = 0; i < arcIndices.length; i++) { const seg = resolveArc(arcIndices[i]); // Skip the duplicated joining point between consecutive arcs. if (i === 0) ring.push(...seg); else ring.push(...seg.slice(1)); } return ring; } const rings = []; for (const feature of layer.geometries) { if (feature.type === 'Polygon') { for (const arcIndices of feature.arcs) { rings.push(buildRing(arcIndices)); } } else if (feature.type === 'MultiPolygon') { for (const polygon of feature.arcs) { for (const arcIndices of polygon) { rings.push(buildRing(arcIndices)); } } } } // Use the full Natural Earth 110m resolution. We skip Douglas-Peucker // entirely so coastlines look organic at hero scale rather than blocky. // We still quantize to 0.1° (well below the rendered pixel size on a // ~600 px globe) which is a free ~30 % byte saving with no visible loss. const MIN_VERTS = 3; const simplifiedRings = []; for (const ring of rings) { if (ring.length < MIN_VERTS) continue; const flat = []; for (const [lng, lat] of ring) { flat.push(Math.round(lng * 10) / 10, Math.round(lat * 10) / 10); } simplifiedRings.push(flat); } const totalCoords = simplifiedRings.reduce((sum, r) => sum + r.length / 2, 0); const banner = `/** * Simplified land polygons for the hero globe. * * Generated from Natural Earth 110m country boundaries via * \`scripts/build-land-polygons.mjs\`. Each entry is a flat \`[lng, lat, lng, * lat, ...]\` ring. We keep the data inline (rather than fetching a TopoJSON * blob at runtime) so the hero renders instantly, with no network jitter and * no extra runtime dependency. * * Do not edit by hand — re-run the script to regenerate. */ `; const body = `export const LAND_RINGS: readonly (readonly number[])[] = [\n${ simplifiedRings.map((r) => ` [${r.join(',')}],`).join('\n') }\n];\n`; fs.writeFileSync(OUTPUT, banner + body); console.log( `Wrote ${OUTPUT}`, `\n rings: ${simplifiedRings.length}`, `\n vertices: ${totalCoords}`, `\n bytes: ${fs.statSync(OUTPUT).size}`, );