scripts/layerdiff.js (273 lines of code) (raw):

/* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ import fs from 'node:fs'; import { isEqual, compact, values } from 'lodash-es'; import GeoJSONReader from 'jsts/org/locationtech/jts/io/GeoJSONReader.js'; import Centroid from 'jsts/org/locationtech/jts/algorithm/Centroid.js'; import { getDistance, getAreaOfPolygon } from 'geolib'; import yargs from 'yargs/yargs'; /* Command line options definition */ const argv = yargs(process.argv.slice(2)) .version() .scriptName('layerdiff') .help() .alias('h', 'help') .option('verbose', { alias: 'v', type: 'boolean', default: false, describe: 'More verbose output', }) .option('field-id', { alias: 'i', type: 'string', describe: 'Use a property as identifier, by default the id from the feature will be used', }) .option('check-id', { alias: 'c', type: 'string', nargs: 1, describe: 'Check a single feature with the provided ID', }) .option('area-diff', { alias: 'a', type: 'number', nargs: 1, default: 1, describe: 'Area percentage difference to trigger an alert', }) .option('centroid-dist', { alias: 'd', type: 'number', nargs: 1, default: 1000, describe: 'Distance in meters of the centroids that trigger an alert', }) .option('check-parts', { alias: 'p', type: 'boolean', default: false, describe: 'Enable to compare the number of parts on multipolygons', }) .demandCommand(2) .example('$0 old.geojson new.geojson', 'Compare two files') .example('$0 -i INSEE old new', 'Compare using a custom property') .example('$0 -c Q43121 old new', 'Compare given ID feature') .epilog('Elastic, 2020') .argv; /* Logging function */ function print() { console.log.apply(console, arguments); } const VERBOSE = argv.verbose; const FIELD_ID = argv.fieldId ? argv.fieldId.trim() : false; const CHECK_ID = argv.checkId; const AREA_DIFF = argv.areaDiff; const CENTROID_DIST = argv.centroidDist; const CHECK_PARTS = argv.checkParts; const getId = function (f) { return FIELD_ID ? f.properties[FIELD_ID] : f.id; }; const toFeaturePoint = function (geom) { return { latitude: geom.getY(), longitude: geom.getX(), }; }; const getAreaFromRing = function (ring) { const points = ring.getCoordinates().map(c => [c.x, c.y]); return getAreaOfPolygon(points); }; const getArea = function (geom) { let totalArea = 0; for (let i = 0; i < geom.getNumGeometries(); i++) { const part = geom.getGeometryN(i); const ringArea = getAreaFromRing(part.getExteriorRing()); let holeArea = 0; for (let j = 0; j < part.getNumInteriorRing(); j++) { const hole = part.getInteriorRingN(j); holeArea += getAreaFromRing(hole); } totalArea += ringArea - holeArea; } return totalArea; }; /* Compare two features */ const compareFeatures = function ({ id, left, right }) { const diffs = {}; const details = {}; // Deep comparison of properties using lodash diffs.properties = !isEqual(left.properties, right.properties); // Geometry check const lGeom = left.geometry; const rGeom = right.geometry; // Area check const lArea = getArea(lGeom); const rArea = getArea(rGeom); const areaDiff = (1 - lArea / rArea) * 100; diffs.area = Math.abs(areaDiff) > AREA_DIFF; details.area = areaDiff; details.areas = { left: Math.round(lArea / 1e6) + " km²", right: Math.round(rArea / 1e6) + " km²", }; // Centroid check const lCentroid = Centroid.getCentroid(lGeom); const rCentroid = Centroid.getCentroid(rGeom); const centroidDist = Math.floor( getDistance(toFeaturePoint(lCentroid), toFeaturePoint(rCentroid)) ); diffs.centroid = centroidDist > CENTROID_DIST; details.centroid = centroidDist; details.centroids = { left: `${lCentroid.getX()}, ${lCentroid.getY()}`, right: `${rCentroid.getX()}, ${rCentroid.getY()}`, }; // Check parts const lParts = lGeom.getNumGeometries(); const rParts = rGeom.getNumGeometries(); details.parts = { left: lParts, right: rParts, }; diffs.parts = CHECK_PARTS && lParts !== rParts; return { id, left, right, diffs, details, }; }; const reportDiffs = function ({ id, left, right, diffs, details }) { const numErrors = compact(values(diffs)).length; if (numErrors === 0 && !VERBOSE) { print(`Feature ${id} is OK ✔️`); return; } else { print(`\n---------------------------------- Feature ${id}`); } if (diffs.properties) { print('❌ Properties differ:'); console.table({ left: left.properties, right: right.properties }); } else if (VERBOSE) { print('✔️ Properties are OK'); console.table([left.properties]); } if (diffs.area || VERBOSE) { const mark = diffs.area ? '❌' : '✔️'; print(`${mark} Area difference: `, details.area.toFixed(2) + '%'); if (VERBOSE > 0) { console.table(details.areas); } } if (diffs.centroid || VERBOSE) { const mark = diffs.centroid ? '❌' : '✔️'; print(`${mark} Centroid distance:`, Math.round(details.centroid) + 'm'); if (VERBOSE > 0) { console.table(details.centroids); } } if (diffs.parts || VERBOSE) { const mark = diffs.parts ? '❌' : '✔️'; print(`${mark} Geometry parts: left: ${details.parts.left} right: ${details.parts.right}`); } }; const splitFeatures = function (leftJson, rightJson) { let features = []; const notLeft = []; if (CHECK_ID) { // Find the elements directly by the provided ID const id = CHECK_ID; const left = leftJson.features.find(f => getId(f) === id); const right = rightJson.features.find(f => getId(f) === id); if (left && right) { features.push({ id, left, right }); } else { if (!left) { print('❌ ID not found in the left GeoJSON'); } if (!right) { print('❌ ID not found in the right GeoJSON'); } yargs.exit(0); } } else { // Join the two arrays merging by the id // Put all the left features in a new object features = leftJson.features.map(f => { const id = getId(f); return { id, left: f, }; }); // Put all the right features in the main object // and save the ones that are not found for later rightJson.features.forEach(f => { const id = getId(f); const fIndex = features.findIndex(ff => ff.id === id); if (fIndex === -1) { notLeft.push(f); } else { features[fIndex].right = f; } }); } // Find if there are any features from the left // without the right side const notRight = features.filter(f => !('right' in f)); // Work only with features that are on both sides const finalFeatures = features.filter(f => 'right' in f); return { notLeft, notRight, features: finalFeatures, }; }; const pluralize = function (count, noun) { return `${count} ${noun}` + (count > 1 ? 's' : ''); }; // Execution starts here try { // Report given parameters if (VERBOSE) { const fieldId = FIELD_ID ? 'property ' + FIELD_ID : 'default feature id'; const checkId = CHECK_ID || 'none'; print('================================== Parameters'); print(`Identifier: ${fieldId}`); print(`Check a single feature id: ${checkId}`); print(`Area difference: ${AREA_DIFF}`); print(`Centroid distance: ${CENTROID_DIST}`); print(`Check parts: ${CHECK_PARTS}`); } const leftPath = argv._[0]; const rightPath = argv._[1]; const reader = new GeoJSONReader(); // Load GeoJSON files print(`================================== Loading files...`); const [leftJson, rightJson] = [leftPath, rightPath].map(path =>{ if (path === '-') { return reader.read(fs.readFileSync(0, 'utf-8')); } else { return reader.read(fs.readFileSync(path, 'utf-8')); } }); if (VERBOSE) { console.table([ { 'file': 'left', 'path': leftPath, 'features': leftJson.features.length }, { 'file': 'right', 'path': rightPath, 'features': rightJson.features.length }, ]); } // Check for IDs on both sides const { notLeft, notRight, features } = splitFeatures(leftJson, rightJson); print(`================================== IDs checked`); if (notRight.length > 0 || notLeft.length > 0) { print('The files don\'t have exactly the same number of features'); if (notRight.length > 0) { const ids = notRight.map(getId); print('❌ Missing IDs on the right file:', ids.join(', ')); } if (notLeft.length > 0) { const ids = notLeft.map(getId); print('❌ Missing IDs on the left file:', ids.join(', ')); } } else { print(`✔️ All good`); } // Run the comparison checks print(`================================== Comparing ${pluralize(features.length, 'feature')}...`); const featuresWithDiffs = features.map(compareFeatures); const warnings = featuresWithDiffs.filter(f => compact(values(f.diffs)).length > 0); print(`${pluralize(warnings.length, 'difference')} detected`); // Report differences warnings.forEach(reportDiffs); print('================================== Done! ✔️'); } catch (error) { print(error); print('Quiting ❌'); yargs.exit(0); }