From 86ab4ae3e5ee9fac6639ffa326fed370f60e9de4 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Wed, 30 Dec 2020 01:01:39 -0500 Subject: [PATCH] Perf: Don't union features iteratively with locationReducer We needed to do this before with Turf/JSTS/martinez clippers, but mfogel/polygon-clipper can handle multiple input coord arrays, so we can send all the coords in one shot and it just works fast. --- .editorconfig | 2 +- dist/index.es5.js | 133 +++++++++++++++++++++++++--------------------- dist/index.js | 133 +++++++++++++++++++++++++--------------------- index.mjs | 83 +++++++++++------------------ 4 files changed, 177 insertions(+), 174 deletions(-) diff --git a/.editorconfig b/.editorconfig index 56a0e57..05f9e08 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,7 +2,7 @@ root = true [*] trim_trailing_whitespace = true -insert_final_newline = false +insert_final_newline = true # for ESLint [*.js] diff --git a/dist/index.es5.js b/dist/index.es5.js index 5a8d6d6..ad591ad 100644 --- a/dist/index.es5.js +++ b/dist/index.es5.js @@ -6,18 +6,9 @@ typeof define === 'function' && define.amd ? define(factory) : var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; -function createCommonjsModule(fn, basedir, module) { - return module = { - path: basedir, - exports: {}, - require: function (path, base) { - return commonjsRequire(path, (base === undefined || base === null) ? module.path : base); - } - }, fn(module, module.exports), module.exports; -} - -function commonjsRequire () { - throw new Error('Dynamic requires are not currently supported by @rollup/plugin-commonjs'); +function createCommonjsModule(fn) { + var module = { exports: {} }; + return fn(module, module.exports), module.exports; } var quickselect = createCommonjsModule(function (module, exports) { @@ -1233,6 +1224,45 @@ function featuresContaining(query, strict) { } return features; } +function featuresIn(id, strict) { + var feature = featureForID(id); + if (!feature) { return []; } + var features = []; + if (!strict) { + features.push(feature); + } + var properties = feature.properties; + if (properties.members) { + for (var i in properties.members) { + var memberID = properties.members[i]; + features.push(featuresByCode[memberID]); + } + } + return features; +} +function aggregateFeature(id) { + var features = featuresIn(id, false); + if (features.length === 0) { return null; } + var aggregateCoordinates = []; + for (var i in features) { + var feature = features[i]; + if ( + feature.geometry && + feature.geometry.type === 'MultiPolygon' && + feature.geometry.coordinates + ) { + aggregateCoordinates = aggregateCoordinates.concat(feature.geometry.coordinates); + } + } + return { + type: 'Feature', + properties: features[0].properties, + geometry: { + type: 'MultiPolygon', + coordinates: aggregateCoordinates + } + }; +} var RADIUS = 6378137; var FLATTENING = 1/298.257223563; @@ -4148,16 +4178,16 @@ var defaultExport = function defaultExport(fc) { feature.properties = feature.properties || {}; var props = feature.properties; - // get `id` from either `id` or `properties` + // Get `id` from either `id` or `properties` var id = feature.id || props.id; if (!id || !/^\S+\.geojson$/i.test(id)) { return; } - // ensure `id` exists and is lowercase + // Ensure `id` exists and is lowercase id = id.toLowerCase(); feature.id = id; props.id = id; - // ensure `area` property exists + // Ensure `area` property exists if (!props.area) { var area = geojsonArea.geometry(feature.geometry) / 1e6;// m² to km² props.area = Number(area.toFixed(2)); @@ -4246,12 +4276,12 @@ defaultExport.prototype.resolveLocation = function resolveLocation (location) { var id = valid.id; - // return a result from cache if we can + // Return a result from cache if we can if (this._cache[id]) { return Object.assign(valid, { feature: this._cache[id] }); } - // a [lon,lat] coordinate pair? + // A [lon,lat] coordinate pair? if (valid.type === 'point') { var RADIUS = 25000;// meters var EDGES = 10; @@ -4265,7 +4295,7 @@ defaultExport.prototype.resolveLocation = function resolveLocation (location) { }, PRECISION); return Object.assign(valid, { feature: feature$1 }); - // a .geojson filename? + // A .geojson filename? } else if (valid.type === 'geojson') ; else if (valid.type === 'countrycoder') { var feature$1$1 = _cloneDeep(feature(id)); var props = feature$1$1.properties; @@ -4274,24 +4304,24 @@ defaultExport.prototype.resolveLocation = function resolveLocation (location) { // CountryCoder includes higher level features which are made up of members. // These features don't have their own geometry, but CountryCoder provides an // `aggregateFeature` method to combine these members into a MultiPolygon. - // BUT, when we try to actually work with these aggregated MultiPolygons, - // Turf/JSTS gets crashy because of topography bugs. - // SO, we'll aggregate the features ourselves by unioning them together. + // In the past, Turf/JSTS/martinez could not handle the aggregated features, + // so we'd iteratively union them all together.(this was slow) + // But now mfogel/polygon-clipping handles these MultiPolygons like a boss. // This approach also has the benefit of removing all the internal boaders and // simplifying the regional polygons a lot. if (Array.isArray(props.members)) { - var seed = feature$1$1.geometry ? feature$1$1 : null; - var aggregate = props.members.reduce(_locationReducer.bind(this), seed); + var aggregate = aggregateFeature(id); + aggregate.geometry.coordinates = _clip([aggregate], 'UNION').geometry.coordinates; feature$1$1.geometry = aggregate.geometry; } - // ensure `area` property exists + // Ensure `area` property exists if (!props.area) { var area$1 = geojsonArea.geometry(feature$1$1.geometry) / 1e6;// m² to km² props.area = Number(area$1.toFixed(2)); } - // ensure `id` property exists + // Ensure `id` property exists feature$1$1.id = id; props.id = id; @@ -4340,7 +4370,7 @@ defaultExport.prototype.validateLocationSet = function validateLocationSet (loca } } - // generate stable identifier + // Generate stable identifier include.sort(_sortLocations); var id = '+[' + include.map(function (d) { return d.id; }).join(',') + ']'; if (exclude.length) { @@ -4377,26 +4407,26 @@ defaultExport.prototype.resolveLocationSet = function resolveLocationSet (locati var id = valid.id; - // return a result from cache if we can + // Return a result from cache if we can if (this._cache[id]) { return Object.assign(valid, { feature: this._cache[id] }); } var resolver = this.resolveLocation.bind(this); - var include = (locationSet.include || []).map(resolver).filter(Boolean); - var exclude = (locationSet.exclude || []).map(resolver).filter(Boolean); + var includes = (locationSet.include || []).map(resolver).filter(Boolean); + var excludes = (locationSet.exclude || []).map(resolver).filter(Boolean); - // return quickly if it's a single included location.. - if (include.length === 1 && exclude.length === 0) { - return Object.assign(valid, { feature: include[0].feature }); + // Return quickly if it's a single included location.. + if (includes.length === 1 && excludes.length === 0) { + return Object.assign(valid, { feature: includes[0].feature }); } - // calculate unions - var includeGeoJSON = include.map(function (d) { return d.location; }).reduce(_locationReducer.bind(this), null); - var excludeGeoJSON = exclude.map(function (d) { return d.location; }).reduce(_locationReducer.bind(this), null); + // Calculate unions + var includeGeoJSON = _clip(includes.map(function (d) { return d.feature; }), 'UNION'); + var excludeGeoJSON = _clip(excludes.map(function (d) { return d.feature; }), 'UNION'); - // calculate difference, update `area` and return result - var resultGeoJSON = excludeGeoJSON ? _clip(includeGeoJSON, excludeGeoJSON, 'DIFFERENCE') : includeGeoJSON; + // Calculate difference, update `area` and return result + var resultGeoJSON = excludeGeoJSON ? _clip([includeGeoJSON, excludeGeoJSON], 'DIFFERENCE') : includeGeoJSON; var area = geojsonArea.geometry(resultGeoJSON.geometry) / 1e6;// m² to km² resultGeoJSON.id = id; resultGeoJSON.properties = { id: id, area: Number(area.toFixed(2)) }; @@ -4432,11 +4462,13 @@ defaultExport.prototype.stringify = function stringify (obj, options) { }; - // Wrap the mfogel/polygon-clipping library and return a GeoJSON feature. -function _clip(a, b, which) { +function _clip(features, which) { + if (!Array.isArray(features) || !features.length) { return null; } + var fn = { UNION: index.union, DIFFERENCE: index.difference }[which]; - var coords = fn(a.geometry.coordinates, b.geometry.coordinates); + var args = features.map(function (feature) { return feature.geometry.coordinates; }); + var coords = fn.apply(null, args); return { type: 'Feature', properties: {}, @@ -4457,27 +4489,6 @@ function _clip(a, b, which) { } -// Reduce an array of locations into a single GeoJSON feature -function _locationReducer(accumulator, location) { - /* eslint-disable no-console, no-invalid-this */ - var result; - try { - var resolved = this.resolveLocation(location); - if (!resolved || !resolved.feature) { - console.warn(("Warning: Couldn't resolve location \"" + location + "\"")); - return accumulator; - } - result = !accumulator ? resolved.feature : _clip(accumulator, resolved.feature, 'UNION'); - } catch (e) { - console.warn(("Warning: Error resolving location \"" + location + "\"")); - console.warn(e); - result = accumulator; - } - return result; - /* eslint-enable no-console, no-invalid-this */ -} - - function _cloneDeep(obj) { return JSON.parse(JSON.stringify(obj)); } diff --git a/dist/index.js b/dist/index.js index f875e2f..56fb807 100644 --- a/dist/index.js +++ b/dist/index.js @@ -6,18 +6,9 @@ typeof define === 'function' && define.amd ? define(factory) : var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; -function createCommonjsModule(fn, basedir, module) { - return module = { - path: basedir, - exports: {}, - require: function (path, base) { - return commonjsRequire(path, (base === undefined || base === null) ? module.path : base); - } - }, fn(module, module.exports), module.exports; -} - -function commonjsRequire () { - throw new Error('Dynamic requires are not currently supported by @rollup/plugin-commonjs'); +function createCommonjsModule(fn) { + var module = { exports: {} }; + return fn(module, module.exports), module.exports; } var quickselect = createCommonjsModule(function (module, exports) { @@ -1229,6 +1220,45 @@ function featuresContaining(query, strict) { } return features; } +function featuresIn(id, strict) { + let feature = featureForID(id); + if (!feature) return []; + let features = []; + if (!strict) { + features.push(feature); + } + let properties = feature.properties; + if (properties.members) { + for (let i in properties.members) { + let memberID = properties.members[i]; + features.push(featuresByCode[memberID]); + } + } + return features; +} +function aggregateFeature(id) { + let features = featuresIn(id, false); + if (features.length === 0) return null; + let aggregateCoordinates = []; + for (let i in features) { + let feature = features[i]; + if ( + feature.geometry && + feature.geometry.type === 'MultiPolygon' && + feature.geometry.coordinates + ) { + aggregateCoordinates = aggregateCoordinates.concat(feature.geometry.coordinates); + } + } + return { + type: 'Feature', + properties: features[0].properties, + geometry: { + type: 'MultiPolygon', + coordinates: aggregateCoordinates + } + }; +} var RADIUS = 6378137; var FLATTENING = 1/298.257223563; @@ -4128,16 +4158,16 @@ class index$1 { feature.properties = feature.properties || {}; let props = feature.properties; - // get `id` from either `id` or `properties` + // Get `id` from either `id` or `properties` let id = feature.id || props.id; if (!id || !/^\S+\.geojson$/i.test(id)) return; - // ensure `id` exists and is lowercase + // Ensure `id` exists and is lowercase id = id.toLowerCase(); feature.id = id; props.id = id; - // ensure `area` property exists + // Ensure `area` property exists if (!props.area) { const area = geojsonArea.geometry(feature.geometry) / 1e6; // m² to km² props.area = Number(area.toFixed(2)); @@ -4226,12 +4256,12 @@ class index$1 { const id = valid.id; - // return a result from cache if we can + // Return a result from cache if we can if (this._cache[id]) { return Object.assign(valid, { feature: this._cache[id] }); } - // a [lon,lat] coordinate pair? + // A [lon,lat] coordinate pair? if (valid.type === 'point') { const RADIUS = 25000; // meters const EDGES = 10; @@ -4245,7 +4275,7 @@ class index$1 { }, PRECISION); return Object.assign(valid, { feature: feature }); - // a .geojson filename? + // A .geojson filename? } else if (valid.type === 'geojson') ; else if (valid.type === 'countrycoder') { let feature$1 = _cloneDeep(feature(id)); let props = feature$1.properties; @@ -4254,24 +4284,24 @@ class index$1 { // CountryCoder includes higher level features which are made up of members. // These features don't have their own geometry, but CountryCoder provides an // `aggregateFeature` method to combine these members into a MultiPolygon. - // BUT, when we try to actually work with these aggregated MultiPolygons, - // Turf/JSTS gets crashy because of topography bugs. - // SO, we'll aggregate the features ourselves by unioning them together. + // In the past, Turf/JSTS/martinez could not handle the aggregated features, + // so we'd iteratively union them all together. (this was slow) + // But now mfogel/polygon-clipping handles these MultiPolygons like a boss. // This approach also has the benefit of removing all the internal boaders and // simplifying the regional polygons a lot. if (Array.isArray(props.members)) { - const seed = feature$1.geometry ? feature$1 : null; - const aggregate = props.members.reduce(_locationReducer.bind(this), seed); + let aggregate = aggregateFeature(id); + aggregate.geometry.coordinates = _clip([aggregate], 'UNION').geometry.coordinates; feature$1.geometry = aggregate.geometry; } - // ensure `area` property exists + // Ensure `area` property exists if (!props.area) { const area = geojsonArea.geometry(feature$1.geometry) / 1e6; // m² to km² props.area = Number(area.toFixed(2)); } - // ensure `id` property exists + // Ensure `id` property exists feature$1.id = id; props.id = id; @@ -4320,7 +4350,7 @@ class index$1 { } } - // generate stable identifier + // Generate stable identifier include.sort(_sortLocations); let id = '+[' + include.map(d => d.id).join(',') + ']'; if (exclude.length) { @@ -4357,26 +4387,26 @@ class index$1 { const id = valid.id; - // return a result from cache if we can + // Return a result from cache if we can if (this._cache[id]) { return Object.assign(valid, { feature: this._cache[id] }); } const resolver = this.resolveLocation.bind(this); - const include = (locationSet.include || []).map(resolver).filter(Boolean); - const exclude = (locationSet.exclude || []).map(resolver).filter(Boolean); + const includes = (locationSet.include || []).map(resolver).filter(Boolean); + const excludes = (locationSet.exclude || []).map(resolver).filter(Boolean); - // return quickly if it's a single included location.. - if (include.length === 1 && exclude.length === 0) { - return Object.assign(valid, { feature: include[0].feature }); + // Return quickly if it's a single included location.. + if (includes.length === 1 && excludes.length === 0) { + return Object.assign(valid, { feature: includes[0].feature }); } - // calculate unions - const includeGeoJSON = include.map(d => d.location).reduce(_locationReducer.bind(this), null); - const excludeGeoJSON = exclude.map(d => d.location).reduce(_locationReducer.bind(this), null); + // Calculate unions + const includeGeoJSON = _clip(includes.map(d => d.feature), 'UNION'); + const excludeGeoJSON = _clip(excludes.map(d => d.feature), 'UNION'); - // calculate difference, update `area` and return result - let resultGeoJSON = excludeGeoJSON ? _clip(includeGeoJSON, excludeGeoJSON, 'DIFFERENCE') : includeGeoJSON; + // Calculate difference, update `area` and return result + let resultGeoJSON = excludeGeoJSON ? _clip([includeGeoJSON, excludeGeoJSON], 'DIFFERENCE') : includeGeoJSON; const area = geojsonArea.geometry(resultGeoJSON.geometry) / 1e6; // m² to km² resultGeoJSON.id = id; resultGeoJSON.properties = { id: id, area: Number(area.toFixed(2)) }; @@ -4413,11 +4443,13 @@ class index$1 { } - // Wrap the mfogel/polygon-clipping library and return a GeoJSON feature. -function _clip(a, b, which) { +function _clip(features, which) { + if (!Array.isArray(features) || !features.length) return null; + const fn = { UNION: index.union, DIFFERENCE: index.difference }[which]; - const coords = fn(a.geometry.coordinates, b.geometry.coordinates); + const args = features.map(feature => feature.geometry.coordinates); + const coords = fn.apply(null, args); return { type: 'Feature', properties: {}, @@ -4438,27 +4470,6 @@ function _clip(a, b, which) { } -// Reduce an array of locations into a single GeoJSON feature -function _locationReducer(accumulator, location) { - /* eslint-disable no-console, no-invalid-this */ - let result; - try { - let resolved = this.resolveLocation(location); - if (!resolved || !resolved.feature) { - console.warn(`Warning: Couldn't resolve location "${location}"`); - return accumulator; - } - result = !accumulator ? resolved.feature : _clip(accumulator, resolved.feature, 'UNION'); - } catch (e) { - console.warn(`Warning: Error resolving location "${location}"`); - console.warn(e); - result = accumulator; - } - return result; - /* eslint-enable no-console, no-invalid-this */ -} - - function _cloneDeep(obj) { return JSON.parse(JSON.stringify(obj)); } diff --git a/index.mjs b/index.mjs index 31e186f..71a3edc 100644 --- a/index.mjs +++ b/index.mjs @@ -49,16 +49,16 @@ export default class { feature.properties = feature.properties || {}; let props = feature.properties; - // get `id` from either `id` or `properties` + // Get `id` from either `id` or `properties` let id = feature.id || props.id; if (!id || !/^\S+\.geojson$/i.test(id)) return; - // ensure `id` exists and is lowercase + // Ensure `id` exists and is lowercase id = id.toLowerCase(); feature.id = id; props.id = id; - // ensure `area` property exists + // Ensure `area` property exists if (!props.area) { const area = calcArea.geometry(feature.geometry) / 1e6; // m² to km² props.area = Number(area.toFixed(2)); @@ -147,12 +147,12 @@ export default class { const id = valid.id; - // return a result from cache if we can + // Return a result from cache if we can if (this._cache[id]) { return Object.assign(valid, { feature: this._cache[id] }); } - // a [lon,lat] coordinate pair? + // A [lon,lat] coordinate pair? if (valid.type === 'point') { const RADIUS = 25000; // meters const EDGES = 10; @@ -166,11 +166,11 @@ export default class { }, PRECISION); return Object.assign(valid, { feature: feature }); - // a .geojson filename? + // A .geojson filename? } else if (valid.type === 'geojson') { // nothing to do here - these are all in _cache and would have returned already - // a country-coder identifier? + // A country-coder identifier? } else if (valid.type === 'countrycoder') { let feature = _cloneDeep(CountryCoder.feature(id)); let props = feature.properties; @@ -179,24 +179,24 @@ export default class { // CountryCoder includes higher level features which are made up of members. // These features don't have their own geometry, but CountryCoder provides an // `aggregateFeature` method to combine these members into a MultiPolygon. - // BUT, when we try to actually work with these aggregated MultiPolygons, - // Turf/JSTS gets crashy because of topography bugs. - // SO, we'll aggregate the features ourselves by unioning them together. + // In the past, Turf/JSTS/martinez could not handle the aggregated features, + // so we'd iteratively union them all together. (this was slow) + // But now mfogel/polygon-clipping handles these MultiPolygons like a boss. // This approach also has the benefit of removing all the internal boaders and // simplifying the regional polygons a lot. if (Array.isArray(props.members)) { - const seed = feature.geometry ? feature : null; - const aggregate = props.members.reduce(_locationReducer.bind(this), seed); + let aggregate = CountryCoder.aggregateFeature(id); + aggregate.geometry.coordinates = _clip([aggregate], 'UNION').geometry.coordinates; feature.geometry = aggregate.geometry; } - // ensure `area` property exists + // Ensure `area` property exists if (!props.area) { const area = calcArea.geometry(feature.geometry) / 1e6; // m² to km² props.area = Number(area.toFixed(2)); } - // ensure `id` property exists + // Ensure `id` property exists feature.id = id; props.id = id; @@ -245,7 +245,7 @@ export default class { } } - // generate stable identifier + // Generate stable identifier include.sort(_sortLocations); let id = '+[' + include.map(d => d.id).join(',') + ']'; if (exclude.length) { @@ -282,26 +282,26 @@ export default class { const id = valid.id; - // return a result from cache if we can + // Return a result from cache if we can if (this._cache[id]) { return Object.assign(valid, { feature: this._cache[id] }); } const resolver = this.resolveLocation.bind(this); - const include = (locationSet.include || []).map(resolver).filter(Boolean); - const exclude = (locationSet.exclude || []).map(resolver).filter(Boolean); + const includes = (locationSet.include || []).map(resolver).filter(Boolean); + const excludes = (locationSet.exclude || []).map(resolver).filter(Boolean); - // return quickly if it's a single included location.. - if (include.length === 1 && exclude.length === 0) { - return Object.assign(valid, { feature: include[0].feature }); + // Return quickly if it's a single included location.. + if (includes.length === 1 && excludes.length === 0) { + return Object.assign(valid, { feature: includes[0].feature }); } - // calculate unions - const includeGeoJSON = include.map(d => d.location).reduce(_locationReducer.bind(this), null); - const excludeGeoJSON = exclude.map(d => d.location).reduce(_locationReducer.bind(this), null); + // Calculate unions + const includeGeoJSON = _clip(includes.map(d => d.feature), 'UNION'); + const excludeGeoJSON = _clip(excludes.map(d => d.feature), 'UNION'); - // calculate difference, update `area` and return result - let resultGeoJSON = excludeGeoJSON ? _clip(includeGeoJSON, excludeGeoJSON, 'DIFFERENCE') : includeGeoJSON; + // Calculate difference, update `area` and return result + let resultGeoJSON = excludeGeoJSON ? _clip([includeGeoJSON, excludeGeoJSON], 'DIFFERENCE') : includeGeoJSON; const area = calcArea.geometry(resultGeoJSON.geometry) / 1e6; // m² to km² resultGeoJSON.id = id; resultGeoJSON.properties = { id: id, area: Number(area.toFixed(2)) }; @@ -338,11 +338,13 @@ export default class { } - // Wrap the mfogel/polygon-clipping library and return a GeoJSON feature. -function _clip(a, b, which) { +function _clip(features, which) { + if (!Array.isArray(features) || !features.length) return null; + const fn = { UNION: polygonClipping.union, DIFFERENCE: polygonClipping.difference }[which]; - const coords = fn(a.geometry.coordinates, b.geometry.coordinates); + const args = features.map(feature => feature.geometry.coordinates); + const coords = fn.apply(null, args); return { type: 'Feature', properties: {}, @@ -363,27 +365,6 @@ function _clip(a, b, which) { } -// Reduce an array of locations into a single GeoJSON feature -function _locationReducer(accumulator, location) { - /* eslint-disable no-console, no-invalid-this */ - let result; - try { - let resolved = this.resolveLocation(location); - if (!resolved || !resolved.feature) { - console.warn(`Warning: Couldn't resolve location "${location}"`); - return accumulator; - } - result = !accumulator ? resolved.feature : _clip(accumulator, resolved.feature, 'UNION'); - } catch (e) { - console.warn(`Warning: Error resolving location "${location}"`); - console.warn(e); - result = accumulator; - } - return result; - /* eslint-enable no-console, no-invalid-this */ -} - - function _cloneDeep(obj) { return JSON.parse(JSON.stringify(obj)); } @@ -399,4 +380,4 @@ function _sortLocations(a, b) { return (aRank > bRank) ? 1 : (aRank < bRank) ? -1 : a.id.localeCompare(b.id); -} +} \ No newline at end of file