UNPKG

@rapideditor/location-conflation

Version:

Define complex geographic regions by including and excluding country codes and geojson shapes

374 lines (319 loc) 12.7 kB
import * as CountryCoder from '@rapideditor/country-coder'; import * as Polyclip from 'polyclip-ts'; import calcArea from '@mapbox/geojson-area'; import circleToPolygon from 'circle-to-polygon'; import precision from 'geojson-precision'; import prettyStringify from '@aitodotai/json-stringify-pretty-compact'; export class LocationConflation { // constructor // // `fc` Optional FeatureCollection of known features // // Optionally pass a GeoJSON FeatureCollection of known features which we can refer to later. // Each feature must have a filename-like `id`, for example: `something.geojson` // // { // "type": "FeatureCollection" // "features": [ // { // "type": "Feature", // "id": "philly_metro.geojson", // "properties": { … }, // "geometry": { … } // } // ] // } constructor(fc) { // The _cache retains resolved features, so if you ask for the same thing multiple times // we don't repeat the expensive resolving/clipping operations. // // Each feature has a stable identifier that is used as the cache key. // The identifiers look like: // - for point locations, the stringified point: e.g. '[8.67039,49.41882]' // - for geojson locations, the geojson id: e.g. 'de-hamburg.geojson' // - for countrycoder locations, feature.id property: e.g. 'Q2' (countrycoder uses Wikidata identifiers) // - for aggregated locationSets, +[include]-[exclude]: e.g '+[Q2]-[Q18,Q27611]' this._cache = {}; // When strict mode = true, throw on invalid locations or locationSets. // When strict mode = false, return `null` for invalid locations or locationSets. this.strict = true; // process input FeatureCollection if (fc && fc.type === 'FeatureCollection' && Array.isArray(fc.features)) { fc.features.forEach(feature => { feature.properties = feature.properties || {}; let props = feature.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 id = id.toLowerCase(); feature.id = id; props.id = id; // Ensure `area` property exists if (!props.area) { const area = calcArea.geometry(feature.geometry) / 1e6; // m² to km² props.area = Number(area.toFixed(2)); } this._cache[id] = feature; }); } // Replace CountryCoder world geometry to be a polygon covering the world. let world = _cloneDeep(CountryCoder.feature('Q2')); world.geometry = { type: 'Polygon', coordinates: [[[-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90]]] }; world.id = 'Q2'; world.properties.id = 'Q2'; world.properties.area = calcArea.geometry(world.geometry) / 1e6; // m² to km² this._cache.Q2 = world; } // validateLocation // `location` The location to validate // // Pass a `location` value to validate // // Returns a result like: // { // type: 'point', 'geojson', or 'countrycoder' // location: the queried location // id: the stable identifier for the feature // } // or `null` if the location is invalid // validateLocation(location) { if (Array.isArray(location) && (location.length === 2 || location.length === 3)) { // [lon, lat] or [lon, lat, radius] point? const lon = location[0]; const lat = location[1]; const radius = location[2]; if ( Number.isFinite(lon) && lon >= -180 && lon <= 180 && Number.isFinite(lat) && lat >= -90 && lat <= 90 && (location.length === 2 || (Number.isFinite(radius) && radius > 0)) ) { const id = '[' + location.toString() + ']'; return { type: 'point', location: location, id: id }; } } else if (typeof location === 'string' && /^\S+\.geojson$/i.test(location)) { // a .geojson filename? const id = location.toLowerCase(); if (this._cache[id]) { return { type: 'geojson', location: location, id: id }; } } else if (typeof location === 'string' || typeof location === 'number') { // a country-coder value? const feature = CountryCoder.feature(location); if (feature) { // Use wikidata QID as the identifier, since that seems to be the one // property that everything in CountryCoder is guaranteed to have. const id = feature.properties.wikidata; return { type: 'countrycoder', location: location, id: id }; } } if (this.strict) { throw new Error(`validateLocation: Invalid location: "${location}".`); } else { return null; } } // resolveLocation // `location` The location to resolve // // Pass a `location` value to resolve // // Returns a result like: // { // type: 'point', 'geojson', or 'countrycoder' // location: the queried location // id: a stable identifier for the feature // feature: the resolved GeoJSON feature // } // or `null` if the location is invalid // resolveLocation(location) { const valid = this.validateLocation(location); if (!valid) return null; const id = valid.id; // 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? if (valid.type === 'point') { const lon = location[0]; const lat = location[1]; const radius = location[2] || 25; // km const EDGES = 10; const PRECISION = 3; const area = Math.PI * radius * radius; const feature = this._cache[id] = precision({ type: 'Feature', id: id, properties: { id: id, area: Number(area.toFixed(2)) }, geometry: circleToPolygon([lon, lat], radius * 1000, EDGES) // km to m }, PRECISION); return Object.assign(valid, { feature: feature }); // 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? } else if (valid.type === 'countrycoder') { let feature = _cloneDeep(CountryCoder.feature(id)); let props = feature.properties; // -> This block of code is weird and requires some explanation. <- // 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. // 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)) { let aggregate = CountryCoder.aggregateFeature(id); aggregate.geometry.coordinates = _clip([aggregate], 'UNION').geometry.coordinates; feature.geometry = aggregate.geometry; } // 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 feature.id = id; props.id = id; this._cache[id] = feature; return Object.assign(valid, { feature: feature }); } if (this.strict) { throw new Error(`resolveLocation: Couldn't resolve location "${location}".`); } else { return null; } } // validateLocationSet // `locationSet` the locationSet to validate // // Pass a locationSet Object to validate like: // { // include: [ Array of locations ], // exclude: [ Array of locations ] // } // // Returns a result like: // { // type: 'locationset' // locationSet: the queried locationSet // id: the stable identifier for the feature // } // or `null` if the locationSet is invalid // validateLocationSet(locationSet) { locationSet = locationSet || {}; const validator = this.validateLocation.bind(this); let include = (locationSet.include || []).map(validator).filter(Boolean); let exclude = (locationSet.exclude || []).map(validator).filter(Boolean); if (!include.length) { if (this.strict) { throw new Error(`validateLocationSet: LocationSet includes nothing.`); } else { // non-strict mode, replace an empty locationSet with one that includes "the world" locationSet.include = ['Q2']; include = [{ type: 'countrycoder', location: 'Q2', id: 'Q2' }]; } } // Generate stable identifier include.sort(_sortLocations); let id = '+[' + include.map(d => d.id).join(',') + ']'; if (exclude.length) { exclude.sort(_sortLocations); id += '-[' + exclude.map(d => d.id).join(',') + ']'; } return { type: 'locationset', locationSet: locationSet, id: id }; } // resolveLocationSet // `locationSet` the locationSet to resolve // // Pass a locationSet Object to validate like: // { // include: [ Array of locations ], // exclude: [ Array of locations ] // } // // Returns a result like: // { // type: 'locationset' // locationSet: the queried locationSet // id: the stable identifier for the feature // feature: the resolved GeoJSON feature // } // or `null` if the locationSet is invalid // resolveLocationSet(locationSet) { locationSet = locationSet || {}; const valid = this.validateLocationSet(locationSet); if (!valid) return null; const id = valid.id; // 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 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 (includes.length === 1 && excludes.length === 0) { return Object.assign(valid, { feature: includes[0].feature }); } // 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; const area = calcArea.geometry(resultGeoJSON.geometry) / 1e6; // m² to km² resultGeoJSON.id = id; resultGeoJSON.properties = { id: id, area: Number(area.toFixed(2)) }; this._cache[id] = resultGeoJSON; return Object.assign(valid, { feature: resultGeoJSON }); } // stringify // convenience method to prettyStringify the given object stringify(obj, options) { return prettyStringify(obj, options); } } // Wrap the https://github.com/luizbarboza/polyclip-ts library and return a GeoJSON feature. function _clip(features, which) { if (!Array.isArray(features) || !features.length) return null; const fn = { UNION: Polyclip.union, DIFFERENCE: Polyclip.difference }[which]; const args = features.map(feature => feature.geometry.coordinates); const coords = fn.apply(null, args); return { type: 'Feature', properties: {}, geometry: { type: whichType(coords), coordinates: coords } }; // is this a Polygon or a MultiPolygon? function whichType(coords) { const a = Array.isArray(coords); const b = a && Array.isArray(coords[0]); const c = b && Array.isArray(coords[0][0]); const d = c && Array.isArray(coords[0][0][0]); return d ? 'MultiPolygon' : 'Polygon'; } } function _cloneDeep(obj) { return JSON.parse(JSON.stringify(obj)); } // Sorting the location lists is ok because they end up unioned together. // This sorting makes it possible to generate a deterministic id. function _sortLocations(a, b) { const rank = { countrycoder: 1, geojson: 2, point: 3 }; const aRank = rank[a.type]; const bRank = rank[b.type]; return (aRank > bRank) ? 1 : (aRank < bRank) ? -1 : a.id.localeCompare(b.id); } export default LocationConflation;