Files
iD/modules/core/LocationManager.js

343 lines
11 KiB
JavaScript

import { LocationConflation } from '@rapideditor/location-conflation';
import whichPolygon from 'which-polygon';
import calcArea from '@mapbox/geojson-area';
const _loco = new LocationConflation(); // instance of a location-conflation resolver
/**
* `LocationManager` 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/rapideditor/location-conflation
* https://github.com/rapideditor/country-coder
*/
export class LocationManager {
/**
* @constructor
*/
constructor() {
this._wp = null; // A which-polygon index
this._resolved = new Map(); // Map (id -> GeoJSON feature)
this._knownLocationSets = new Map(); // Map (locationSetID -> Number area)
this._locationIncludedIn = new Map(); // Map (locationID -> Set(locationSetID) )
this._locationExcludedIn = new Map(); // Map (locationID -> Set(locationSetID) )
// pre-resolve the worldwide locationSet
const world = { locationSet: { include: ['Q2'] } };
this._resolveLocationSet(world);
this._rebuildIndex();
}
/**
* _validateLocationSet
* Pass an Object with a `locationSet` property.
* Validates the `locationSet` and sets a `locationSetID` property on the object.
* To avoid so much computation we only resolve the include and exclude regions, but not the locationSet itself.
*
* Use `_resolveLocationSet()` instead if you need to resolve geojson of locationSet, for example to render it.
* Note: You need to call `_rebuildIndex()` after you're all finished validating the locationSets.
*
* @param `obj` Object to check, it should have `locationSet` property
*/
_validateLocationSet(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
}
// Validate the locationSet only
// Resolve the include/excludes
const locationSetID = _loco.validateLocationSet(locationSet).id;
obj.locationSetID = locationSetID;
if (this._knownLocationSets.has(locationSetID)) return; // seen one like this before
let area = 0;
// Resolve and index the 'includes'
(locationSet.include || []).forEach(location => {
const locationID = _loco.validateLocation(location).id;
let geojson = this._resolved.get(locationID);
if (!geojson) { // first time seeing a location like this
geojson = _loco.resolveLocation(location).feature; // resolve to GeoJSON
this._resolved.set(locationID, geojson);
}
area += geojson.properties.area;
let s = this._locationIncludedIn.get(locationID);
if (!s) {
s = new Set();
this._locationIncludedIn.set(locationID, s);
}
s.add(locationSetID);
});
// Resolve and index the 'excludes'
(locationSet.exclude || []).forEach(location => {
const locationID = _loco.validateLocation(location).id;
let geojson = this._resolved.get(locationID);
if (!geojson) { // first time seeing a location like this
geojson = _loco.resolveLocation(location).feature; // resolve to GeoJSON
this._resolved.set(locationID, geojson);
}
area -= geojson.properties.area;
let s = this._locationExcludedIn.get(locationID);
if (!s) {
s = new Set();
this._locationExcludedIn.set(locationID, s);
}
s.add(locationSetID);
});
this._knownLocationSets.set(locationSetID, area);
} catch {
obj.locationSet = { include: ['Q2'] }; // default worldwide
obj.locationSetID = '+[Q2]';
}
}
/**
* _resolveLocationSet
* Does everything that `_validateLocationSet()` does, but then "resolves" the locationSet into GeoJSON.
* This step is a bit more computationally expensive, so really only needed if you intend to render the shape.
*
* Note: You need to call `_rebuildIndex()` after you're all finished validating the locationSets.
*
* @param `obj` Object to check, it should have `locationSet` property
*/
_resolveLocationSet(obj) {
this._validateLocationSet(obj);
if (this._resolved.has(obj.locationSetID)) return; // work was done already
try {
const result = _loco.resolveLocationSet(obj.locationSet);
const locationSetID = result.id;
obj.locationSetID = locationSetID;
if (!result.feature.geometry.coordinates.length || !result.feature.properties.area) {
throw new Error(`locationSet ${locationSetID} resolves to an empty feature.`);
}
let geojson = JSON.parse(JSON.stringify(result.feature)); // deep clone
geojson.id = locationSetID; // Important: always use the locationSet `id` (`+[Q30]`), not the feature `id` (`Q30`)
geojson.properties.id = locationSetID;
this._resolved.set(locationSetID, geojson);
} catch {
obj.locationSet = { include: ['Q2'] }; // default worldwide
obj.locationSetID = '+[Q2]';
}
}
/**
* _rebuildIndex
* Rebuilds the whichPolygon index with whatever features have been resolved into GeoJSON.
*/
_rebuildIndex() {
this._wp = whichPolygon({ features: [...this._resolved.values()] });
}
/**
* mergeCustomGeoJSON
* Accepts a 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": { … }
* }
* ]
* }
*
* @param `fc` FeatureCollection-like Object containing custom locations
*/
mergeCustomGeoJSON(fc) {
if (!fc || fc.type !== 'FeatureCollection' || !Array.isArray(fc.features)) return;
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; // insert directly into LocationConflations internal cache
});
}
/**
* mergeLocationSets
* Accepts an Array of Objects containing `locationSet` properties:
* [
* { id: 'preset1', locationSet: {…} },
* { id: 'preset2', locationSet: {…} },
* …
* ]
* After validating, the Objects will be decorated with a `locationSetID` property:
* [
* { id: 'preset1', locationSet: {…}, locationSetID: '+[Q2]' },
* { id: 'preset2', locationSet: {…}, locationSetID: '+[Q30]' },
* …
* ]
*
* @param `objects` Objects to check - they should have `locationSet` property
* @return Promise resolved true (this function used to be slow/async, now it's faster and sync)
*/
mergeLocationSets(objects) {
if (!Array.isArray(objects)) return Promise.reject('nothing to do');
objects.forEach(obj => this._validateLocationSet(obj));
this._rebuildIndex();
return Promise.resolve(objects);
}
/**
* 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`)
*
* @param `locationSet` A locationSet Object, e.g. `{ include: ['us'] }`
* @return String locationSetID, e.g. `+[Q30]`
*/
locationSetID(locationSet) {
let locationSetID;
try {
locationSetID = _loco.validateLocationSet(locationSet).id;
} catch {
locationSetID = '+[Q2]'; // the world
}
return locationSetID;
}
/**
* feature
* Returns the resolved GeoJSON feature for a given locationSetID (fallback to 'world')
* A GeoJSON feature:
* {
* type: 'Feature',
* id: '+[Q30]',
* properties: { id: '+[Q30]', area: 21817019.17, … },
* geometry: { … }
* }
*
* @param `locationSetID` String identifier, e.g. `+[Q30]`
* @return GeoJSON object (fallback to world)
*/
feature(locationSetID = '+[Q2]') {
const feature = this._resolved.get(locationSetID);
return feature || this._resolved.get('+[Q2]');
}
/**
* locationSetsAt
* Find all the locationSets valid at the given location.
* Results include the area (in km²) to facilitate sorting.
*
* Object of locationSetIDs to areas (in km²)
* {
* "+[Q2]": 511207893.3958111,
* "+[Q30]": 21817019.17,
* "+[new_jersey.geojson]": 22390.77,
* …
* }
*
* @param `loc` `[lon,lat]` location to query, e.g. `[-74.4813, 40.7967]`
* @return Object of locationSetIDs valid at given location
*/
locationSetsAt(loc) {
let result = {};
const hits = this._wp(loc, true) || [];
const thiz = this;
// locationSets
hits.forEach(prop => {
if (prop.id[0] !== '+') return; // skip - it's a location
const locationSetID = prop.id;
const area = thiz._knownLocationSets.get(locationSetID);
if (area) {
result[locationSetID] = area;
}
});
// locations included
hits.forEach(prop => {
if (prop.id[0] === '+') return; // skip - it's a locationset
const locationID = prop.id;
const included = thiz._locationIncludedIn.get(locationID);
(included || []).forEach(locationSetID => {
const area = thiz._knownLocationSets.get(locationSetID);
if (area) {
result[locationSetID] = area;
}
});
});
// locations excluded
hits.forEach(prop => {
if (prop.id[0] === '+') return; // skip - it's a locationset
const locationID = prop.id;
const excluded = thiz._locationExcludedIn.get(locationID);
(excluded || []).forEach(locationSetID => {
delete result[locationSetID];
});
});
return result;
}
// Direct access to the location-conflation resolver
loco() {
return _loco;
}
}
const _sharedLocationManager = new LocationManager();
export { _sharedLocationManager as locationManager };