mirror of
https://github.com/FoggedLens/iD.git
synced 2026-02-13 01:02:58 +00:00
343 lines
11 KiB
JavaScript
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 };
|
|
|