diff --git a/modules/core/context.js b/modules/core/context.js index 9427aaf6b..b9bfe958c 100644 --- a/modules/core/context.js +++ b/modules/core/context.js @@ -586,12 +586,19 @@ export function coreContext() { function loadNSIPresets() { - return fileFetcher.get('nsi_presets') - .then(d => { + return Promise.all([ + fileFetcher.get('nsi_presets'), + fileFetcher.get('nsi_features') + ]) + .then(vals => { // Add `suggestion=true` to all the nsi presets // The preset json schema doesn't include it, but the iD code still uses it - Object.values(d.presets).forEach(preset => preset.suggestion = true); - presetManager.merge({ presets: d.presets }); + Object.values(vals[0].presets).forEach(preset => preset.suggestion = true); + + presetManager.merge({ + presets: vals[0].presets, + featureCollection: vals[1] + }); }) .catch(() => { /* ignore */ }); } diff --git a/modules/presets/index.js b/modules/presets/index.js index 50a795955..a97ab7d0d 100644 --- a/modules/presets/index.js +++ b/modules/presets/index.js @@ -1,5 +1,8 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; +import LocationConflation from '@ideditor/location-conflation'; +import whichPolygon from 'which-polygon'; + import { prefs } from '../core/preferences'; import { fileFetcher } from '../core/file_fetcher'; import { osmNodeGeometriesForTags, osmSetAreaKeys, osmSetPointTags, osmSetVertexTags } from '../osm/tags'; @@ -7,7 +10,7 @@ import { presetCategory } from './category'; import { presetCollection } from './collection'; import { presetField } from './field'; import { presetPreset } from './preset'; -import { utilArrayUniq, utilRebind } from '../util'; +import { utilArrayChunk, utilArrayUniq, utilRebind } from '../util'; export { presetCategory }; export { presetCollection }; @@ -45,13 +48,20 @@ export function presetIndex() { let _fields = {}; let _categories = {}; let _universal = []; + let _customFeatures = {}; + let _resolvedFeatures = {}; // cache of all locationSet Features let _addablePresetIDs = null; // Set of preset IDs that the user can add let _recents; let _favorites; + let _deferred = new Set(); + let _queue = []; + // Index of presets by (geometry, tag key). let _geometryIndex = { point: {}, vertex: {}, line: {}, area: {}, relation: {} }; + let _loco; + let _featureIndex; let _loadPromise; _this.ensureLoaded = () => { @@ -77,13 +87,33 @@ export function presetIndex() { }; + // `merge` accepts an object containing new preset data (all properties optional): + // { + // fields: {}, + // presets: {}, + // categories: {}, + // defaults: {}, + // featureCollection: {} + //} _this.merge = (d) => { + + // Cancel any existing deferred work - we'll end up redoing it after this merge + _queue = []; + Array.from(_deferred).forEach(handle => { + window.cancelIdleCallback(handle); + _deferred.delete(handle); + }); + // Merge Fields if (d.fields) { Object.keys(d.fields).forEach(fieldID => { const f = d.fields[fieldID]; if (f) { // add or replace _fields[fieldID] = presetField(fieldID, f); + if (!_fields[fieldID].locationSet) { + _fields[fieldID].locationSet = { include: ['Q2'] }; // default worldwide + _fields[fieldID].locationSetID = '+[Q2]'; + } } else { // remove delete _fields[fieldID]; } @@ -97,6 +127,10 @@ export function presetIndex() { if (p) { // add or replace const isAddable = !_addablePresetIDs || _addablePresetIDs.has(presetID); _presets[presetID] = presetPreset(presetID, p, isAddable, _fields, _presets); + if (!_presets[presetID].locationSet) { + _presets[presetID].locationSet = { include: ['Q2'] }; // default worldwide + _presets[presetID].locationSetID = '+[Q2]'; + } } else { // remove (but not if it's a fallback) const existing = _presets[presetID]; if (existing && !existing.isFallback()) { @@ -155,7 +189,87 @@ export function presetIndex() { }); }); + // Merge Custom Features + if (d.featureCollection && Array.isArray(d.featureCollection.features)) { + d.featureCollection.features.forEach(feature => { + const featureID = feature.id || (feature.properties && feature.properties.id); + if (featureID) { // add or replace + _customFeatures[featureID] = feature; + } + }); + } + + // Replace LocationConflation resolver if new customFeatures have been added + // (Would be nice in a future version to be able to add new custom features to it, rather than replacing entirely) + if (!_loco || d.featureCollection) { + _loco = new LocationConflation({ type: 'FeatureCollection', features: Object.values(_customFeatures) }); + resolveLocationSet({ include: ['Q2'] }); // resolve the default "world" feature + } + + // Resolve all features -> geojson, processing data in chunks + let toResolve = Object.values(_presets).concat(Object.values(_fields)) + .filter(d => !d.locationSetID); + + _queue = utilArrayChunk(toResolve, 250); + + // Everything after here will be deferred. + processQueue() + .then(() => { // Rebuild feature index + _featureIndex = whichPolygon({ type: 'FeatureCollection', features: Object.values(_resolvedFeatures) }); + }); + return _this; + + + function processQueue() { + if (!_queue.length) return Promise.resolve(); + + const chunk = _queue.pop(); + return new Promise(resolvePromise => { + const handle = window.requestIdleCallback(() => { + _deferred.delete(handle); + resolveLocationSets(chunk); + resolvePromise(); + }); + _deferred.add(handle); + }) + .then(() => processQueue()); + } + + + function resolveLocationSets(items) { + if (!Array.isArray(items)) return; + items.forEach(item => { + let locationSet = item.locationSet || { include: ['Q2'] }; // fallback to world + let locationSetID; + + try { + locationSetID = resolveLocationSet(locationSet); + } catch (err) { + locationSet = { include: ['Q2'] }; // fallback to world + locationSetID = '+[Q2]'; + } + // store this info with the preset/field + item.locationSet = locationSet; + item.locationSetID = locationSetID; + }); + } + + function resolveLocationSet(locationSet) { + const resolved = _loco.resolveLocationSet(locationSet); + const locationSetID = resolved.id; + 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 + } + return locationSetID; + } + };