fs2026-Frontend-Semesterarbeit/tools/convert-geojson-outlines.js
2026-06-01 09:12:35 +02:00

380 lines
9.5 KiB
JavaScript

#!/usr/bin/env node
import { mkdir, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import shapefile from "shapefile";
const DEFAULT_TOLERANCE = 1.5;
const SHAPEFILE_BASENAME = "ne_10m_admin_0_countries";
const GAME_COUNTRIES = [
"Switzerland",
"Norway",
"Italy",
"Japan",
"Brazil",
"Australia",
"France",
"India",
"Canada",
"Germany",
];
function usage() {
return `Usage:
node convert-geojson-outlines.js <input-dir> <output-dir> [options]
Options:
--tolerance <number> Douglas-Peucker tolerance in 0..100 units. Default: 1.5
--max-points <number> Simplify each ring until it has at most this many points.
--include-holes Include inner rings from Polygon/MultiPolygon geometries.
--pretty Write indented JSON instead of compact JSON.
`;
}
function parseArgs(argv) {
const positionals = [];
const options = {
tolerance: DEFAULT_TOLERANCE,
maxPoints: null,
includeHoles: false,
pretty: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--tolerance") {
options.tolerance = Number(argv[++i]);
} else if (arg === "--max-points") {
options.maxPoints = Number(argv[++i]);
} else if (arg === "--include-holes") {
options.includeHoles = true;
} else if (arg === "--pretty") {
options.pretty = true;
} else if (arg === "--help" || arg === "-h") {
console.log(usage());
process.exit(0);
} else {
positionals.push(arg);
}
}
if (positionals.length !== 2) {
throw new Error("Expected exactly <input-dir> and <output-dir>.");
}
if (!Number.isFinite(options.tolerance) || options.tolerance < 0) {
throw new Error("--tolerance must be a number greater than or equal to 0.");
}
if (
options.maxPoints !== null &&
(!Number.isInteger(options.maxPoints) || options.maxPoints < 4)
) {
throw new Error("--max-points must be an integer greater than or equal to 4.");
}
return {
inputDir: positionals[0],
outputDir: positionals[1],
options,
};
}
function stripNulls(value) {
return typeof value === "string" ? value.replaceAll("\0", "").trim() : value;
}
function cleanProperties(properties) {
return Object.fromEntries(
Object.entries(properties || {}).map(([key, value]) => [key, stripNulls(value)]),
);
}
function getCountryName(properties) {
return (
properties.NAME_EN ||
properties.NAME_LONG ||
properties.NAME ||
properties.ADMIN ||
properties.SOVEREIGNT ||
""
);
}
function slugify(value) {
return value
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
}
function extractRings(geometry, includeHoles) {
if (geometry.type === "Polygon") {
return includeHoles
? geometry.coordinates
: [geometry.coordinates?.[0]].filter(Boolean);
}
if (geometry.type === "MultiPolygon") {
return geometry.coordinates.flatMap((polygon) =>
includeHoles ? polygon : [polygon?.[0]].filter(Boolean),
);
}
return [];
}
function isValidCoordinate(coord) {
return (
Array.isArray(coord) &&
coord.length >= 2 &&
Number.isFinite(coord[0]) &&
Number.isFinite(coord[1])
);
}
function cleanRing(ring) {
if (!Array.isArray(ring)) return [];
return ring.filter(isValidCoordinate).map(([lon, lat]) => [lon, lat]);
}
function getBounds(rings) {
let minLon = Infinity;
let maxLon = -Infinity;
let minLat = Infinity;
let maxLat = -Infinity;
for (const ring of rings) {
for (const [lon, lat] of ring) {
minLon = Math.min(minLon, lon);
maxLon = Math.max(maxLon, lon);
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
}
}
if (![minLon, maxLon, minLat, maxLat].every(Number.isFinite)) {
throw new Error("Could not calculate bounds from geometry.");
}
return { minLon, maxLon, minLat, maxLat };
}
function roundCoord(value) {
return Math.round(value * 100) / 100;
}
function normalizePoint([lon, lat], bounds) {
const lonSpan = bounds.maxLon - bounds.minLon;
const latSpan = bounds.maxLat - bounds.minLat;
return {
x: roundCoord(lonSpan === 0 ? 50 : ((lon - bounds.minLon) / lonSpan) * 100),
y: roundCoord(
latSpan === 0 ? 50 : (1 - (lat - bounds.minLat) / latSpan) * 100,
),
};
}
function samePoint(a, b) {
return a.x === b.x && a.y === b.y;
}
function closeRing(points) {
if (points.length < 2) return points;
const first = points[0];
const last = points[points.length - 1];
return samePoint(first, last) ? points : [...points, { ...first }];
}
function openRing(points) {
if (points.length < 2) return points;
const first = points[0];
const last = points[points.length - 1];
return samePoint(first, last) ? points.slice(0, -1) : points;
}
function squaredDistanceToSegment(point, start, end) {
const dx = end.x - start.x;
const dy = end.y - start.y;
if (dx === 0 && dy === 0) {
return (point.x - start.x) ** 2 + (point.y - start.y) ** 2;
}
const t = Math.max(
0,
Math.min(
1,
((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy),
),
);
const projection = {
x: start.x + t * dx,
y: start.y + t * dy,
};
return (point.x - projection.x) ** 2 + (point.y - projection.y) ** 2;
}
function douglasPeucker(points, tolerance) {
if (points.length <= 2 || tolerance === 0) return points;
let maxDistance = 0;
let splitIndex = 0;
const lastIndex = points.length - 1;
for (let i = 1; i < lastIndex; i++) {
const distance = squaredDistanceToSegment(points[i], points[0], points[lastIndex]);
if (distance > maxDistance) {
maxDistance = distance;
splitIndex = i;
}
}
if (maxDistance <= tolerance * tolerance) {
return [points[0], points[lastIndex]];
}
const left = douglasPeucker(points.slice(0, splitIndex + 1), tolerance);
const right = douglasPeucker(points.slice(splitIndex), tolerance);
return [...left.slice(0, -1), ...right];
}
function simplifyRing(points, initialTolerance, maxPoints) {
let tolerance = initialTolerance;
let simplified = closeRing(douglasPeucker(openRing(points), tolerance));
let attempts = 0;
while (maxPoints && simplified.length > maxPoints && attempts < 30) {
tolerance = tolerance === 0 ? 0.5 : tolerance * 1.25;
simplified = closeRing(douglasPeucker(openRing(points), tolerance));
attempts++;
}
return simplified;
}
function normalizeGeometry(geometry, options) {
if (!geometry || !["Polygon", "MultiPolygon"].includes(geometry.type)) {
throw new Error("No Polygon or MultiPolygon geometry found.");
}
const rings = extractRings(geometry, options.includeHoles)
.map(cleanRing)
.filter((ring) => ring.length >= 4);
if (!rings.length) {
throw new Error("No valid polygon rings found.");
}
const bounds = getBounds(rings);
const normalizedRings = rings
.map((ring) => ring.map((point) => normalizePoint(point, bounds)))
.map(closeRing)
.map((ring) => simplifyRing(ring, options.tolerance, options.maxPoints))
.filter((ring) => ring.length >= 4);
if (!normalizedRings.length) {
throw new Error("No valid rings remained after simplification.");
}
return {
type: geometry.type,
bounds,
rings: normalizedRings,
};
}
async function findShapefile(inputDir) {
const entries = await readdir(inputDir, { withFileTypes: true });
const shpFiles = entries
.filter((entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === ".shp")
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b));
const preferred = shpFiles.find(
(fileName) => path.basename(fileName, ".shp") === SHAPEFILE_BASENAME,
);
const fileName = preferred || shpFiles[0];
if (!fileName) {
throw new Error(`No .shp file found in ${inputDir}.`);
}
return path.join(inputDir, fileName);
}
async function readTargetFeatures(shpPath, targets) {
const source = await shapefile.open(shpPath, undefined, { encoding: "utf-8" });
const features = new Map();
while (true) {
const result = await source.read();
if (result.done) break;
const feature = result.value;
const properties = cleanProperties(feature.properties);
const countryName = getCountryName(properties);
if (targets.has(countryName)) {
features.set(countryName, {
geometry: feature.geometry,
properties,
});
}
}
return features;
}
async function writeCountryOutline(outputDir, countryName, feature, sourceFile, options) {
const output = {
source: sourceFile,
country: {
name: countryName,
isoA2: feature.properties.ISO_A2,
isoA3: feature.properties.ISO_A3,
continent: feature.properties.CONTINENT,
subregion: feature.properties.SUBREGION,
},
outline: normalizeGeometry(feature.geometry, options),
};
const fileName = `${slugify(countryName)}.json`;
const json = JSON.stringify(output, null, options.pretty ? 2 : 0);
await writeFile(path.join(outputDir, fileName), `${json}\n`, "utf8");
console.log(`Converted ${countryName} -> ${fileName}`);
}
async function main() {
const { inputDir, outputDir, options } = parseArgs(process.argv.slice(2));
const shpPath = await findShapefile(inputDir);
const sourceFile = path.basename(shpPath);
const targets = new Set(GAME_COUNTRIES);
const features = await readTargetFeatures(shpPath, targets);
const missing = GAME_COUNTRIES.filter((countryName) => !features.has(countryName));
if (missing.length) {
throw new Error(`Missing country feature(s): ${missing.join(", ")}`);
}
await mkdir(outputDir, { recursive: true });
for (const countryName of GAME_COUNTRIES) {
await writeCountryOutline(
outputDir,
countryName,
features.get(countryName),
sourceFile,
options,
);
}
console.log(`Done. Converted ${GAME_COUNTRIES.length} country outline(s).`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
console.error(usage());
process.exit(1);
});