Files
iD/modules/presets/index.js
Martin Raifer 5f1360ed0f don't suggest to "connect the ends" if a feature with area tags matches a line preset
For example, when a feature tagged as `highway=primary` (line preset) and `man_made=bridge` (area preset) is mapped as an unclosed way, converting it to an area (by closing the way by connecting the endpoints) does not improve the situation, as then the other tag doesn't fit to the geometry anymore.

closes #7037
2022-11-04 12:23:15 +01:00

691 lines
20 KiB
JavaScript

import { dispatch as d3_dispatch } from 'd3-dispatch';
import { prefs } from '../core/preferences';
import { fileFetcher } from '../core/file_fetcher';
import { locationManager } from '../core/LocationManager';
import { osmNodeGeometriesForTags, osmSetAreaKeys, osmSetLineTags, osmSetPointTags, osmSetVertexTags } from '../osm/tags';
import { presetCategory } from './category';
import { presetCollection } from './collection';
import { presetField } from './field';
import { presetPreset } from './preset';
import { utilArrayUniq, utilRebind } from '../util';
export { presetCategory };
export { presetCollection };
export { presetField };
export { presetPreset };
let _mainPresetIndex = presetIndex(); // singleton
export { _mainPresetIndex as presetManager };
//
// `presetIndex` wraps a `presetCollection`
// with methods for loading new data and returning defaults
//
export function presetIndex() {
const dispatch = d3_dispatch('favoritePreset', 'recentsChange');
const MAXRECENTS = 30;
// seed the preset lists with geometry fallbacks
const POINT = presetPreset('point', { name: 'Point', tags: {}, geometry: ['point', 'vertex'], matchScore: 0.1 } );
const LINE = presetPreset('line', { name: 'Line', tags: {}, geometry: ['line'], matchScore: 0.1 } );
const AREA = presetPreset('area', { name: 'Area', tags: { area: 'yes' }, geometry: ['area'], matchScore: 0.1 } );
const RELATION = presetPreset('relation', { name: 'Relation', tags: {}, geometry: ['relation'], matchScore: 0.1 } );
let _this = presetCollection([POINT, LINE, AREA, RELATION]);
let _presets = { point: POINT, line: LINE, area: AREA, relation: RELATION };
let _defaults = {
point: presetCollection([POINT]),
vertex: presetCollection([POINT]),
line: presetCollection([LINE]),
area: presetCollection([AREA]),
relation: presetCollection([RELATION])
};
let _fields = {};
let _categories = {};
let _universal = [];
let _addablePresetIDs = null; // Set of preset IDs that the user can add
let _recents;
let _favorites;
// Index of presets by (geometry, tag key).
let _geometryIndex = { point: {}, vertex: {}, line: {}, area: {}, relation: {} };
let _loadPromise;
_this.ensureLoaded = () => {
if (_loadPromise) return _loadPromise;
return _loadPromise = Promise.all([
fileFetcher.get('preset_categories'),
fileFetcher.get('preset_defaults'),
fileFetcher.get('preset_presets'),
fileFetcher.get('preset_fields')
])
.then(vals => {
_this.merge({
categories: vals[0],
defaults: vals[1],
presets: vals[2],
fields: vals[3]
});
osmSetAreaKeys(_this.areaKeys());
osmSetLineTags(_this.lineTags());
osmSetPointTags(_this.pointTags());
osmSetVertexTags(_this.vertexTags());
});
};
// `merge` accepts an object containing new preset data (all properties optional):
// {
// fields: {},
// presets: {},
// categories: {},
// defaults: {},
// featureCollection: {}
//}
_this.merge = (d) => {
let newLocationSets = [];
// Merge Fields
if (d.fields) {
Object.keys(d.fields).forEach(fieldID => {
let f = d.fields[fieldID];
if (f) { // add or replace
f = presetField(fieldID, f, _fields);
if (f.locationSet) newLocationSets.push(f);
_fields[fieldID] = f;
} else { // remove
delete _fields[fieldID];
}
});
}
// Merge Presets
if (d.presets) {
Object.keys(d.presets).forEach(presetID => {
let p = d.presets[presetID];
if (p) { // add or replace
const isAddable = !_addablePresetIDs || _addablePresetIDs.has(presetID);
p = presetPreset(presetID, p, isAddable, _fields, _presets);
if (p.locationSet) newLocationSets.push(p);
_presets[presetID] = p;
} else { // remove (but not if it's a fallback)
const existing = _presets[presetID];
if (existing && !existing.isFallback()) {
delete _presets[presetID];
}
}
});
}
// Merge Categories
if (d.categories) {
Object.keys(d.categories).forEach(categoryID => {
let c = d.categories[categoryID];
if (c) { // add or replace
c = presetCategory(categoryID, c, _presets);
if (c.locationSet) newLocationSets.push(c);
_categories[categoryID] = c;
} else { // remove
delete _categories[categoryID];
}
});
}
// Rebuild _this.collection after changing presets and categories
_this.collection = Object.values(_presets).concat(Object.values(_categories));
// Merge Defaults
if (d.defaults) {
Object.keys(d.defaults).forEach(geometry => {
const def = d.defaults[geometry];
if (Array.isArray(def)) { // add or replace
_defaults[geometry] = presetCollection(
def.map(id => _presets[id] || _categories[id]).filter(Boolean)
);
} else { // remove
delete _defaults[geometry];
}
});
}
// Rebuild universal fields array
_universal = Object.values(_fields).filter(field => field.universal);
// Reset all the preset fields - they'll need to be resolved again
Object.values(_presets).forEach(preset => preset.resetFields());
// Rebuild geometry index
_geometryIndex = { point: {}, vertex: {}, line: {}, area: {}, relation: {} };
_this.collection.forEach(preset => {
(preset.geometry || []).forEach(geometry => {
let g = _geometryIndex[geometry];
for (let key in preset.tags) {
g[key] = g[key] || {};
let value = preset.tags[key];
(g[key][value] = g[key][value] || []).push(preset);
}
});
});
// Merge Custom Features
if (d.featureCollection && Array.isArray(d.featureCollection.features)) {
locationManager.mergeCustomGeoJSON(d.featureCollection);
}
// Resolve all locationSet features.
if (newLocationSets.length) {
locationManager.mergeLocationSets(newLocationSets);
}
return _this;
};
_this.match = (entity, resolver) => {
return resolver.transient(entity, 'presetMatch', () => {
let geometry = entity.geometry(resolver);
// Treat entities on addr:interpolation lines as points, not vertices - #3241
if (geometry === 'vertex' && entity.isOnAddressLine(resolver)) {
geometry = 'point';
}
const entityExtent = entity.extent(resolver);
return _this.matchTags(entity.tags, geometry, entityExtent.center());
});
};
_this.matchTags = (tags, geometry, loc) => {
const keyIndex = _geometryIndex[geometry];
let bestScore = -1;
let bestMatch;
let matchCandidates = [];
for (let k in tags) {
let indexMatches = [];
let valueIndex = keyIndex[k];
if (!valueIndex) continue;
let keyValueMatches = valueIndex[tags[k]];
if (keyValueMatches) indexMatches.push(...keyValueMatches);
let keyStarMatches = valueIndex['*'];
if (keyStarMatches) indexMatches.push(...keyStarMatches);
if (indexMatches.length === 0) continue;
for (let i = 0; i < indexMatches.length; i++) {
const candidate = indexMatches[i];
const score = candidate.matchScore(tags);
if (score === -1){
continue;
}
matchCandidates.push({score, candidate});
if (score > bestScore) {
bestScore = score;
bestMatch = candidate;
}
}
}
if (bestMatch && bestMatch.locationSetID && bestMatch.locationSetID !== '+[Q2]' && Array.isArray(loc)){
const validHere = locationManager.locationSetsAt(loc);
if (!validHere[bestMatch.locationSetID]) {
matchCandidates.sort((a, b) => (a.score < b.score) ? 1 : -1);
for (let i = 0; i < matchCandidates.length; i++) {
const candidateScore = matchCandidates[i];
if (!candidateScore.candidate.locationSetID || validHere[candidateScore.candidate.locationSetID]) {
bestMatch = candidateScore.candidate;
bestScore = candidateScore.score;
break;
}
}
}
}
// If any part of an address is present, allow fallback to "Address" preset - #4353
if (!bestMatch || bestMatch.isFallback()) {
for (let k in tags){
if (/^addr:/.test(k) && keyIndex['addr:*'] && keyIndex['addr:*']['*']) {
bestMatch = keyIndex['addr:*']['*'][0];
break;
}
}
}
return bestMatch || _this.fallback(geometry);
};
_this.allowsVertex = (entity, resolver) => {
if (entity.type !== 'node') return false;
if (Object.keys(entity.tags).length === 0) return true;
return resolver.transient(entity, 'vertexMatch', () => {
// address lines allow vertices to act as standalone points
if (entity.isOnAddressLine(resolver)) return true;
const geometries = osmNodeGeometriesForTags(entity.tags);
if (geometries.vertex) return true;
if (geometries.point) return false;
// allow vertices for unspecified points
return true;
});
};
// Because of the open nature of tagging, iD will never have a complete
// list of tags used in OSM, so we want it to have logic like "assume
// that a closed way with an amenity tag is an area, unless the amenity
// is one of these specific types". This function computes a structure
// that allows testing of such conditions, based on the presets designated
// as as supporting (or not supporting) the area geometry.
//
// The returned object L is a keeplist/discardlist of tags. A closed way
// with a tag (k, v) is considered to be an area if `k in L && !(v in L[k])`
// (see `Way#isArea()`). In other words, the keys of L form the keeplist,
// and the subkeys form the discardlist.
_this.areaKeys = () => {
// The ignore list is for keys that imply lines. (We always add `area=yes` for exceptions)
const ignore = {
barrier: true,
highway: true,
footway: true,
railway: true,
junction: true,
type: true
};
let areaKeys = {};
// ignore name-suggestion-index and deprecated presets
const presets = _this.collection.filter(p => !p.suggestion && !p.replacement);
// keeplist
presets.forEach(p => {
const keys = p.tags && Object.keys(p.tags);
const key = keys && keys.length && keys[0]; // pick the first tag
if (!key) return;
if (ignore[key]) return;
if (p.geometry.indexOf('area') !== -1) { // probably an area..
areaKeys[key] = areaKeys[key] || {};
}
});
// discardlist
presets.forEach(p => {
let key;
for (key in p.addTags) {
// examine all addTags to get a better sense of what can be tagged on lines - #6800
const value = p.addTags[key];
if (key in areaKeys && // probably an area...
p.geometry.indexOf('line') !== -1 && // but sometimes a line
value !== '*') {
areaKeys[key][value] = true;
}
}
});
return areaKeys;
};
_this.lineTags = () => {
return _this.collection.filter((lineTags, d) => {
// ignore name-suggestion-index, deprecated, and generic presets
if (d.suggestion || d.replacement || d.searchable === false) return lineTags;
// only care about the primary tag
const keys = d.tags && Object.keys(d.tags);
const key = keys && keys.length && keys[0]; // pick the first tag
if (!key) return lineTags;
// if this can be a line
if (d.geometry.indexOf('line') !== -1) {
lineTags[key] = lineTags[key] || [];
lineTags[key].push(d.tags);
}
return lineTags;
}, {});
};
_this.pointTags = () => {
return _this.collection.reduce((pointTags, d) => {
// ignore name-suggestion-index, deprecated, and generic presets
if (d.suggestion || d.replacement || d.searchable === false) return pointTags;
// only care about the primary tag
const keys = d.tags && Object.keys(d.tags);
const key = keys && keys.length && keys[0]; // pick the first tag
if (!key) return pointTags;
// if this can be a point
if (d.geometry.indexOf('point') !== -1) {
pointTags[key] = pointTags[key] || {};
pointTags[key][d.tags[key]] = true;
}
return pointTags;
}, {});
};
_this.vertexTags = () => {
return _this.collection.reduce((vertexTags, d) => {
// ignore name-suggestion-index, deprecated, and generic presets
if (d.suggestion || d.replacement || d.searchable === false) return vertexTags;
// only care about the primary tag
const keys = d.tags && Object.keys(d.tags);
const key = keys && keys.length && keys[0]; // pick the first tag
if (!key) return vertexTags;
// if this can be a vertex
if (d.geometry.indexOf('vertex') !== -1) {
vertexTags[key] = vertexTags[key] || {};
vertexTags[key][d.tags[key]] = true;
}
return vertexTags;
}, {});
};
_this.field = (id) => _fields[id];
_this.universal = () => _universal;
_this.defaults = (geometry, n, startWithRecents, loc, extraPresets) => {
let recents = [];
if (startWithRecents) {
recents = _this.recent().matchGeometry(geometry).collection.slice(0, 4);
}
let defaults;
if (_addablePresetIDs) {
defaults = Array.from(_addablePresetIDs).map(function(id) {
var preset = _this.item(id);
if (preset && preset.matchGeometry(geometry)) return preset;
return null;
}).filter(Boolean);
} else {
defaults = _defaults[geometry].collection.concat(_this.fallback(geometry));
}
let result = presetCollection(
utilArrayUniq(recents.concat(defaults).concat(extraPresets || [])).slice(0, n - 1)
);
if (Array.isArray(loc)) {
const validHere = locationManager.locationSetsAt(loc);
result.collection = result.collection.filter(a => !a.locationSetID || validHere[a.locationSetID]);
}
return result;
};
// pass a Set of addable preset ids
_this.addablePresetIDs = function(val) {
if (!arguments.length) return _addablePresetIDs;
// accept and convert arrays
if (Array.isArray(val)) val = new Set(val);
_addablePresetIDs = val;
if (_addablePresetIDs) { // reset all presets
_this.collection.forEach(p => {
// categories aren't addable
if (p.addable) p.addable(_addablePresetIDs.has(p.id));
});
} else {
_this.collection.forEach(p => {
if (p.addable) p.addable(true);
});
}
return _this;
};
_this.recent = () => {
return presetCollection(
utilArrayUniq(_this.getRecents()
.map(d => d.preset)
.filter(d => d.searchable !== false))
);
};
function RibbonItem(preset, source) {
let item = {};
item.preset = preset;
item.source = source;
item.isFavorite = () => item.source === 'favorite';
item.isRecent = () => item.source === 'recent';
item.matches = (preset) => item.preset.id === preset.id;
item.minified = () => ({ pID: item.preset.id });
return item;
}
function ribbonItemForMinified(d, source) {
if (d && d.pID) {
const preset = _this.item(d.pID);
if (!preset) return null;
return RibbonItem(preset, source);
}
return null;
}
_this.getGenericRibbonItems = () => {
return ['point', 'line', 'area'].map(id => RibbonItem(_this.item(id), 'generic'));
};
_this.getAddable = () => {
if (!_addablePresetIDs) return [];
return _addablePresetIDs.map((id) => {
const preset = _this.item(id);
if (preset) return RibbonItem(preset, 'addable');
return null;
}).filter(Boolean);
};
function setRecents(items) {
_recents = items;
const minifiedItems = items.map(d => d.minified());
prefs('preset_recents', JSON.stringify(minifiedItems));
dispatch.call('recentsChange');
}
_this.getRecents = () => {
if (!_recents) {
// fetch from local storage
_recents = (JSON.parse(prefs('preset_recents')) || [])
.reduce((acc, d) => {
let item = ribbonItemForMinified(d, 'recent');
if (item && item.preset.addable()) acc.push(item);
return acc;
}, []);
}
return _recents;
};
_this.addRecent = (preset, besidePreset, after) => {
const recents = _this.getRecents();
const beforeItem = _this.recentMatching(besidePreset);
let toIndex = recents.indexOf(beforeItem);
if (after) toIndex += 1;
const newItem = RibbonItem(preset, 'recent');
recents.splice(toIndex, 0, newItem);
setRecents(recents);
};
_this.removeRecent = (preset) => {
const item = _this.recentMatching(preset);
if (item) {
let items = _this.getRecents();
items.splice(items.indexOf(item), 1);
setRecents(items);
}
};
_this.recentMatching = (preset) => {
const items = _this.getRecents();
for (let i in items) {
if (items[i].matches(preset)) {
return items[i];
}
}
return null;
};
_this.moveItem = (items, fromIndex, toIndex) => {
if (fromIndex === toIndex ||
fromIndex < 0 || toIndex < 0 ||
fromIndex >= items.length || toIndex >= items.length
) return null;
items.splice(toIndex, 0, items.splice(fromIndex, 1)[0]);
return items;
};
_this.moveRecent = (item, beforeItem) => {
const recents = _this.getRecents();
const fromIndex = recents.indexOf(item);
const toIndex = recents.indexOf(beforeItem);
const items = _this.moveItem(recents, fromIndex, toIndex);
if (items) setRecents(items);
};
_this.setMostRecent = (preset) => {
if (preset.searchable === false) return;
let items = _this.getRecents();
let item = _this.recentMatching(preset);
if (item) {
items.splice(items.indexOf(item), 1);
} else {
item = RibbonItem(preset, 'recent');
}
// remove the last recent (first in, first out)
while (items.length >= MAXRECENTS) {
items.pop();
}
// prepend array
items.unshift(item);
setRecents(items);
};
function setFavorites(items) {
_favorites = items;
const minifiedItems = items.map(d => d.minified());
prefs('preset_favorites', JSON.stringify(minifiedItems));
// call update
dispatch.call('favoritePreset');
}
_this.addFavorite = (preset, besidePreset, after) => {
const favorites = _this.getFavorites();
const beforeItem = _this.favoriteMatching(besidePreset);
let toIndex = favorites.indexOf(beforeItem);
if (after) toIndex += 1;
const newItem = RibbonItem(preset, 'favorite');
favorites.splice(toIndex, 0, newItem);
setFavorites(favorites);
};
_this.toggleFavorite = (preset) => {
const favs = _this.getFavorites();
const favorite = _this.favoriteMatching(preset);
if (favorite) {
favs.splice(favs.indexOf(favorite), 1);
} else {
// only allow 10 favorites
if (favs.length === 10) {
// remove the last favorite (last in, first out)
favs.pop();
}
// append array
favs.push(RibbonItem(preset, 'favorite'));
}
setFavorites(favs);
};
_this.removeFavorite = (preset) => {
const item = _this.favoriteMatching(preset);
if (item) {
const items = _this.getFavorites();
items.splice(items.indexOf(item), 1);
setFavorites(items);
}
};
_this.getFavorites = () => {
if (!_favorites) {
// fetch from local storage
let rawFavorites = JSON.parse(prefs('preset_favorites'));
if (!rawFavorites) {
rawFavorites = [];
prefs('preset_favorites', JSON.stringify(rawFavorites));
}
_favorites = rawFavorites.reduce((output, d) => {
const item = ribbonItemForMinified(d, 'favorite');
if (item && item.preset.addable()) output.push(item);
return output;
}, []);
}
return _favorites;
};
_this.favoriteMatching = (preset) => {
const favs = _this.getFavorites();
for (let index in favs) {
if (favs[index].matches(preset)) {
return favs[index];
}
}
return null;
};
return utilRebind(_this, dispatch, 'on');
}