mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
145 lines
5.4 KiB
JavaScript
145 lines
5.4 KiB
JavaScript
// 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;
|
||
|
|
});
|