modules/core/locations.js (131 lines of code) (raw):

import LocationConflation from '@ideditor/location-conflation'; import whichPolygon from 'which-polygon'; import calcArea from '@mapbox/geojson-area'; import { utilArrayChunk } from '@id-sdk/util'; let _mainLocations = coreLocations(); // singleton export { _mainLocations as locationManager }; // // `coreLocations` maintains an internal index of all the boundaries/geofences used by iD. // It's used by presets, community index, background imagery, to know where in the world these things are valid. // These geofences should be defined by `locationSet` objects: // // let locationSet = { // include: [ Array of locations ], // exclude: [ Array of locations ] // }; // // For more info see the location-conflation and country-coder projects, see: // https://github.com/ideditor/location-conflation // https://github.com/ideditor/country-coder // export function coreLocations() { let _this = {}; let _resolvedFeatures = {}; // cache of *resolved* locationSet features let _loco = new LocationConflation(); // instance of a location-conflation resolver let _wp; // instance of a which-polygon index // pre-resolve the worldwide locationSet const world = { locationSet: { include: ['Q2'] } }; resolveLocationSet(world); rebuildIndex(); let _queue = []; let _deferred = new Set(); let _inProcess; // Returns a Promise to process the queue function processQueue() { if (!_queue.length) return Promise.resolve(); // console.log(`queue length ${_queue.length}`); const chunk = _queue.pop(); return new Promise(resolvePromise => { const handle = window.requestIdleCallback(() => { _deferred.delete(handle); // const t0 = performance.now(); chunk.forEach(resolveLocationSet); // const t1 = performance.now(); // console.log('chunk processed in ' + (t1 - t0) + ' ms'); resolvePromise(); }); _deferred.add(handle); }) .then(() => processQueue()); } // Pass an Object with a `locationSet` property, // Performs the locationSet resolution, caches the result, and sets a `locationSetID` property on the object. function resolveLocationSet(obj) { if (obj.locationSetID) return; // work was done already try { let locationSet = obj.locationSet; if (!locationSet) { throw new Error('object missing locationSet property'); } if (!locationSet.include) { // missing `include`, default to worldwide include locationSet.include = ['Q2']; // https://github.com/openstreetmap/iD/pull/8305#discussion_r662344647 } const resolved = _loco.resolveLocationSet(locationSet); const locationSetID = resolved.id; obj.locationSetID = locationSetID; if (!resolved.feature.geometry.coordinates.length || !resolved.feature.properties.area) { throw new Error(`locationSet ${locationSetID} resolves to an empty feature.`); } if (!_resolvedFeatures[locationSetID]) { // First time seeing this locationSet feature let feature = JSON.parse(JSON.stringify(resolved.feature)); // deep clone feature.id = locationSetID; // Important: always use the locationSet `id` (`+[Q30]`), not the feature `id` (`Q30`) feature.properties.id = locationSetID; _resolvedFeatures[locationSetID] = feature; // insert into cache } } catch (err) { obj.locationSet = { include: ['Q2'] }; // default worldwide obj.locationSetID = '+[Q2]'; } } // Rebuilds the whichPolygon index with whatever features have been resolved. function rebuildIndex() { _wp = whichPolygon({ features: Object.values(_resolvedFeatures) }); } // // `mergeCustomGeoJSON` // Accepts an FeatureCollection-like object containing custom locations // Each feature must have a filename-like `id`, for example: `something.geojson` // // { // "type": "FeatureCollection" // "features": [ // { // "type": "Feature", // "id": "philly_metro.geojson", // "properties": { … }, // "geometry": { … } // } // ] // } // _this.mergeCustomGeoJSON = (fc) => { 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)); } _loco._cache[id] = feature; }); } }; // // `mergeLocationSets` // Accepts an Array of Objects containing `locationSet` properties. // The locationSets will be resolved and indexed in the background. // [ // { id: 'preset1', locationSet: {…} }, // { id: 'preset2', locationSet: {…} }, // { id: 'preset3', locationSet: {…} }, // … // ] // After resolving and indexing, the Objects will be decorated with a // `locationSetID` property. // [ // { id: 'preset1', locationSet: {…}, locationSetID: '+[Q2]' }, // { id: 'preset2', locationSet: {…}, locationSetID: '+[Q30]' }, // { id: 'preset3', locationSet: {…}, locationSetID: '+[Q2]' }, // … // ] // // Returns a Promise fulfilled when the resolving/indexing has been completed // This will take some seconds but happen in the background during browser idle time. // _this.mergeLocationSets = (objects) => { if (!Array.isArray(objects)) return Promise.reject('nothing to do'); // Resolve all locationSets -> geojson, processing data in chunks // // Because this will happen during idle callbacks, we want to choose a chunk size // that won't make the browser stutter too badly. LocationSets that are a simple // country coder include will resolve instantly, but ones that involve complex // include/exclude operations will take some milliseconds longer. // // Some discussion and performance results on these tickets: // https://github.com/ideditor/location-conflation/issues/26 // https://github.com/osmlab/name-suggestion-index/issues/4784#issuecomment-742003434 _queue = _queue.concat(utilArrayChunk(objects, 200)); if (!_inProcess) { _inProcess = processQueue() .then(() => { rebuildIndex(); _inProcess = null; return objects; }); } return _inProcess; }; // // `locationSetID` // Returns a locationSetID for a given locationSet (fallback to `+[Q2]`, world) // (The locationset doesn't necessarily need to be resolved to compute its `id`) // // Arguments // `locationSet`: A locationSet, e.g. `{ include: ['us'] }` // Returns // The locationSetID, e.g. `+[Q30]` // _this.locationSetID = (locationSet) => { let locationSetID; try { locationSetID = _loco.validateLocationSet(locationSet).id; } catch (err) { locationSetID = '+[Q2]'; // the world } return locationSetID; }; // // `feature` // Returns the resolved GeoJSON feature for a given locationSetID (fallback to 'world') // // Arguments // `locationSetID`: id of the form like `+[Q30]` (United States) // Returns // A GeoJSON feature: // { // type: 'Feature', // id: '+[Q30]', // properties: { id: '+[Q30]', area: 21817019.17, … }, // geometry: { … } // } _this.feature = (locationSetID) => _resolvedFeatures[locationSetID] || _resolvedFeatures['+[Q2]']; // // `locationsAt` // Find all the resolved locationSets valid at the given location. // Results include the area (in km²) to facilitate sorting. // // Arguments // `loc`: the [lon,lat] location to query, e.g. `[-74.4813, 40.7967]` // Returns // Object of locationSetIDs to areas (in km²) // { // "+[Q2]": 511207893.3958111, // "+[Q30]": 21817019.17, // "+[new_jersey.geojson]": 22390.77, // … // } // _this.locationsAt = (loc) => { let result = {}; (_wp(loc, true) || []).forEach(prop => result[prop.id] = prop.area); return result; }; // // `query` // Execute a query directly against which-polygon // https://github.com/mapbox/which-polygon // // Arguments // `loc`: the [lon,lat] location to query, // `multi`: `true` to return all results, `false` to return first result // Returns // Array of GeoJSON *properties* for the locationSet features that exist at `loc` // _this.query = (loc, multi) => _wp(loc, multi); // Direct access to the location-conflation resolver _this.loco = () => _loco; // Direct access to the which-polygon index _this.wp = () => _wp; return _this; }