380 lines
9.5 KiB
JavaScript
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);
|
|
});
|