/** * scripts/build-region-centroids.mjs — build the precomputed region-centroid * manifest: `public/region-centroids.json` = `{ "": { "lat": , "lon": , "spread": }, ... }`. * * For every sub-region (region key, see `public/region-keys.json`) this records * the point the LLM's map markers (v0.27, #44) will be anchored to: the * **centroid of the region's largest-area part** (a MultiPolygon's biggest * island/lake-piece rather than an area-weighted average of all parts), plus * a `spread` value ≈ 0.1 × the smallest bbox dimension of that part (in * degrees) — the engine scatters markers of one region within ±(spread/2) * around the centroid using a deterministic per-marker offset, so several * markers of the same region do not stack on one pixel. * * Method — plain turf on the pre-generated geo file (turf is allowed in .mjs * scripts; it is deliberately kept OUT of the TS typecheck program, see the * v0.17 note). For each feature we (1) split a MultiPolygon into its parts * (a plain Polygon is its own single part), (2) pick the part with the * greatest `@turf/area`, (3) take `@turf/centroid` of that part and * `@turf/bbox` of that part, and (4) round everything to 6 decimal places * (≈ 0.1 m at this scale — far below the map's visual precision). * * Output contract (asserted by scripts/region-centroids.test.ts): * - one entry for **every** region key, keyed identically to * `public/region-keys.json` (same key set, 1,800 entries); * - each centroid lies inside its region's (largest part's) bbox; * - `spread` is a non-negative number of degrees (0 for degenerate parts); * - values are rounded to at most 6 decimal places. * * Deterministic + idempotent: the input files are fixed, turf is a pure * geometric computation, the output keeps the geo file's (stable) feature * order and rounds to fixed precision — so a re-run rewrites a byte-identical * file (the test re-runs this script and compares bytes). * * Usage: `node scripts/build-region-centroids.mjs` (or `npm run build:region-centroids`). */ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import area from '@turf/area'; import bbox from '@turf/bbox'; import centroid from '@turf/centroid'; import { feature } from '@turf/helpers'; const here = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(here, '..'); const round6 = (v) => Math.round(v * 1e6) / 1e6; // --- load the geo + region-key manifest (plain parse; keys join feature->key) -- const geo = JSON.parse(readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8')); const idToKey = JSON.parse(readFileSync(path.join(root, 'public', 'region-keys.json'), 'utf8')); /** * The polygon parts of a geometry: a MultiPolygon's individual polygons, a * plain Polygon as its own single part, anything else as an empty list (the * caller falls back to the whole-feature centroid). */ function partsOf(geometry) { if (geometry?.type === 'MultiPolygon') return geometry.coordinates ?? []; if (geometry?.type === 'Polygon') return [geometry.coordinates]; return []; } const manifest = {}; let skipped = 0; for (const f of geo.features ?? []) { const id = f.id == null ? '' : String(f.id); const key = idToKey[id]; if (!key) continue; // a geo feature with no manifest entry is skipped const parts = partsOf(f.geometry); if (parts.length === 0) { // Degenerate geometry (a point/line region): anchor at the feature's own // centroid. Can't happen with the current 1,800-polygon dataset, but the // fallback keeps the manifest complete if the generator ever changes. skipped += 1; const c = centroid(feature(f.geometry)); const b = bbox(feature(f.geometry)); manifest[key] = { lat: round6(c.geometry.coordinates[1]), lon: round6(c.geometry.coordinates[0]), spread: round6(Math.min(b[2] - b[0], b[3] - b[1]) * 0.1), }; continue; } // Pick the part with the greatest area (km²) and anchor to IT, so a // multi-part region's markers cluster on its main mass, not between parts. let best = 0; let bestArea = -1; for (let i = 0; i < parts.length; i++) { const a = area(feature({ type: 'Polygon', coordinates: parts[i] })); if (a > bestArea) { bestArea = a; best = i; } } const part = feature({ type: 'Polygon', coordinates: parts[best] }); const c = centroid(part); const b = bbox(part); manifest[key] = { lat: round6(c.geometry.coordinates[1]), lon: round6(c.geometry.coordinates[0]), spread: round6(Math.min(b[2] - b[0], b[3] - b[1]) * 0.1), }; } // --- hard assertions (the test re-checks these against the committed file) --- const expectedKeys = Object.values(idToKey); if (Object.keys(manifest).length !== expectedKeys.length) { throw new Error( `expected ${expectedKeys.length} centroid entries (one per region key), got ${Object.keys(manifest).length}`, ); } for (const k of expectedKeys) { if (!(k in manifest)) throw new Error(`missing centroid for region key ${JSON.stringify(k)}`); } for (const [k, v] of Object.entries(manifest)) { if (!Number.isFinite(v.lat) || !Number.isFinite(v.lon) || !Number.isFinite(v.spread)) { throw new Error(`non-finite centroid for ${k}: ${JSON.stringify(v)}`); } if (v.spread < 0) throw new Error(`negative spread for ${k}: ${v.spread}`); } // --- write idempotently (stable order = insertion order = geo file order) --- writeFileSync(path.join(root, 'public', 'region-centroids.json'), JSON.stringify(manifest, null, 2) + '\n'); // --- summary ------------------------------------------------------------------ const spreads = Object.values(manifest).map((v) => v.spread); console.log( `[build-region-centroids] wrote public/region-centroids.json — ${Object.keys(manifest).length} region keys` + (skipped ? ` (${skipped} non-polygon fallbacks)` : '') + `, spread min/max ${Math.min(...spreads)} / ${Math.max(...spreads)} deg`, );