Files
5chan/scripts/generate-world-map-dots.mjs
T

145 lines
5.4 KiB
JavaScript
Raw Normal View History

// Generates src/data/world-map-dots.ts: a compact dotted-world-map backdrop for
// the P2P stats panel, rasterized from Natural Earth 1:110m land polygons
// (public domain). Run with `yarn map:generate`.
//
// The map is decorative ("approximate locations"), so we only need a recognizable
// land/sea mask. We rasterize the real land polygons onto a regular lon/lat grid
// and pack the result into a 1-bit-per-cell bitmap (base64). At runtime the panel
// expands set bits into square dots using the same equirectangular projection the
// peer markers use, so dots and markers stay aligned without shipping a geo lib.
import { Buffer } from 'node:buffer';
import { writeFileSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, '..');
const OUTPUT_PATH = resolve(REPO_ROOT, 'src/data/world-map-dots.ts');
// Natural Earth 1:110m land (public domain). Override with WORLD_MAP_GEOJSON_URL.
const GEOJSON_URL =
process.env.WORLD_MAP_GEOJSON_URL ??
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_land.geojson';
// Equirectangular grid. STEP is the cell size in degrees; smaller = finer/more
// dots. The latitude band excludes Antarctica and trims the very top of the map.
const STEP = 2;
const LON_MIN = -180;
const LAT_MAX = 84;
const LAT_MIN = -56;
const COLS = Math.round((180 - LON_MIN) / STEP);
const ROWS = Math.round((LAT_MAX - LAT_MIN) / STEP);
const log = (message) => process.stdout.write(`[generate-world-map-dots] ${message}\n`);
const fetchGeoJson = async (url) => {
log(`fetching ${url}`);
const response = await fetch(url);
if (!response.ok) throw new Error(`failed to fetch land GeoJSON: ${response.status} ${response.statusText}`);
return response.json();
};
// Flatten Polygon/MultiPolygon features into rings plus a bounding box for a fast
// reject before the per-edge ray cast.
const collectPolygons = (geojson) => {
const polygons = [];
for (const feature of geojson.features ?? []) {
const geometry = feature.geometry;
if (!geometry) continue;
const coordinateSets =
geometry.type === 'Polygon' ? [geometry.coordinates] : geometry.type === 'MultiPolygon' ? geometry.coordinates : [];
for (const rings of coordinateSets) {
let minLon = Infinity;
let minLat = Infinity;
let maxLon = -Infinity;
let maxLat = -Infinity;
for (const ring of rings) {
for (const [lon, lat] of ring) {
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
}
}
polygons.push({ bbox: [minLon, minLat, maxLon, maxLat], rings });
}
}
return polygons;
};
// Even-odd ray casting across every ring of a polygon, so interior holes (lakes)
// correctly read as sea. Natural Earth cuts geometry at the antimeridian, so no
// polygon wraps past +/-180 and a plain lon-space ray cast is sound.
const isInsidePolygon = (lon, lat, rings) => {
let inside = false;
for (const ring of rings) {
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0];
const yi = ring[i][1];
const xj = ring[j][0];
const yj = ring[j][1];
if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
}
}
return inside;
};
const isLand = (lon, lat, polygons) => {
for (const { bbox, rings } of polygons) {
if (lon < bbox[0] || lon > bbox[2] || lat < bbox[1] || lat > bbox[3]) continue;
if (isInsidePolygon(lon, lat, rings)) return true;
}
return false;
};
const rasterize = (polygons) => {
const bits = new Uint8Array(Math.ceil((COLS * ROWS) / 8));
let landCount = 0;
for (let row = 0; row < ROWS; row++) {
const lat = LAT_MAX - (row + 0.5) * STEP;
for (let col = 0; col < COLS; col++) {
const lon = LON_MIN + (col + 0.5) * STEP;
if (isLand(lon, lat, polygons)) {
const index = row * COLS + col;
bits[index >> 3] |= 1 << (7 - (index & 7));
landCount++;
}
}
}
return { bits, landCount };
};
const renderModule = (base64) =>
`// GENERATED FILE — do not edit by hand.
// Source: Natural Earth 1:110m land (public domain), rasterized by
// scripts/generate-world-map-dots.mjs. Run \`yarn map:generate\` to refresh.
//
// Row-major land/sea grid for the P2P stats world map. \`bitmap\` is a base64
// 1-bit-per-cell mask (MSB first, 1 = land). Cell (col,row) centers on
// lon = lonMin + (col + 0.5) * step, lat = latMax - (row + 0.5) * step.
export const WORLD_MAP_DOTS = {
step: ${STEP},
lonMin: ${LON_MIN},
latMax: ${LAT_MAX},
cols: ${COLS},
rows: ${ROWS},
bitmap: '${base64}',
} as const;
`;
const main = async () => {
const geojson = await fetchGeoJson(GEOJSON_URL);
const polygons = collectPolygons(geojson);
log(`rasterizing ${polygons.length} land polygons onto a ${COLS}x${ROWS} grid (step ${STEP}°)`);
const { bits, landCount } = rasterize(polygons);
const base64 = Buffer.from(bits).toString('base64');
writeFileSync(OUTPUT_PATH, renderModule(base64));
log(`wrote ${relative(REPO_ROOT, OUTPUT_PATH)} (${landCount} land cells, ${base64.length} base64 chars)`);
};
main().catch((error) => {
process.stderr.write(`[generate-world-map-dots] ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});