#!/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 [options] Options: --tolerance Douglas-Peucker tolerance in 0..100 units. Default: 0.3 --padding Padding on each side in 0..100 units. Default: 6 --max-points Simplify each ring until it has at most this many points. --min-ring-area-ratio 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 and ."); } 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); });