546 lines
14 KiB
JavaScript
546 lines
14 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 = 0.3;
|
|
const DEFAULT_PADDING = 6;
|
|
const MAX_MERCATOR_LAT = 85.05112878;
|
|
const SHAPEFILE_BASENAME = "ne_10m_admin_0_countries";
|
|
const GAME_COUNTRIES = [
|
|
"Switzerland",
|
|
"Norway",
|
|
"Italy",
|
|
"Japan",
|
|
"Brazil",
|
|
"Australia",
|
|
"France",
|
|
"India",
|
|
"Canada",
|
|
"Germany",
|
|
];
|
|
const COUNTRY_OPTIONS = {
|
|
France: { keepLargestRingOnly: true },
|
|
Norway: { keepLargestRingOnly: true },
|
|
};
|
|
|
|
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: 0.3
|
|
--padding <number> Padding on each side in 0..100 units. Default: 6
|
|
--max-points <number> Simplify each ring until it has at most this many points.
|
|
--min-ring-area-ratio <number>
|
|
Drop rings smaller than this ratio of the largest ring. Default: 0.001
|
|
--mainland-only Keep only the largest ring for every country.
|
|
--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,
|
|
padding: DEFAULT_PADDING,
|
|
maxPoints: null,
|
|
minRingAreaRatio: 0.001,
|
|
mainlandOnly: false,
|
|
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 === "--padding") {
|
|
options.padding = Number(argv[++i]);
|
|
} else if (arg === "--max-points") {
|
|
options.maxPoints = Number(argv[++i]);
|
|
} else if (arg === "--min-ring-area-ratio") {
|
|
options.minRingAreaRatio = Number(argv[++i]);
|
|
} else if (arg === "--mainland-only") {
|
|
options.mainlandOnly = true;
|
|
} 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 (
|
|
!Number.isFinite(options.padding) ||
|
|
options.padding < 0 ||
|
|
options.padding >= 50
|
|
) {
|
|
throw new Error("--padding must be a number greater than or equal to 0 and less than 50.");
|
|
}
|
|
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.",
|
|
);
|
|
}
|
|
if (
|
|
!Number.isFinite(options.minRingAreaRatio) ||
|
|
options.minRingAreaRatio < 0 ||
|
|
options.minRingAreaRatio > 1
|
|
) {
|
|
throw new Error("--min-ring-area-ratio must be a number between 0 and 1.");
|
|
}
|
|
|
|
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 mercatorProject([lon, lat]) {
|
|
const clampedLat = Math.max(
|
|
-MAX_MERCATOR_LAT,
|
|
Math.min(MAX_MERCATOR_LAT, lat),
|
|
);
|
|
const lonRad = (lon * Math.PI) / 180;
|
|
const latRad = (clampedLat * Math.PI) / 180;
|
|
|
|
return [
|
|
lonRad,
|
|
Math.log(Math.tan(Math.PI / 4 + latRad / 2)),
|
|
];
|
|
}
|
|
|
|
function ringArea(ring) {
|
|
if (!Array.isArray(ring) || ring.length < 4) return 0;
|
|
let area = 0;
|
|
for (let i = 0; i < ring.length; i++) {
|
|
const [x1, y1] = ring[i];
|
|
const [x2, y2] = ring[(i + 1) % ring.length];
|
|
area += x1 * y2 - x2 * y1;
|
|
}
|
|
return Math.abs(area / 2);
|
|
}
|
|
|
|
function filterGameplayRings(rings, options) {
|
|
const ranked = rings
|
|
.map((ring) => ({ ring, area: ringArea(ring) }))
|
|
.filter(({ area }) => area > 0)
|
|
.sort((a, b) => b.area - a.area);
|
|
|
|
if (!ranked.length) return rings;
|
|
if (options.mainlandOnly || options.keepLargestRingOnly) {
|
|
return [ranked[0].ring];
|
|
}
|
|
|
|
const minArea = ranked[0].area * options.minRingAreaRatio;
|
|
return ranked.filter(({ area }) => area >= minArea).map(({ ring }) => ring);
|
|
}
|
|
|
|
function getGeoBounds(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 getProjectedBounds(rings) {
|
|
let minX = Infinity;
|
|
let maxX = -Infinity;
|
|
let minY = Infinity;
|
|
let maxY = -Infinity;
|
|
|
|
for (const ring of rings) {
|
|
for (const [x, y] of ring) {
|
|
minX = Math.min(minX, x);
|
|
maxX = Math.max(maxX, x);
|
|
minY = Math.min(minY, y);
|
|
maxY = Math.max(maxY, y);
|
|
}
|
|
}
|
|
|
|
if (![minX, maxX, minY, maxY].every(Number.isFinite)) {
|
|
throw new Error("Could not calculate projected bounds from geometry.");
|
|
}
|
|
|
|
return { minX, maxX, minY, maxY };
|
|
}
|
|
|
|
function roundCoord(value) {
|
|
return Math.round(value * 100) / 100;
|
|
}
|
|
|
|
function roundMeta(value) {
|
|
return Math.round(value * 1_000_000) / 1_000_000;
|
|
}
|
|
|
|
function createProjection(bounds, padding) {
|
|
const xSpan = bounds.maxX - bounds.minX;
|
|
const ySpan = bounds.maxY - bounds.minY;
|
|
const usableSize = 100 - padding * 2;
|
|
const maxSpan = Math.max(xSpan, ySpan);
|
|
const scale = maxSpan === 0 ? 0 : usableSize / maxSpan;
|
|
const width = xSpan * scale;
|
|
const height = ySpan * scale;
|
|
|
|
return {
|
|
padding,
|
|
scale,
|
|
xOffset: (100 - width) / 2,
|
|
yOffset: (100 - height) / 2,
|
|
};
|
|
}
|
|
|
|
function normalizePoint([x, y], bounds, projection) {
|
|
return {
|
|
x: roundCoord(
|
|
projection.scale === 0
|
|
? 50
|
|
: projection.xOffset + (x - bounds.minX) * projection.scale,
|
|
),
|
|
y: roundCoord(
|
|
projection.scale === 0
|
|
? 50
|
|
: projection.yOffset + (bounds.maxY - y) * projection.scale,
|
|
),
|
|
};
|
|
}
|
|
|
|
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 geoRings = filterGameplayRings(
|
|
extractRings(geometry, options.includeHoles)
|
|
.map(cleanRing)
|
|
.filter((ring) => ring.length >= 4),
|
|
options,
|
|
);
|
|
|
|
if (!geoRings.length) {
|
|
throw new Error("No valid polygon rings found.");
|
|
}
|
|
|
|
const projectedRings = geoRings.map((ring) => ring.map(mercatorProject));
|
|
const geoBounds = getGeoBounds(geoRings);
|
|
const projectedBounds = getProjectedBounds(projectedRings);
|
|
const projection = createProjection(projectedBounds, options.padding);
|
|
const normalizedRings = projectedRings
|
|
.map((ring) =>
|
|
ring.map((point) => normalizePoint(point, projectedBounds, projection)),
|
|
)
|
|
.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: geoRings.length > 1 ? "MultiPolygon" : "Polygon",
|
|
geoBounds,
|
|
projectedBounds: {
|
|
minX: roundMeta(projectedBounds.minX),
|
|
maxX: roundMeta(projectedBounds.maxX),
|
|
minY: roundMeta(projectedBounds.minY),
|
|
maxY: roundMeta(projectedBounds.maxY),
|
|
},
|
|
projection: {
|
|
padding: roundMeta(projection.padding),
|
|
scale: roundMeta(projection.scale),
|
|
xOffset: roundMeta(projection.xOffset),
|
|
yOffset: roundMeta(projection.yOffset),
|
|
},
|
|
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 countryOptions = {
|
|
...options,
|
|
...(COUNTRY_OPTIONS[countryName] || {}),
|
|
};
|
|
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, countryOptions),
|
|
};
|
|
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);
|
|
});
|