mirror of
https://github.com/FoggedLens/iD.git
synced 2026-05-12 20:42:37 +02:00
Merge pull request #8305 from openstreetmap/nsi-v5
Name-suggestion-index v6
This commit is contained in:
+7
-4
@@ -1085,7 +1085,7 @@ a.hide-toggle {
|
||||
visibility: visible;
|
||||
}
|
||||
.preset-icon-container.showing-img *:not(.image-icon) {
|
||||
visibility: hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preset-icon-point-border path {
|
||||
@@ -3226,11 +3226,11 @@ div.full-screen > button:focus {
|
||||
|
||||
.issue-text .issue-icon {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 7px;
|
||||
padding: 2px 3px;
|
||||
}
|
||||
.issue-text .issue-message {
|
||||
flex: 1 1 auto;
|
||||
padding: 5px 0;
|
||||
padding: 4px 5px;
|
||||
}
|
||||
.issue-label .issue-autofix {
|
||||
flex: 0 0 auto;
|
||||
@@ -3547,6 +3547,9 @@ li.issue-fix-item button:not(.actionable) .fix-icon {
|
||||
.issue-container:not(.active) ul.issue-fix-list {
|
||||
display: none;
|
||||
}
|
||||
.issue-container:not(.active) .issue-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.issue-info {
|
||||
flex: 1 1 auto;
|
||||
@@ -5636,4 +5639,4 @@ li.hide + li.version .badge .tooltip .popover-arrow {
|
||||
height: 100px;
|
||||
width: 100px;
|
||||
color: #7092ff;
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1808,9 +1808,9 @@ en:
|
||||
message: "{feature} has incomplete tags"
|
||||
reference: "Some features should have additional tags."
|
||||
noncanonical_brand:
|
||||
message: "{feature} looks like a brand with nonstandard tags"
|
||||
message_incomplete: "{feature} looks like a brand with incomplete tags"
|
||||
reference: "All features of the same brand should be tagged the same way."
|
||||
message: "{feature} looks like a common feature with nonstandard tags"
|
||||
message_incomplete: "{feature} looks like a common feature with incomplete tags"
|
||||
reference: "Some features, for example retail chains or post offices, are expected to have certain tags in common."
|
||||
point_as_area:
|
||||
message: '{feature} should be a point, not an area'
|
||||
point_as_line:
|
||||
@@ -2374,4 +2374,4 @@ en:
|
||||
wikidata:
|
||||
identifier: "Identifier"
|
||||
label: "Label"
|
||||
description: "Description"
|
||||
description: "Description"
|
||||
+25138
-15110
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import { select as d3_select } from 'd3-selection';
|
||||
|
||||
import { t } from '../core/localizer';
|
||||
|
||||
import { fileFetcher as data } from './file_fetcher';
|
||||
import { fileFetcher } from './file_fetcher';
|
||||
import { localizer } from './localizer';
|
||||
import { prefs } from './preferences';
|
||||
import { coreHistory } from './history';
|
||||
@@ -447,7 +447,7 @@ export function coreContext() {
|
||||
context.assetPath = function(val) {
|
||||
if (!arguments.length) return _assetPath;
|
||||
_assetPath = val;
|
||||
data.assetPath(val);
|
||||
fileFetcher.assetPath(val);
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -455,7 +455,7 @@ export function coreContext() {
|
||||
context.assetMap = function(val) {
|
||||
if (!arguments.length) return _assetMap;
|
||||
_assetMap = val;
|
||||
data.assetMap(val);
|
||||
fileFetcher.assetMap(val);
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -576,13 +576,13 @@ export function coreContext() {
|
||||
|
||||
// if the container isn't available, e.g. when testing, don't load the UI
|
||||
if (!context.container().empty()) {
|
||||
_ui.ensureLoaded().then(function() {
|
||||
_photos.init();
|
||||
});
|
||||
_ui.ensureLoaded()
|
||||
.then(() => {
|
||||
_photos.init();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ export function coreFileFetcher() {
|
||||
'keepRight': 'data/keepRight.min.json',
|
||||
'languages': 'data/languages.min.json',
|
||||
'locales': 'locales/index.min.json',
|
||||
'nsi_brands': 'https://cdn.jsdelivr.net/npm/name-suggestion-index@4/dist/brands.min.json',
|
||||
'nsi_filters': 'https://cdn.jsdelivr.net/npm/name-suggestion-index@4/dist/filters.min.json',
|
||||
'oci_defaults': 'https://cdn.jsdelivr.net/npm/osm-community-index@4/dist/defaults.min.json',
|
||||
'oci_features': 'https://cdn.jsdelivr.net/npm/osm-community-index@4/dist/featureCollection.min.json',
|
||||
'oci_resources': 'https://cdn.jsdelivr.net/npm/osm-community-index@4/dist/resources.min.json',
|
||||
|
||||
@@ -4,6 +4,7 @@ export { coreDifference } from './difference';
|
||||
export { coreGraph } from './graph';
|
||||
export { coreHistory } from './history';
|
||||
export { coreLocalizer, t, localizer } from './localizer';
|
||||
export { coreLocations, locationManager } from './locations';
|
||||
export { prefs } from './preferences';
|
||||
export { coreTree } from './tree';
|
||||
export { coreUploader } from './uploader';
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import LocationConflation from '@ideditor/location-conflation';
|
||||
import whichPolygon from 'which-polygon';
|
||||
import calcArea from '@mapbox/geojson-area';
|
||||
import { utilArrayChunk } from '../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;
|
||||
}
|
||||
+738
-456
File diff suppressed because it is too large
Load Diff
@@ -176,10 +176,6 @@ osmEntity.prototype = {
|
||||
return Object.keys(this.tags).some(osmIsInterestingTag);
|
||||
},
|
||||
|
||||
hasWikidata: function() {
|
||||
return !!this.tags.wikidata || !!this.tags['brand:wikidata'];
|
||||
},
|
||||
|
||||
isHighwayIntersection: function() {
|
||||
return false;
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { presetCollection } from './collection';
|
||||
// `presetCategory` builds a `presetCollection` of member presets,
|
||||
// decorated with some extra methods for searching and matching geometry
|
||||
//
|
||||
export function presetCategory(categoryID, category, all) {
|
||||
export function presetCategory(categoryID, category, allPresets) {
|
||||
let _this = Object.assign({}, category); // shallow copy
|
||||
let _searchName; // cache
|
||||
let _searchNameStripped; // cache
|
||||
@@ -14,7 +14,7 @@ export function presetCategory(categoryID, category, all) {
|
||||
_this.id = categoryID;
|
||||
|
||||
_this.members = presetCollection(
|
||||
category.members.map(presetID => all.item(presetID)).filter(Boolean)
|
||||
(category.members || []).map(presetID => allPresets[presetID]).filter(Boolean)
|
||||
);
|
||||
|
||||
_this.geometry = _this.members.collection
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { utilArrayIntersection, utilArrayUniq } from '../util/array';
|
||||
import { locationManager } from '../core/locations';
|
||||
import { utilArrayUniq } from '../util/array';
|
||||
import { utilEditDistance } from '../util';
|
||||
|
||||
|
||||
@@ -46,7 +47,7 @@ export function presetCollection(collection) {
|
||||
return _this.item(id);
|
||||
};
|
||||
|
||||
_this.search = (value, geometry, countryCodes) => {
|
||||
_this.search = (value, geometry, loc) => {
|
||||
if (!value) return _this;
|
||||
|
||||
// don't remove diacritical characters since we're assuming the user is being intentional
|
||||
@@ -87,18 +88,11 @@ export function presetCollection(collection) {
|
||||
}
|
||||
|
||||
let pool = _this.collection;
|
||||
if (countryCodes) {
|
||||
if (typeof countryCodes === 'string') countryCodes = [countryCodes];
|
||||
countryCodes = countryCodes.map(code => code.toLowerCase());
|
||||
|
||||
pool = pool.filter(a => {
|
||||
if (a.locationSet) {
|
||||
if (a.locationSet.include && !utilArrayIntersection(a.locationSet.include, countryCodes).length) return false;
|
||||
if (a.locationSet.exclude && utilArrayIntersection(a.locationSet.exclude, countryCodes).length) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (Array.isArray(loc)) {
|
||||
const validLocations = locationManager.locationsAt(loc);
|
||||
pool = pool.filter(a => !a.locationSetID || validLocations[a.locationSetID]);
|
||||
}
|
||||
|
||||
const searchable = pool.filter(a => a.searchable !== false && a.suggestion !== true);
|
||||
const suggestions = pool.filter(a => a.suggestion === true);
|
||||
|
||||
@@ -124,6 +118,9 @@ export function presetCollection(collection) {
|
||||
const leadingTerms = searchable
|
||||
.filter(a => (a.terms() || []).some(leading));
|
||||
|
||||
const leadingSuggestionTerms = suggestions
|
||||
.filter(a => (a.terms() || []).some(leading));
|
||||
|
||||
// matches value to preset.tags values
|
||||
const leadingTagValues = searchable
|
||||
.filter(a => Object.values(a.tags || {}).filter(val => val !== '*').some(leading));
|
||||
@@ -155,6 +152,7 @@ export function presetCollection(collection) {
|
||||
leadingNamesStripped,
|
||||
leadingSuggestionsStripped,
|
||||
leadingTerms,
|
||||
leadingSuggestionTerms,
|
||||
leadingTagValues,
|
||||
similarName,
|
||||
similarSuggestions,
|
||||
|
||||
+69
-17
@@ -2,6 +2,8 @@ import { dispatch as d3_dispatch } from 'd3-dispatch';
|
||||
|
||||
import { prefs } from '../core/preferences';
|
||||
import { fileFetcher } from '../core/file_fetcher';
|
||||
import { locationManager } from '../core/locations';
|
||||
|
||||
import { osmNodeGeometriesForTags, osmSetAreaKeys, osmSetPointTags, osmSetVertexTags } from '../osm/tags';
|
||||
import { presetCategory } from './category';
|
||||
import { presetCollection } from './collection';
|
||||
@@ -51,9 +53,9 @@ export function presetIndex() {
|
||||
|
||||
// Index of presets by (geometry, tag key).
|
||||
let _geometryIndex = { point: {}, vertex: {}, line: {}, area: {}, relation: {} };
|
||||
|
||||
let _loadPromise;
|
||||
|
||||
|
||||
_this.ensureLoaded = () => {
|
||||
if (_loadPromise) return _loadPromise;
|
||||
|
||||
@@ -77,13 +79,27 @@ export function presetIndex() {
|
||||
};
|
||||
|
||||
|
||||
// `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 => {
|
||||
const f = d.fields[fieldID];
|
||||
let f = d.fields[fieldID];
|
||||
|
||||
if (f) { // add or replace
|
||||
_fields[fieldID] = presetField(fieldID, f);
|
||||
f = presetField(fieldID, f);
|
||||
if (f.locationSet) newLocationSets.push(f);
|
||||
_fields[fieldID] = f;
|
||||
|
||||
} else { // remove
|
||||
delete _fields[fieldID];
|
||||
}
|
||||
@@ -93,10 +109,14 @@ export function presetIndex() {
|
||||
// Merge Presets
|
||||
if (d.presets) {
|
||||
Object.keys(d.presets).forEach(presetID => {
|
||||
const p = d.presets[presetID];
|
||||
let p = d.presets[presetID];
|
||||
|
||||
if (p) { // add or replace
|
||||
const isAddable = !_addablePresetIDs || _addablePresetIDs.has(presetID);
|
||||
_presets[presetID] = presetPreset(presetID, p, isAddable, _fields, _presets);
|
||||
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()) {
|
||||
@@ -106,22 +126,23 @@ export function presetIndex() {
|
||||
});
|
||||
}
|
||||
|
||||
// Need to rebuild _this.collection before loading categories
|
||||
_this.collection = Object.values(_presets).concat(Object.values(_categories));
|
||||
|
||||
// Merge Categories
|
||||
if (d.categories) {
|
||||
Object.keys(d.categories).forEach(categoryID => {
|
||||
const c = d.categories[categoryID];
|
||||
let c = d.categories[categoryID];
|
||||
|
||||
if (c) { // add or replace
|
||||
_categories[categoryID] = presetCategory(categoryID, c, _this);
|
||||
c = presetCategory(categoryID, c, _presets);
|
||||
if (c.locationSet) newLocationSets.push(c);
|
||||
_categories[categoryID] = c;
|
||||
|
||||
} else { // remove
|
||||
delete _categories[categoryID];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Rebuild _this.collection after loading categories
|
||||
// Rebuild _this.collection after changing presets and categories
|
||||
_this.collection = Object.values(_presets).concat(Object.values(_categories));
|
||||
|
||||
// Merge Defaults
|
||||
@@ -155,6 +176,16 @@ export function presetIndex() {
|
||||
});
|
||||
});
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
@@ -166,17 +197,23 @@ export function presetIndex() {
|
||||
if (geometry === 'vertex' && entity.isOnAddressLine(resolver)) {
|
||||
geometry = 'point';
|
||||
}
|
||||
return _this.matchTags(entity.tags, geometry);
|
||||
const entityExtent = entity.extent(resolver);
|
||||
return _this.matchTags(entity.tags, geometry, entityExtent.center());
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
_this.matchTags = (tags, geometry) => {
|
||||
_this.matchTags = (tags, geometry, loc) => {
|
||||
const geometryMatches = _geometryIndex[geometry];
|
||||
let address;
|
||||
let best = -1;
|
||||
let match;
|
||||
|
||||
let validLocations;
|
||||
if (Array.isArray(loc)) {
|
||||
validLocations = locationManager.locationsAt(loc);
|
||||
}
|
||||
|
||||
for (let k in tags) {
|
||||
// If any part of an address is present, allow fallback to "Address" preset - #4353
|
||||
if (/^addr:/.test(k) && geometryMatches['addr:*']) {
|
||||
@@ -187,10 +224,17 @@ export function presetIndex() {
|
||||
if (!keyMatches) continue;
|
||||
|
||||
for (let i = 0; i < keyMatches.length; i++) {
|
||||
const score = keyMatches[i].matchScore(tags);
|
||||
const candidate = keyMatches[i];
|
||||
|
||||
// discard candidate preset if location is not valid at `loc`
|
||||
if (validLocations && candidate.locationSetID) {
|
||||
if (!validLocations[candidate.locationSetID]) continue;
|
||||
}
|
||||
|
||||
const score = candidate.matchScore(tags);
|
||||
if (score > best) {
|
||||
best = score;
|
||||
match = keyMatches[i];
|
||||
match = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,11 +357,12 @@ export function presetIndex() {
|
||||
_this.universal = () => _universal;
|
||||
|
||||
|
||||
_this.defaults = (geometry, n, startWithRecents) => {
|
||||
_this.defaults = (geometry, n, startWithRecents, loc) => {
|
||||
let recents = [];
|
||||
if (startWithRecents) {
|
||||
recents = _this.recent().matchGeometry(geometry).collection.slice(0, 4);
|
||||
}
|
||||
|
||||
let defaults;
|
||||
if (_addablePresetIDs) {
|
||||
defaults = Array.from(_addablePresetIDs).map(function(id) {
|
||||
@@ -329,9 +374,16 @@ export function presetIndex() {
|
||||
defaults = _defaults[geometry].collection.concat(_this.fallback(geometry));
|
||||
}
|
||||
|
||||
return presetCollection(
|
||||
let result = presetCollection(
|
||||
utilArrayUniq(recents.concat(defaults)).slice(0, n - 1)
|
||||
);
|
||||
|
||||
if (Array.isArray(loc)) {
|
||||
const validLocations = locationManager.locationsAt(loc);
|
||||
result.collection = result.collection.filter(a => !a.locationSetID || validLocations[a.locationSetID]);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// pass a Set of addable preset ids
|
||||
|
||||
@@ -155,7 +155,13 @@ export function presetPreset(presetID, preset, addable, allFields, allPresets) {
|
||||
|
||||
_this.reference = () => {
|
||||
// Lookup documentation on Wikidata...
|
||||
const qid = _this.tags.wikidata || _this.tags['brand:wikidata'] || _this.tags['operator:wikidata'];
|
||||
const qid = (
|
||||
_this.tags.wikidata ||
|
||||
_this.tags['flag:wikidata'] ||
|
||||
_this.tags['brand:wikidata'] ||
|
||||
_this.tags['network:wikidata'] ||
|
||||
_this.tags['operator:wikidata']
|
||||
);
|
||||
if (qid) {
|
||||
return { qid: qid };
|
||||
}
|
||||
|
||||
+33
-30
@@ -4,6 +4,7 @@ import serviceOsmose from './osmose';
|
||||
import serviceMapillary from './mapillary';
|
||||
import serviceMapRules from './maprules';
|
||||
import serviceNominatim from './nominatim';
|
||||
import serviceNsi from './nsi';
|
||||
import serviceOpenstreetcam from './openstreetcam';
|
||||
import serviceOsm from './osm';
|
||||
import serviceOsmWikibase from './osm_wikibase';
|
||||
@@ -14,36 +15,38 @@ import serviceWikidata from './wikidata';
|
||||
import serviceWikipedia from './wikipedia';
|
||||
|
||||
|
||||
export var services = {
|
||||
geocoder: serviceNominatim,
|
||||
keepRight: serviceKeepRight,
|
||||
improveOSM: serviceImproveOSM,
|
||||
osmose: serviceOsmose,
|
||||
mapillary: serviceMapillary,
|
||||
openstreetcam: serviceOpenstreetcam,
|
||||
osm: serviceOsm,
|
||||
osmWikibase: serviceOsmWikibase,
|
||||
maprules: serviceMapRules,
|
||||
streetside: serviceStreetside,
|
||||
taginfo: serviceTaginfo,
|
||||
vectorTile: serviceVectorTile,
|
||||
wikidata: serviceWikidata,
|
||||
wikipedia: serviceWikipedia
|
||||
export let services = {
|
||||
geocoder: serviceNominatim,
|
||||
keepRight: serviceKeepRight,
|
||||
improveOSM: serviceImproveOSM,
|
||||
osmose: serviceOsmose,
|
||||
mapillary: serviceMapillary,
|
||||
nsi: serviceNsi,
|
||||
openstreetcam: serviceOpenstreetcam,
|
||||
osm: serviceOsm,
|
||||
osmWikibase: serviceOsmWikibase,
|
||||
maprules: serviceMapRules,
|
||||
streetside: serviceStreetside,
|
||||
taginfo: serviceTaginfo,
|
||||
vectorTile: serviceVectorTile,
|
||||
wikidata: serviceWikidata,
|
||||
wikipedia: serviceWikipedia
|
||||
};
|
||||
|
||||
export {
|
||||
serviceKeepRight,
|
||||
serviceImproveOSM,
|
||||
serviceOsmose,
|
||||
serviceMapillary,
|
||||
serviceMapRules,
|
||||
serviceNominatim,
|
||||
serviceOpenstreetcam,
|
||||
serviceOsm,
|
||||
serviceOsmWikibase,
|
||||
serviceStreetside,
|
||||
serviceTaginfo,
|
||||
serviceVectorTile,
|
||||
serviceWikidata,
|
||||
serviceWikipedia
|
||||
};
|
||||
serviceKeepRight,
|
||||
serviceImproveOSM,
|
||||
serviceOsmose,
|
||||
serviceMapillary,
|
||||
serviceMapRules,
|
||||
serviceNominatim,
|
||||
serviceNsi,
|
||||
serviceOpenstreetcam,
|
||||
serviceOsm,
|
||||
serviceOsmWikibase,
|
||||
serviceStreetside,
|
||||
serviceTaginfo,
|
||||
serviceVectorTile,
|
||||
serviceWikidata,
|
||||
serviceWikipedia
|
||||
};
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
import { Matcher } from 'name-suggestion-index';
|
||||
import parseVersion from 'vparse';
|
||||
|
||||
import { fileFetcher, locationManager } from '../core';
|
||||
import { presetManager } from '../presets';
|
||||
|
||||
// Make very sure this resolves to iD's `package.json`
|
||||
// If you mess up the `../`s, the resolver may import another random package.json from somewhere else.
|
||||
import packageJSON from '../../package.json';
|
||||
|
||||
// This service contains all the code related to the **name-suggestion-index** (aka NSI)
|
||||
// NSI contains the most correct tagging for many commonly mapped features.
|
||||
// See https://github.com/osmlab/name-suggestion-index and https://nsi.guide
|
||||
|
||||
|
||||
// DATA
|
||||
|
||||
let _nsiStatus = 'loading'; // 'loading', 'ok', 'failed'
|
||||
let _nsi = {};
|
||||
|
||||
// Sometimes we can upgrade a feature tagged like `building=yes` to a better tag.
|
||||
const buildingPreset = {
|
||||
'building/commercial': true,
|
||||
'building/government': true,
|
||||
'building/hotel': true,
|
||||
'building/retail': true,
|
||||
'building/office': true,
|
||||
'building/supermarket': true,
|
||||
'building/yes': true
|
||||
};
|
||||
|
||||
// Exceptions to the namelike regexes.
|
||||
// Usually a tag suffix contains a language code like `name:en`, `name:ru`
|
||||
// but we want to exclude things like `operator:type`, `name:etymology`, etc..
|
||||
const notNames = /:(colou?r|type|forward|backward|left|right|etymology|pronunciation|wikipedia)$/i;
|
||||
|
||||
// Exceptions to the branchlike regexes
|
||||
const notBranches = /(coop|express|wireless|factory|outlet)/i;
|
||||
|
||||
|
||||
// PRIVATE FUNCTIONS
|
||||
|
||||
// `setNsiSources()`
|
||||
// Adds the sources to iD's filemap so we can start downloading data.
|
||||
//
|
||||
function setNsiSources() {
|
||||
const nsiVersion = packageJSON.dependencies['name-suggestion-index'] || packageJSON.devDependencies['name-suggestion-index'];
|
||||
const v = parseVersion(nsiVersion);
|
||||
const vMinor = `${v.major}.${v.minor}`;
|
||||
const sources = {
|
||||
'nsi_data': `https://cdn.jsdelivr.net/npm/name-suggestion-index@${vMinor}/dist/nsi.min.json`,
|
||||
'nsi_dissolved': `https://cdn.jsdelivr.net/npm/name-suggestion-index@${vMinor}/dist/dissolved.min.json`,
|
||||
'nsi_features': `https://cdn.jsdelivr.net/npm/name-suggestion-index@${vMinor}/dist/featureCollection.min.json`,
|
||||
'nsi_generics': `https://cdn.jsdelivr.net/npm/name-suggestion-index@${vMinor}/dist/genericWords.min.json`,
|
||||
'nsi_presets': `https://cdn.jsdelivr.net/npm/name-suggestion-index@${vMinor}/dist/presets/nsi-id-presets.min.json`,
|
||||
'nsi_replacements': `https://cdn.jsdelivr.net/npm/name-suggestion-index@${vMinor}/dist/replacements.min.json`,
|
||||
'nsi_trees': `https://cdn.jsdelivr.net/npm/name-suggestion-index@${vMinor}/dist/trees.min.json`
|
||||
};
|
||||
|
||||
let fileMap = fileFetcher.fileMap();
|
||||
for (const k in sources) {
|
||||
fileMap[k] = sources[k];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// `loadNsiPresets()`
|
||||
// Returns a Promise fulfilled when the presets have been downloaded and merged into iD.
|
||||
//
|
||||
function loadNsiPresets() {
|
||||
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(vals[0].presets).forEach(preset => preset.suggestion = true);
|
||||
|
||||
presetManager.merge({
|
||||
presets: vals[0].presets,
|
||||
featureCollection: vals[1]
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// `loadNsiData()`
|
||||
// Returns a Promise fulfilled when the other data have been downloaded and processed
|
||||
//
|
||||
function loadNsiData() {
|
||||
return (
|
||||
Promise.all([
|
||||
fileFetcher.get('nsi_data'),
|
||||
fileFetcher.get('nsi_dissolved'),
|
||||
fileFetcher.get('nsi_replacements'),
|
||||
fileFetcher.get('nsi_trees')
|
||||
])
|
||||
.then(vals => {
|
||||
_nsi = {
|
||||
data: vals[0].nsi, // the raw name-suggestion-index data
|
||||
dissolved: vals[1].dissolved, // list of dissolved items
|
||||
replacements: vals[2].replacements, // trivial old->new qid replacements
|
||||
trees: vals[3].trees, // metadata about trees, main tags
|
||||
kvt: new Map(), // Map (k -> Map (v -> t) )
|
||||
qids: new Map(), // Map (wd/wp tag values -> qids)
|
||||
ids: new Map() // Map (id -> NSI item)
|
||||
};
|
||||
|
||||
_nsi.matcher = new Matcher();
|
||||
_nsi.matcher.buildMatchIndex(_nsi.data);
|
||||
_nsi.matcher.buildLocationIndex(_nsi.data, locationManager.loco());
|
||||
|
||||
Object.keys(_nsi.data).forEach(tkv => {
|
||||
const category = _nsi.data[tkv];
|
||||
const parts = tkv.split('/', 3); // tkv = "tree/key/value"
|
||||
const t = parts[0];
|
||||
const k = parts[1];
|
||||
const v = parts[2];
|
||||
|
||||
// Build a reverse index of keys -> values -> trees present in the name-suggestion-index
|
||||
// Collect primary keys (e.g. "amenity", "craft", "shop", "man_made", "route", etc)
|
||||
// "amenity": {
|
||||
// "restaurant": "brands"
|
||||
// }
|
||||
let vmap = _nsi.kvt.get(k);
|
||||
if (!vmap) {
|
||||
vmap = new Map();
|
||||
_nsi.kvt.set(k, vmap);
|
||||
}
|
||||
vmap.set(v, t);
|
||||
|
||||
const tree = _nsi.trees[t]; // e.g. "brands", "operators"
|
||||
const mainTag = tree.mainTag; // e.g. "brand:wikidata", "operator:wikidata", etc
|
||||
|
||||
const items = category.items || [];
|
||||
items.forEach(item => {
|
||||
// Remember some useful things for later, cache NSI id -> item
|
||||
item.tkv = tkv;
|
||||
item.mainTag = mainTag;
|
||||
_nsi.ids.set(item.id, item);
|
||||
|
||||
// Cache Wikidata/Wikipedia values -> qid, for #6416
|
||||
const wd = item.tags[mainTag];
|
||||
const wp = item.tags[mainTag.replace('wikidata', 'wikipedia')];
|
||||
if (wd) _nsi.qids.set(wd, wd);
|
||||
if (wp && wd) _nsi.qids.set(wp, wd);
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// `gatherKVs()`
|
||||
// Gather all the k/v pairs that we will run through the NSI matcher.
|
||||
// An OSM tags object can contain anything, but only a few tags will be interesting to NSI.
|
||||
//
|
||||
// This function will return the interesting tag pairs like:
|
||||
// "amenity/restaurant", "man_made/flagpole"
|
||||
// and fallbacks like
|
||||
// "amenity/yes"
|
||||
// excluding things like
|
||||
// "highway", "surface", "ref", etc.
|
||||
//
|
||||
// Arguments
|
||||
// `tags`: `Object` containing the feature's OSM tags
|
||||
// Returns
|
||||
// `Object` containing kv pairs to test:
|
||||
// {
|
||||
// 'primary': Set(),
|
||||
// 'alternate': Set()
|
||||
// }
|
||||
//
|
||||
function gatherKVs(tags) {
|
||||
let primary = new Set();
|
||||
let alternate = new Set();
|
||||
|
||||
Object.keys(tags).forEach(osmkey => {
|
||||
const osmvalue = tags[osmkey];
|
||||
if (!osmvalue) return;
|
||||
|
||||
const vmap = _nsi.kvt.get(osmkey);
|
||||
if (!vmap) return;
|
||||
|
||||
if (osmvalue !== 'yes') {
|
||||
primary.add(`${osmkey}/${osmvalue}`);
|
||||
} else {
|
||||
alternate.add(`${osmkey}/${osmvalue}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Can we try a generic building fallback match? - See #6122, #7197
|
||||
// Only try this if we do a preset match and find nothing else remarkable about that building.
|
||||
// For example, a way with `building=yes` + `name=Westfield` may be a Westfield department store.
|
||||
// But a way with `building=yes` + `name=Westfield` + `public_transport=station` is a train station for a town named "Westfield"
|
||||
const preset = presetManager.matchTags(tags, 'area');
|
||||
if (buildingPreset[preset.id]) {
|
||||
alternate.add('building/yes');
|
||||
}
|
||||
|
||||
return { primary: primary, alternate: alternate };
|
||||
}
|
||||
|
||||
|
||||
// `identifyTree()`
|
||||
// NSI has a concept of trees: "brands", "operators", "flags", "transit".
|
||||
// The tree determines things like which tags are namelike, and which tags hold important wikidata.
|
||||
// This takes an Object of tags and tries to identify what tree to use.
|
||||
//
|
||||
// Arguments
|
||||
// `tags`: `Object` containing the feature's OSM tags
|
||||
// Returns
|
||||
// `string` the name of the tree if known
|
||||
// or 'unknown' if it could match several trees (e.g. amenity/yes)
|
||||
// or null if no match
|
||||
//
|
||||
function identifyTree(tags) {
|
||||
let unknown;
|
||||
let t;
|
||||
|
||||
// Check all tags
|
||||
Object.keys(tags).forEach(osmkey => {
|
||||
if (t) return; // found already
|
||||
|
||||
const osmvalue = tags[osmkey];
|
||||
if (!osmvalue) return;
|
||||
|
||||
const vmap = _nsi.kvt.get(osmkey);
|
||||
if (!vmap) return; // this key is not in nsi
|
||||
|
||||
if (osmvalue === 'yes') {
|
||||
unknown = 'unknown';
|
||||
} else {
|
||||
t = vmap.get(osmvalue);
|
||||
}
|
||||
});
|
||||
|
||||
return t || unknown || null;
|
||||
}
|
||||
|
||||
|
||||
// `gatherNames()`
|
||||
// Gather all the namelike values that we will run through the NSI matcher.
|
||||
// It will gather values primarily from tags `name`, `name:ru`, `flag:name`
|
||||
// and fallback to alternate tags like `brand`, `brand:ru`, `alt_name`
|
||||
//
|
||||
// Arguments
|
||||
// `tags`: `Object` containing the feature's OSM tags
|
||||
// Returns
|
||||
// `Object` containing namelike values to test:
|
||||
// {
|
||||
// 'primary': Set(),
|
||||
// 'fallbacks': Set()
|
||||
// }
|
||||
//
|
||||
function gatherNames(tags) {
|
||||
const empty = { primary: new Set(), alternate: new Set() };
|
||||
let primary = new Set();
|
||||
let alternate = new Set();
|
||||
let foundSemi = false;
|
||||
let testNameFragments = false;
|
||||
let patterns;
|
||||
|
||||
// Patterns for matching OSM keys that might contain namelike values.
|
||||
// These roughly correspond to the "trees" concept in name-suggestion-index,
|
||||
let t = identifyTree(tags);
|
||||
if (!t) return empty;
|
||||
|
||||
if (t === 'transit') {
|
||||
patterns = {
|
||||
primary: /^network$/i,
|
||||
alternate: /^(operator|operator:\w+|network:\w+|\w+_name|\w+_name:\w+)$/i
|
||||
};
|
||||
} else if (t === 'flags') {
|
||||
patterns = {
|
||||
primary: /^(flag:name|flag:name:\w+)$/i,
|
||||
alternate: /^(flag|flag:\w+|subject|subject:\w+)$/i // note: no `country`, we special-case it below
|
||||
};
|
||||
} else if (t === 'brands') {
|
||||
testNameFragments = true;
|
||||
patterns = {
|
||||
primary: /^(name|name:\w+)$/i,
|
||||
alternate: /^(brand|brand:\w+|operator|operator:\w+|\w+_name|\w+_name:\w+)/i,
|
||||
};
|
||||
} else if (t === 'operators') {
|
||||
testNameFragments = true;
|
||||
patterns = {
|
||||
primary: /^(name|name:\w+|operator|operator:\w+)$/i,
|
||||
alternate: /^(brand|brand:\w+|\w+_name|\w+_name:\w+)/i,
|
||||
};
|
||||
} else { // unknown/multiple
|
||||
testNameFragments = true;
|
||||
patterns = {
|
||||
primary: /^(name|name:\w+)$/i,
|
||||
alternate: /^(brand|brand:\w+|network|network:\w+|operator|operator:\w+|\w+_name|\w+_name:\w+)/i,
|
||||
};
|
||||
}
|
||||
|
||||
// Test `name` fragments, longest to shortest, to fit them into a "Name Branch" pattern.
|
||||
// e.g. "TUI ReiseCenter - Neuss Innenstadt" -> ["TUI", "ReiseCenter", "Neuss", "Innenstadt"]
|
||||
if (tags.name && testNameFragments) {
|
||||
const nameParts = tags.name.split(/[\s\-\/,.]/);
|
||||
for (let split = nameParts.length; split > 0; split--) {
|
||||
const name = nameParts.slice(0, split).join(' '); // e.g. "TUI ReiseCenter"
|
||||
primary.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Check all tags
|
||||
Object.keys(tags).forEach(osmkey => {
|
||||
const osmvalue = tags[osmkey];
|
||||
if (!osmvalue) return;
|
||||
|
||||
if (isNamelike(osmkey, 'primary')) {
|
||||
if (/;/.test(osmvalue)) {
|
||||
foundSemi = true;
|
||||
} else {
|
||||
primary.add(osmvalue);
|
||||
alternate.delete(osmvalue);
|
||||
}
|
||||
} else if (!primary.has(osmvalue) && isNamelike(osmkey, 'alternate')) {
|
||||
if (/;/.test(osmvalue)) {
|
||||
foundSemi = true;
|
||||
} else {
|
||||
alternate.add(osmvalue);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// For flags only, fallback to `country` tag only if no other namelike values were found.
|
||||
// See https://github.com/openstreetmap/iD/pull/8305#issuecomment-769174070
|
||||
if (tags.man_made === 'flagpole' && !primary.size && !alternate.size && !!tags.country) {
|
||||
const osmvalue = tags.country;
|
||||
if (/;/.test(osmvalue)) {
|
||||
foundSemi = true;
|
||||
} else {
|
||||
alternate.add(osmvalue);
|
||||
}
|
||||
}
|
||||
|
||||
// If any namelike value contained a semicolon, return empty set and don't try matching anything.
|
||||
if (foundSemi) {
|
||||
return empty;
|
||||
} else {
|
||||
return { primary: primary, alternate: alternate };
|
||||
}
|
||||
|
||||
function isNamelike(osmkey, which) {
|
||||
return patterns[which].test(osmkey) && !notNames.test(osmkey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// `gatherTuples()`
|
||||
// Generate all combinations of [key,value,name] that we want to test.
|
||||
// This prioritizes them so that the primary name and k/v pairs go first
|
||||
//
|
||||
// Arguments
|
||||
// `tryKVs`: `Object` containing primary and alternate k/v pairs to test
|
||||
// `tryNames`: `Object` containing primary and alternate names to test
|
||||
// Returns
|
||||
// `Array`: tuple objects ordered by priority
|
||||
//
|
||||
function gatherTuples(tryKVs, tryNames) {
|
||||
let tuples = [];
|
||||
['primary', 'alternate'].forEach(whichName => {
|
||||
// test names longest to shortest
|
||||
const arr = Array.from(tryNames[whichName]).sort((a, b) => b.length - a.length);
|
||||
arr.forEach(n => {
|
||||
['primary', 'alternate'].forEach(whichKV => {
|
||||
tryKVs[whichKV].forEach(kv => {
|
||||
const parts = kv.split('/', 2);
|
||||
const k = parts[0];
|
||||
const v = parts[1];
|
||||
tuples.push({ k: k, v: v, n: n });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return tuples;
|
||||
}
|
||||
|
||||
|
||||
// `_upgradeTags()`
|
||||
// Try to match a feature to a canonical record in name-suggestion-index
|
||||
// and upgrade the tags to match.
|
||||
//
|
||||
// Arguments
|
||||
// `tags`: `Object` containing the feature's OSM tags
|
||||
// `loc`: Location where this feature exists, as a [lon, lat]
|
||||
// Returns
|
||||
// `Object`: The tags the the feature should have, or `null` if no changes needed
|
||||
//
|
||||
function _upgradeTags(tags, loc) {
|
||||
let newTags = Object.assign({}, tags); // shallow copy
|
||||
let changed = false;
|
||||
|
||||
// Before anything, perform trivial Wikipedia/Wikidata replacements
|
||||
Object.keys(newTags).forEach(osmkey => {
|
||||
const matchTag = osmkey.match(/^(\w+:)?wikidata$/);
|
||||
if (matchTag) { // Look at '*:wikidata' tags
|
||||
const prefix = (matchTag[1] || '');
|
||||
const wd = newTags[osmkey];
|
||||
const replace = _nsi.replacements[wd]; // If it matches a QID in the replacement list...
|
||||
|
||||
if (replace && replace.wikidata !== undefined) { // replace or delete `*:wikidata` tag
|
||||
changed = true;
|
||||
if (replace.wikidata) {
|
||||
newTags[osmkey] = replace.wikidata;
|
||||
} else {
|
||||
delete newTags[osmkey];
|
||||
}
|
||||
}
|
||||
if (replace && replace.wikipedia !== undefined) { // replace or delete `*:wikipedia` tag
|
||||
changed = true;
|
||||
const wpkey = `${prefix}wikipedia`;
|
||||
if (replace.wikipedia) {
|
||||
newTags[wpkey] = replace.wikipedia;
|
||||
} else {
|
||||
delete newTags[wpkey];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Gather key/value tag pairs to try to match
|
||||
const tryKVs = gatherKVs(tags);
|
||||
if (!tryKVs.primary.size && !tryKVs.alternate.size) return changed ? newTags : null;
|
||||
|
||||
// Gather namelike tag values to try to match
|
||||
const tryNames = gatherNames(tags);
|
||||
|
||||
// Do `wikidata=*` or `wikipedia=*` tags identify this entity as a chain? - See #6416
|
||||
// If so, these tags can be swapped to e.g. `brand:wikidata`/`brand:wikipedia`.
|
||||
const foundQID = _nsi.qids.get(tags.wikidata) || _nsi.qids.get(tags.wikipedia);
|
||||
if (foundQID) tryNames.primary.add(foundQID); // matcher will recognize the Wikidata QID as name too
|
||||
|
||||
if (!tryNames.primary.size && !tryNames.alternate.size) return changed ? newTags : null;
|
||||
|
||||
// Order the [key,value,name] tuples - test primary before alternate
|
||||
const tuples = gatherTuples(tryKVs, tryNames);
|
||||
|
||||
for (let i = 0; i < tuples.length; i++) {
|
||||
const tuple = tuples[i];
|
||||
const hits = _nsi.matcher.match(tuple.k, tuple.v, tuple.n, loc); // Attempt to match an item in NSI
|
||||
|
||||
if (!hits || !hits.length) continue; // no match, try next tuple
|
||||
if (hits[0].match !== 'primary' && hits[0].match !== 'alternate') break; // a generic match, stop looking
|
||||
|
||||
// A match may contain multiple results, the first one is likely the best one for this location
|
||||
// e.g. `['pfk-a54c14', 'kfc-1ff19c', 'kfc-658eea']`
|
||||
let itemID, item;
|
||||
for (let j = 0; j < hits.length; j++) {
|
||||
const hit = hits[j];
|
||||
itemID = hit.itemID;
|
||||
if (_nsi.dissolved[itemID]) continue; // Don't upgrade to a dissolved item
|
||||
|
||||
item = _nsi.ids.get(itemID);
|
||||
if (!item) continue;
|
||||
const mainTag = item.mainTag; // e.g. `brand:wikidata`
|
||||
const itemQID = item.tags[mainTag]; // e.g. `brand:wikidata` qid
|
||||
const notQID = newTags[`not:${mainTag}`]; // e.g. `not:brand:wikidata` qid
|
||||
|
||||
if ( // Exceptions, skip this hit
|
||||
(!itemQID || itemQID === notQID) || // No `*:wikidata` or matched a `not:*:wikidata`
|
||||
(newTags.office && !item.tags.office) // feature may be a corporate office for a brand? - #6416
|
||||
) {
|
||||
item = null;
|
||||
continue; // continue looking
|
||||
} else {
|
||||
break; // use `item`
|
||||
}
|
||||
}
|
||||
|
||||
// Can't use any of these hits, try next tuple..
|
||||
if (!item) continue;
|
||||
|
||||
// At this point we have matched a canonical item and can suggest tag upgrades..
|
||||
const tkv = item.tkv;
|
||||
const parts = tkv.split('/', 3); // tkv = "tree/key/value"
|
||||
const k = parts[1];
|
||||
const v = parts[2];
|
||||
const category = _nsi.data[tkv];
|
||||
const properties = category.properties || {};
|
||||
|
||||
// Preserve some tags that we specifically don't want NSI to overwrite. ('^name', sometimes)
|
||||
const preserveTags = item.preserveTags || properties.preserveTags || [];
|
||||
let regexes = preserveTags.map(s => new RegExp(s, 'i'));
|
||||
regexes.push(/^building$/i, /^takeaway$/i);
|
||||
|
||||
let keepTags = {};
|
||||
Object.keys(newTags).forEach(osmkey => {
|
||||
if (regexes.some(regex => regex.test(osmkey))) {
|
||||
keepTags[osmkey] = newTags[osmkey];
|
||||
}
|
||||
});
|
||||
|
||||
// Remove any primary tags ("amenity", "craft", "shop", "man_made", "route", etc)
|
||||
// with a value like `amenity=yes` or `shop=yes`
|
||||
_nsi.kvt.forEach((vmap, k) => {
|
||||
if (newTags[k] === 'yes') delete newTags[k];
|
||||
});
|
||||
|
||||
// Replace mistagged `wikidata`/`wikipedia` with e.g. `brand:wikidata`/`brand:wikipedia`
|
||||
if (foundQID) {
|
||||
delete newTags.wikipedia;
|
||||
delete newTags.wikidata;
|
||||
}
|
||||
|
||||
// Do the tag upgrade
|
||||
Object.assign(newTags, item.tags, keepTags);
|
||||
|
||||
// Special `branch` splitting rules - IF..
|
||||
// - NSI is suggesting to replace `name`, AND
|
||||
// - `branch` doesn't already contain something, AND
|
||||
// - original name has not moved to an alternate name (e.g. "Dunkin' Donuts" -> "Dunkin'"), AND
|
||||
// - original name is "some name" + "some stuff", THEN
|
||||
// consider splitting `name` into `name`/`branch`..
|
||||
const origName = tags.name;
|
||||
const newName = newTags.name;
|
||||
if (newName && origName && newName !== origName && !newTags.branch) {
|
||||
const newNames = gatherNames(newTags);
|
||||
const newSet = new Set([...newNames.primary, ...newNames.alternate]);
|
||||
const isMoved = newSet.has(origName); // another tag holds the original name now
|
||||
|
||||
if (!isMoved) {
|
||||
// Test name fragments, longest to shortest, to fit them into a "Name Branch" pattern.
|
||||
// e.g. "TUI ReiseCenter - Neuss Innenstadt" -> ["TUI", "ReiseCenter", "Neuss", "Innenstadt"]
|
||||
const nameParts = origName.split(/[\s\-\/,.]/);
|
||||
for (let split = nameParts.length; split > 0; split--) {
|
||||
const name = nameParts.slice(0, split).join(' '); // e.g. "TUI ReiseCenter"
|
||||
const branch = nameParts.slice(split).join(' '); // e.g. "Neuss Innenstadt"
|
||||
const nameHits = _nsi.matcher.match(k, v, name, loc);
|
||||
if (!nameHits || !nameHits.length) continue; // no match, try next name fragment
|
||||
|
||||
if (nameHits.some(hit => hit.itemID === itemID)) { // matched the name fragment to the same itemID above
|
||||
if (branch) {
|
||||
if (notBranches.test(branch)) { // "branch" was detected but is noise ("factory outlet", etc)
|
||||
newTags.name = origName; // Leave `name` alone, this part of the name may be significant..
|
||||
} else {
|
||||
const branchHits = _nsi.matcher.match(k, v, branch, loc);
|
||||
if (branchHits && branchHits.length) { // if "branch" matched something else in NSI..
|
||||
if (branchHits[0].match === 'primary' || branchHits[0].match === 'alternate') { // if another brand! (e.g. "KFC - Taco Bell"?)
|
||||
return null; // bail out - can't suggest tags in this case
|
||||
} // else a generic (e.g. "gas", "cafe") - ignore
|
||||
} else { // "branch" is not noise and not something in NSI
|
||||
newTags.branch = branch; // Stick it in the `branch` tag..
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newTags;
|
||||
}
|
||||
|
||||
return changed ? newTags : null;
|
||||
}
|
||||
|
||||
|
||||
// `_isGenericName()`
|
||||
// Is the `name` tag generic?
|
||||
//
|
||||
// Arguments
|
||||
// `tags`: `Object` containing the feature's OSM tags
|
||||
// Returns
|
||||
// `true` if it is generic, `false` if not
|
||||
//
|
||||
function _isGenericName(tags) {
|
||||
const n = tags.name;
|
||||
if (!n) return false;
|
||||
|
||||
// tryNames just contains the `name` tag value and nothing else
|
||||
const tryNames = { primary: new Set([n]), alternate: new Set() };
|
||||
|
||||
// Gather key/value tag pairs to try to match
|
||||
const tryKVs = gatherKVs(tags);
|
||||
if (!tryKVs.primary.size && !tryKVs.alternate.size) return false;
|
||||
|
||||
// Order the [key,value,name] tuples - test primary before alternate
|
||||
const tuples = gatherTuples(tryKVs, tryNames);
|
||||
|
||||
for (let i = 0; i < tuples.length; i++) {
|
||||
const tuple = tuples[i];
|
||||
const hits = _nsi.matcher.match(tuple.k, tuple.v, tuple.n); // Attempt to match an item in NSI
|
||||
|
||||
// If we get a `excludeGeneric` hit, this is a generic name.
|
||||
if (hits && hits.length && hits[0].match === 'excludeGeneric') return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// PUBLIC INTERFACE
|
||||
|
||||
export default {
|
||||
|
||||
// `init()`
|
||||
// On init, start preparing the name-suggestion-index
|
||||
//
|
||||
init: () => {
|
||||
// Note: service.init is called immediately after the presetManager has started loading its data.
|
||||
// We expect to chain onto an unfulfilled promise here.
|
||||
setNsiSources();
|
||||
presetManager.ensureLoaded()
|
||||
.then(() => loadNsiPresets())
|
||||
.then(() => delay(100)) // wait briefly for locationSets to enter the locationManager queue
|
||||
.then(() => locationManager.mergeLocationSets([])) // wait for locationSets to resolve
|
||||
.then(() => loadNsiData())
|
||||
.then(() => _nsiStatus = 'ok')
|
||||
.catch(() => _nsiStatus = 'failed');
|
||||
|
||||
function delay(msec) {
|
||||
return new Promise(resolve => {
|
||||
window.setTimeout(resolve, msec);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// `reset()`
|
||||
// Reset is called when user saves data to OSM (does nothing here)
|
||||
//
|
||||
reset: () => {},
|
||||
|
||||
|
||||
// `status()`
|
||||
// To let other code know how it's going...
|
||||
//
|
||||
// Returns
|
||||
// `String`: 'loading', 'ok', 'failed'
|
||||
//
|
||||
status: () => _nsiStatus,
|
||||
|
||||
|
||||
// `isGenericName()`
|
||||
// Is the `name` tag generic?
|
||||
//
|
||||
// Arguments
|
||||
// `tags`: `Object` containing the feature's OSM tags
|
||||
// Returns
|
||||
// `true` if it is generic, `false` if not
|
||||
//
|
||||
isGenericName: (tags) => _isGenericName(tags),
|
||||
|
||||
|
||||
// `upgradeTags()`
|
||||
// Suggest tag upgrades.
|
||||
// This function will not modify the input tags, it makes a copy.
|
||||
//
|
||||
// Arguments
|
||||
// `tags`: `Object` containing the feature's OSM tags
|
||||
// `loc`: Location where this feature exists, as a [lon, lat]
|
||||
// Returns
|
||||
// `Object`: The tags the the feature should have, or `null` if no change
|
||||
//
|
||||
upgradeTags: (tags, loc) => _upgradeTags(tags, loc),
|
||||
|
||||
|
||||
// `cache()`
|
||||
// Direct access to the NSI cache, useful for testing or breaking things
|
||||
//
|
||||
// Returns
|
||||
// `Object`: the internal NSI cache
|
||||
//
|
||||
cache: () => _nsi
|
||||
};
|
||||
@@ -160,7 +160,15 @@ export function svgTagClasses() {
|
||||
}
|
||||
|
||||
// If this is a wikidata-tagged item, add a class for that..
|
||||
if (t.wikidata || t['brand:wikidata']) {
|
||||
var qid = (
|
||||
t.wikidata ||
|
||||
t['flag:wikidata'] ||
|
||||
t['brand:wikidata'] ||
|
||||
t['network:wikidata'] ||
|
||||
t['operator:wikidata']
|
||||
);
|
||||
|
||||
if (qid) {
|
||||
classes.push('tag-wikidata');
|
||||
}
|
||||
|
||||
|
||||
+12
-25
@@ -1,15 +1,14 @@
|
||||
import * as countryCoder from '@ideditor/country-coder';
|
||||
import { dispatch as d3_dispatch } from 'd3-dispatch';
|
||||
import { select as d3_select } from 'd3-selection';
|
||||
|
||||
import { t, localizer } from '../core/localizer';
|
||||
import { locationManager } from '../core/locations';
|
||||
import { svgIcon } from '../svg/icon';
|
||||
import { uiTooltip } from './tooltip';
|
||||
import { geoExtent } from '../geo/extent';
|
||||
import { uiFieldHelp } from './field_help';
|
||||
import { uiFields } from './fields';
|
||||
import { uiTagReference } from './tag_reference';
|
||||
import { utilArrayIntersection } from '../util/array';
|
||||
import { utilRebind, utilUniqueDomId } from '../util';
|
||||
|
||||
|
||||
@@ -29,6 +28,14 @@ export function uiField(context, presetField, entityIDs, options) {
|
||||
var _state = '';
|
||||
var _tags = {};
|
||||
|
||||
var _entityExtent;
|
||||
if (entityIDs && entityIDs.length) {
|
||||
_entityExtent = entityIDs.reduce(function(extent, entityID) {
|
||||
var entity = context.graph().entity(entityID);
|
||||
return extent.extend(entity.extent(context.graph()));
|
||||
}, geoExtent());
|
||||
}
|
||||
|
||||
var _locked = false;
|
||||
var _lockedTip = uiTooltip()
|
||||
.title(t.html('inspector.lock.suggestion', { label: field.label }))
|
||||
@@ -302,21 +309,9 @@ export function uiField(context, presetField, entityIDs, options) {
|
||||
return field.matchGeometry(context.graph().geometry(entityID));
|
||||
})) return false;
|
||||
|
||||
if (field.locationSet) {
|
||||
var extent = combinedEntityExtent();
|
||||
if (!extent) return true;
|
||||
|
||||
var center = extent.center();
|
||||
var codes = countryCoder.iso1A2Codes(center).map(function(code) {
|
||||
return code.toLowerCase();
|
||||
});
|
||||
|
||||
if (field.locationSet.include && !utilArrayIntersection(codes, field.locationSet.include).length) {
|
||||
return false;
|
||||
}
|
||||
if (field.locationSet.exclude && utilArrayIntersection(codes, field.locationSet.exclude).length) {
|
||||
return false;
|
||||
}
|
||||
if (entityIDs && _entityExtent && field.locationSetID) { // is field allowed in this location?
|
||||
var validLocations = locationManager.locationsAt(_entityExtent.center());
|
||||
if (!validLocations[field.locationSetID]) return false;
|
||||
}
|
||||
|
||||
var prerequisiteTag = field.prerequisiteTag;
|
||||
@@ -355,13 +350,5 @@ export function uiField(context, presetField, entityIDs, options) {
|
||||
};
|
||||
|
||||
|
||||
function combinedEntityExtent() {
|
||||
return entityIDs && entityIDs.length && entityIDs.reduce(function(extent, entityID) {
|
||||
var entity = context.graph().entity(entityID);
|
||||
return extent.extend(entity.extent(context.graph()));
|
||||
}, geoExtent());
|
||||
}
|
||||
|
||||
|
||||
return utilRebind(field, dispatch, 'on');
|
||||
}
|
||||
|
||||
@@ -34,11 +34,33 @@ export function uiFieldText(field, context) {
|
||||
.catch(function() { /* ignore */ });
|
||||
}
|
||||
|
||||
function i(selection) {
|
||||
var entity = _entityIDs.length && context.hasEntity(_entityIDs[0]);
|
||||
var preset = entity && presetManager.match(entity, context.graph());
|
||||
var isLocked = preset && preset.suggestion && field.id === 'brand';
|
||||
|
||||
function calcLocked() {
|
||||
// Protect certain fields that have a companion `*:wikidata` value
|
||||
var isLocked = (field.id === 'brand' || field.id === 'network' || field.id === 'operator' || field.id === 'flag') &&
|
||||
_entityIDs.length &&
|
||||
_entityIDs.some(function(entityID) {
|
||||
var entity = context.graph().hasEntity(entityID);
|
||||
if (!entity) return false;
|
||||
|
||||
// Features linked to Wikidata are likely important and should be protected
|
||||
if (entity.tags.wikidata) return true;
|
||||
|
||||
var preset = presetManager.match(entity, context.graph());
|
||||
var isSuggestion = preset && preset.suggestion;
|
||||
|
||||
// Lock the field if there is a value and a companion `*:wikidata` value
|
||||
var which = field.id; // 'brand', 'network', 'operator', 'flag'
|
||||
return isSuggestion && !!entity.tags[which] && !!entity.tags[which + ':wikidata'];
|
||||
});
|
||||
|
||||
field.locked(isLocked);
|
||||
}
|
||||
|
||||
|
||||
function i(selection) {
|
||||
calcLocked();
|
||||
var isLocked = field.locked();
|
||||
|
||||
var wrap = selection.selectAll('.form-field-input-wrap')
|
||||
.data([0]);
|
||||
|
||||
+25
-155
@@ -9,7 +9,7 @@ import { services } from '../../services';
|
||||
import { svgIcon } from '../../svg';
|
||||
import { uiTooltip } from '../tooltip';
|
||||
import { uiCombobox } from '../combobox';
|
||||
import { utilArrayUniq, utilEditDistance, utilGetSetValue, utilNoAuto, utilRebind, utilTotalExtent, utilUniqueDomId } from '../../util';
|
||||
import { utilArrayUniq, utilGetSetValue, utilNoAuto, utilRebind, utilTotalExtent, utilUniqueDomId } from '../../util';
|
||||
|
||||
var _languagesArray = [];
|
||||
|
||||
@@ -35,20 +35,11 @@ export function uiFieldLocalized(field, context) {
|
||||
.then(function(d) { _territoryLanguages = d; })
|
||||
.catch(function() { /* ignore */ });
|
||||
|
||||
|
||||
var allSuggestions = presetManager.collection.filter(function(p) {
|
||||
return p.suggestion === true;
|
||||
});
|
||||
|
||||
// reuse these combos
|
||||
var langCombo = uiCombobox(context, 'localized-lang')
|
||||
.fetcher(fetchLanguages)
|
||||
.minItems(0);
|
||||
|
||||
var brandCombo = uiCombobox(context, 'localized-brand')
|
||||
.canAutocomplete(false)
|
||||
.minItems(1);
|
||||
|
||||
var _selection = d3_select(null);
|
||||
var _multilingual = [];
|
||||
var _buttonTip = uiTooltip()
|
||||
@@ -83,34 +74,40 @@ export function uiFieldLocalized(field, context) {
|
||||
|
||||
|
||||
function calcLocked() {
|
||||
|
||||
// only lock the Name field
|
||||
var isLocked = field.id === 'name' &&
|
||||
// Protect name field for suggestion presets that don't display a brand/operator field
|
||||
var isLocked = (field.id === 'name') &&
|
||||
_entityIDs.length &&
|
||||
// lock the field if any feature needs it
|
||||
_entityIDs.some(function(entityID) {
|
||||
|
||||
var entity = context.graph().hasEntity(entityID);
|
||||
if (!entity) return false;
|
||||
|
||||
var original = context.graph().base().entities[_entityIDs[0]];
|
||||
var hasOriginalName = original && entity.tags.name && entity.tags.name === original.tags.name;
|
||||
// if the name was already edited manually then allow further editing
|
||||
if (!hasOriginalName) return false;
|
||||
|
||||
// features linked to Wikidata are likely important and should be protected
|
||||
// Features linked to Wikidata are likely important and should be protected
|
||||
if (entity.tags.wikidata) return true;
|
||||
|
||||
// assume the name has already been confirmed if its source has been researched
|
||||
// Assume the name has already been confirmed if its source has been researched
|
||||
if (entity.tags['name:etymology:wikidata']) return true;
|
||||
|
||||
// Lock the `name` if this is a suggestion preset that assigns the name,
|
||||
// and the preset does not display a `brand` or `operator` field.
|
||||
// (For presets like hotels, car dealerships, post offices, the `name` should remain editable)
|
||||
// see also similar logic in `outdated_tags.js`
|
||||
var preset = presetManager.match(entity, context.graph());
|
||||
var isSuggestion = preset && preset.suggestion;
|
||||
var showsBrand = preset && preset.originalFields.filter(function(d) {
|
||||
return d.id === 'brand';
|
||||
}).length;
|
||||
// protect standardized brand names
|
||||
return isSuggestion && !showsBrand;
|
||||
if (preset) {
|
||||
var isSuggestion = preset.suggestion;
|
||||
var fields = preset.fields();
|
||||
var showsBrandField = fields.some(function(d) { return d.id === 'brand'; });
|
||||
var showsOperatorField = fields.some(function(d) { return d.id === 'operator'; });
|
||||
var setsName = preset.addTags.name;
|
||||
var setsBrandWikidata = preset.addTags['brand:wikidata'];
|
||||
var setsOperatorWikidata = preset.addTags['operator:wikidata'];
|
||||
|
||||
return (isSuggestion && setsName && (
|
||||
(setsBrandWikidata && !showsBrandField) ||
|
||||
(setsOperatorWikidata && !showsOperatorField)
|
||||
));
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
field.locked(isLocked);
|
||||
@@ -152,8 +149,6 @@ export function uiFieldLocalized(field, context) {
|
||||
_selection = selection;
|
||||
calcLocked();
|
||||
var isLocked = field.locked();
|
||||
var singularEntity = _entityIDs.length === 1 && context.hasEntity(_entityIDs[0]);
|
||||
var preset = singularEntity && presetManager.match(singularEntity, context.graph());
|
||||
|
||||
var wrap = selection.selectAll('.form-field-input-wrap')
|
||||
.data([0]);
|
||||
@@ -176,39 +171,6 @@ export function uiFieldLocalized(field, context) {
|
||||
.call(utilNoAuto)
|
||||
.merge(input);
|
||||
|
||||
if (preset && field.id === 'name') {
|
||||
var pTag = preset.id.split('/', 2);
|
||||
var pKey = pTag[0];
|
||||
var pValue = pTag[1];
|
||||
|
||||
if (!preset.suggestion) {
|
||||
// Not a suggestion preset - Add a suggestions dropdown if it makes sense to.
|
||||
// This code attempts to determine if the matched preset is the
|
||||
// kind of preset that even can benefit from name suggestions..
|
||||
// - true = shops, cafes, hotels, etc. (also generic and fallback presets)
|
||||
// - false = churches, parks, hospitals, etc. (things not in the index)
|
||||
var isFallback = preset.isFallback();
|
||||
var goodSuggestions = allSuggestions.filter(function(s) {
|
||||
if (isFallback) return true;
|
||||
var sTag = s.id.split('/', 2);
|
||||
var sKey = sTag[0];
|
||||
var sValue = sTag[1];
|
||||
return pKey === sKey && (!pValue || pValue === sValue);
|
||||
});
|
||||
|
||||
// Show the suggestions.. If the user picks one, change the tags..
|
||||
if (allSuggestions.length && goodSuggestions.length) {
|
||||
input
|
||||
.on('blur.localized', checkBrandOnBlur)
|
||||
.call(brandCombo
|
||||
.fetcher(fetchBrandNames(preset, allSuggestions))
|
||||
.on('accept', acceptBrand)
|
||||
.on('cancel', cancelBrand)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input
|
||||
.classed('disabled', !!isLocked)
|
||||
.attr('readonly', isLocked || null)
|
||||
@@ -253,98 +215,6 @@ export function uiFieldLocalized(field, context) {
|
||||
|
||||
|
||||
|
||||
// We are not guaranteed to get an `accept` or `cancel` when blurring the field.
|
||||
// (This can happen if the user actives the combo, arrows down, and then clicks off to blur)
|
||||
// So compare the current field value against the suggestions one last time.
|
||||
function checkBrandOnBlur() {
|
||||
var latest = _entityIDs.length === 1 && context.hasEntity(_entityIDs[0]);
|
||||
if (!latest) return; // deleting the entity blurred the field?
|
||||
|
||||
var preset = presetManager.match(latest, context.graph());
|
||||
if (preset && preset.suggestion) return; // already accepted
|
||||
|
||||
var name = utilGetSetValue(input).trim();
|
||||
var matched = allSuggestions.filter(function(s) { return name === s.name(); });
|
||||
|
||||
if (matched.length === 1) {
|
||||
acceptBrand({ suggestion: matched[0] });
|
||||
} else {
|
||||
cancelBrand();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function acceptBrand(d) {
|
||||
|
||||
var entity = _entityIDs.length === 1 && context.hasEntity(_entityIDs[0]);
|
||||
|
||||
if (!d || !entity) {
|
||||
cancelBrand();
|
||||
return;
|
||||
}
|
||||
|
||||
var tags = entity.tags;
|
||||
var geometry = entity.geometry(context.graph());
|
||||
var removed = preset.unsetTags(tags, geometry);
|
||||
for (var k in tags) {
|
||||
tags[k] = removed[k]; // set removed tags to `undefined`
|
||||
}
|
||||
tags = d.suggestion.setTags(tags, geometry);
|
||||
utilGetSetValue(input, tags.name);
|
||||
dispatch.call('change', this, tags);
|
||||
}
|
||||
|
||||
|
||||
// user hit escape
|
||||
function cancelBrand() {
|
||||
var name = utilGetSetValue(input);
|
||||
dispatch.call('change', this, { name: name });
|
||||
}
|
||||
|
||||
|
||||
function fetchBrandNames(preset, suggestions) {
|
||||
var pTag = preset.id.split('/', 2);
|
||||
var pKey = pTag[0];
|
||||
var pValue = pTag[1];
|
||||
|
||||
return function(value, callback) {
|
||||
var results = [];
|
||||
if (value && value.length > 2) {
|
||||
for (var i = 0; i < suggestions.length; i++) {
|
||||
var s = suggestions[i];
|
||||
|
||||
// don't suggest brands from incompatible countries
|
||||
if (_countryCode && s.countryCodes &&
|
||||
s.countryCodes.indexOf(_countryCode) === -1) continue;
|
||||
|
||||
var sTag = s.id.split('/', 2);
|
||||
var sKey = sTag[0];
|
||||
var sValue = sTag[1];
|
||||
var subtitle = s.subtitle();
|
||||
var name = s.name();
|
||||
if (subtitle) name += ' – ' + subtitle;
|
||||
var dist = utilEditDistance(value, name.substring(0, value.length));
|
||||
var matchesPreset = (pKey === sKey && (!pValue || pValue === sValue));
|
||||
|
||||
if (dist < 1 || (matchesPreset && dist < 3)) {
|
||||
var obj = {
|
||||
value: s.name(),
|
||||
title: name,
|
||||
display: s.nameLabel() + (subtitle ? ' – ' + s.subtitleLabel() : ''),
|
||||
suggestion: s,
|
||||
dist: dist + (matchesPreset ? 0 : 1) // penalize if not matched preset
|
||||
};
|
||||
results.push(obj);
|
||||
}
|
||||
}
|
||||
results.sort(function(a, b) { return a.dist - b.dist; });
|
||||
}
|
||||
results = results.slice(0, 10);
|
||||
callback(results);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function addNew(d3_event) {
|
||||
d3_event.preventDefault();
|
||||
if (field.locked()) return;
|
||||
|
||||
+19
-20
@@ -1,9 +1,5 @@
|
||||
import { dispatch as d3_dispatch } from 'd3-dispatch';
|
||||
import * as countryCoder from '@ideditor/country-coder';
|
||||
|
||||
import {
|
||||
select as d3_select
|
||||
} from 'd3-selection';
|
||||
import { select as d3_select } from 'd3-selection';
|
||||
|
||||
import { presetManager } from '../presets';
|
||||
import { t, localizer } from '../core/localizer';
|
||||
@@ -20,6 +16,7 @@ import { utilKeybinding, utilNoAuto, utilRebind } from '../util';
|
||||
export function uiPresetList(context) {
|
||||
var dispatch = d3_dispatch('cancel', 'choose');
|
||||
var _entityIDs;
|
||||
var _currLoc;
|
||||
var _currentPresets;
|
||||
var _autofocus = false;
|
||||
|
||||
@@ -94,19 +91,16 @@ export function uiPresetList(context) {
|
||||
function inputevent() {
|
||||
var value = search.property('value');
|
||||
list.classed('filtered', value.length);
|
||||
var extent = combinedEntityExtent();
|
||||
var results, messageText;
|
||||
if (value.length && extent) {
|
||||
var center = extent.center();
|
||||
var countryCodes = countryCoder.iso1A2Codes(center);
|
||||
|
||||
results = presets.search(value, entityGeometries()[0], countryCodes);
|
||||
var results, messageText;
|
||||
if (value.length) {
|
||||
results = presets.search(value, entityGeometries()[0], _currLoc);
|
||||
messageText = t('inspector.results', {
|
||||
n: results.collection.length,
|
||||
search: value
|
||||
});
|
||||
} else {
|
||||
results = presetManager.defaults(entityGeometries()[0], 36, !context.inIntro());
|
||||
results = presetManager.defaults(entityGeometries()[0], 36, !context.inIntro(), _currLoc);
|
||||
messageText = t('inspector.choose');
|
||||
}
|
||||
list.call(drawList, results);
|
||||
@@ -147,7 +141,7 @@ export function uiPresetList(context) {
|
||||
var list = listWrap
|
||||
.append('div')
|
||||
.attr('class', 'preset-list')
|
||||
.call(drawList, presetManager.defaults(entityGeometries()[0], 36, !context.inIntro()));
|
||||
.call(drawList, presetManager.defaults(entityGeometries()[0], 36, !context.inIntro(), _currLoc));
|
||||
|
||||
context.features().on('change.preset-list', updateForFeatureHiddenState);
|
||||
}
|
||||
@@ -483,13 +477,25 @@ export function uiPresetList(context) {
|
||||
|
||||
presetList.entityIDs = function(val) {
|
||||
if (!arguments.length) return _entityIDs;
|
||||
|
||||
_entityIDs = val;
|
||||
_currLoc = null;
|
||||
|
||||
if (_entityIDs && _entityIDs.length) {
|
||||
// calculate current location
|
||||
const extent = _entityIDs.reduce(function(extent, entityID) {
|
||||
var entity = context.graph().entity(entityID);
|
||||
return extent.extend(entity.extent(context.graph()));
|
||||
}, geoExtent());
|
||||
_currLoc = extent.center();
|
||||
|
||||
// match presets
|
||||
var presets = _entityIDs.map(function(entityID) {
|
||||
return presetManager.match(context.entity(entityID), context.graph());
|
||||
});
|
||||
presetList.presets(presets);
|
||||
}
|
||||
|
||||
return presetList;
|
||||
};
|
||||
|
||||
@@ -522,12 +528,5 @@ export function uiPresetList(context) {
|
||||
});
|
||||
}
|
||||
|
||||
function combinedEntityExtent() {
|
||||
return _entityIDs.reduce(function(extent, entityID) {
|
||||
var entity = context.graph().entity(entityID);
|
||||
return extent.extend(entity.extent(context.graph()));
|
||||
}, geoExtent());
|
||||
}
|
||||
|
||||
return utilRebind(presetList, dispatch, 'on');
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { select as d3_select } from 'd3-selection';
|
||||
|
||||
import { prefs } from '../../core/preferences';
|
||||
import { svgIcon } from '../../svg/icon';
|
||||
import { utilArrayIdentical } from '../../util/array';
|
||||
import { t } from '../../core/localizer';
|
||||
import { utilHighlightEntities } from '../../util';
|
||||
import { uiSection } from '../section';
|
||||
|
||||
|
||||
export function uiSectionEntityIssues(context) {
|
||||
// Does the user prefer to expand the active issue? Useful for viewing tag diff.
|
||||
// Expand by default so first timers see it - #6408, #8143
|
||||
var preference = prefs('entity-issues.reference.expanded');
|
||||
var _expanded = preference === null ? true : (preference === 'true');
|
||||
|
||||
var _entityIDs = [];
|
||||
var _issues = [];
|
||||
var _activeIssueID;
|
||||
|
||||
|
||||
var section = uiSection('entity-issues', context)
|
||||
.shouldDisplay(function() {
|
||||
return _issues.length > 0;
|
||||
@@ -122,6 +129,8 @@ export function uiSectionEntityIssues(context) {
|
||||
var container = d3_select(this.parentNode.parentNode.parentNode);
|
||||
var info = container.selectAll('.issue-info');
|
||||
var isExpanded = info.classed('expanded');
|
||||
_expanded = !isExpanded;
|
||||
prefs('entity-issues.reference.expanded', _expanded); // update preference
|
||||
|
||||
if (isExpanded) {
|
||||
info
|
||||
@@ -151,9 +160,9 @@ export function uiSectionEntityIssues(context) {
|
||||
|
||||
containersEnter
|
||||
.append('div')
|
||||
.attr('class', 'issue-info')
|
||||
.style('max-height', '0')
|
||||
.style('opacity', '0')
|
||||
.attr('class', 'issue-info' + (_expanded ? ' expanded' : ''))
|
||||
.style('max-height', (_expanded ? null : '0'))
|
||||
.style('opacity', (_expanded ? '1' : '0'))
|
||||
.each(function(d) {
|
||||
if (typeof d.reference === 'function') {
|
||||
d3_select(this)
|
||||
|
||||
@@ -175,7 +175,7 @@ export function uiSectionValidationRules(context) {
|
||||
.property('value', degStr);
|
||||
|
||||
prefs('validate-square-degrees', degStr);
|
||||
context.validator().reloadUnsquareIssues();
|
||||
context.validator().revalidateUnsquare();
|
||||
}
|
||||
|
||||
function isRuleEnabled(d) {
|
||||
|
||||
+40
-45
@@ -1,12 +1,12 @@
|
||||
import { dispatch as d3_dispatch } from 'd3-dispatch';
|
||||
import { select as d3_select } from 'd3-selection';
|
||||
|
||||
import LocationConflation from '@ideditor/location-conflation';
|
||||
import whichPolygon from 'which-polygon';
|
||||
import { resolveStrings } from 'osm-community-index';
|
||||
|
||||
import { fileFetcher } from '../core/file_fetcher';
|
||||
import { locationManager } from '../core/locations';
|
||||
import { t, localizer } from '../core/localizer';
|
||||
|
||||
import { svgIcon } from '../svg/icon';
|
||||
import { uiDisclosure } from '../ui/disclosure';
|
||||
import { utilRebind } from '../util/rebind';
|
||||
@@ -25,40 +25,36 @@ export function uiSuccess(context) {
|
||||
function ensureOSMCommunityIndex() {
|
||||
const data = fileFetcher;
|
||||
return Promise.all([
|
||||
data.get('oci_resources'),
|
||||
data.get('oci_features'),
|
||||
data.get('oci_resources'),
|
||||
data.get('oci_defaults')
|
||||
])
|
||||
.then(vals => {
|
||||
if (_oci) return _oci;
|
||||
|
||||
const ociResources = vals[0].resources;
|
||||
const loco = new LocationConflation(vals[1]);
|
||||
let ociFeatures = {};
|
||||
// Merge Custom Features
|
||||
if (vals[0] && Array.isArray(vals[0].features)) {
|
||||
locationManager.mergeCustomGeoJSON(vals[0]);
|
||||
}
|
||||
|
||||
Object.values(ociResources).forEach(resource => {
|
||||
let feature;
|
||||
try {
|
||||
feature = loco.resolveLocationSet(resource.locationSet).feature;
|
||||
let ociFeature = ociFeatures[feature.id];
|
||||
if (!ociFeature) {
|
||||
ociFeature = JSON.parse(JSON.stringify(feature)); // deep clone
|
||||
ociFeature.properties.resourceIDs = new Set();
|
||||
ociFeatures[feature.id] = ociFeature;
|
||||
}
|
||||
ociFeature.properties.resourceIDs.add(resource.id);
|
||||
} catch (err) {
|
||||
/* ignore communities with an unresolvable locationSet */
|
||||
console.warn(`warning: skipping community resource ${resource.id}: ${err.message}`); // eslint-disable-line no-console
|
||||
}
|
||||
});
|
||||
|
||||
return _oci = {
|
||||
defaults: vals[2].defaults,
|
||||
features: ociFeatures,
|
||||
resources: ociResources,
|
||||
query: whichPolygon({ type: 'FeatureCollection', features: Object.values(ociFeatures) })
|
||||
};
|
||||
let ociResources = Object.values(vals[1].resources);
|
||||
if (ociResources.length) {
|
||||
// Resolve all locationSet features.
|
||||
return locationManager.mergeLocationSets(ociResources)
|
||||
.then(() => {
|
||||
_oci = {
|
||||
resources: ociResources,
|
||||
defaults: vals[2].defaults
|
||||
};
|
||||
return _oci;
|
||||
});
|
||||
} else {
|
||||
_oci = {
|
||||
resources: [], // no resources?
|
||||
defaults: vals[2].defaults
|
||||
};
|
||||
return _oci;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,24 +158,23 @@ export function uiSuccess(context) {
|
||||
// Get OSM community index features intersecting the map..
|
||||
ensureOSMCommunityIndex()
|
||||
.then(oci => {
|
||||
const loc = context.map().center();
|
||||
const validLocations = locationManager.locationsAt(loc);
|
||||
|
||||
// Gather the communities
|
||||
let communities = [];
|
||||
const properties = oci.query(context.map().center(), true) || [];
|
||||
oci.resources.forEach(resource => {
|
||||
let area = validLocations[resource.locationSetID];
|
||||
if (!area) return;
|
||||
|
||||
// Gather the communities from the result
|
||||
properties.forEach(props => {
|
||||
const resourceIDs = Array.from(props.resourceIDs);
|
||||
resourceIDs.forEach(resourceID => {
|
||||
let resource = oci.resources[resourceID];
|
||||
|
||||
// Resolve strings
|
||||
const localizer = (stringID) => t.html(`community.${stringID}`);
|
||||
resource.resolved = resolveStrings(resource, oci.defaults, localizer);
|
||||
|
||||
communities.push({
|
||||
area: props.area || Infinity,
|
||||
order: resource.order || 0,
|
||||
resource: resource
|
||||
});
|
||||
// Resolve strings
|
||||
const localizer = (stringID) => t.html(`community.${stringID}`);
|
||||
resource.resolved = resolveStrings(resource, oci.defaults, localizer);
|
||||
|
||||
communities.push({
|
||||
area: area,
|
||||
order: resource.order || 0,
|
||||
resource: resource
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+18
-10
@@ -243,21 +243,29 @@ export function utilDisplayType(id) {
|
||||
}
|
||||
|
||||
|
||||
export function utilDisplayLabel(entity, graphOrGeometry) {
|
||||
// `utilDisplayLabel`
|
||||
// Returns a string suitable for display
|
||||
// By default returns something like name/ref, fallback to preset type, fallback to OSM type
|
||||
// "Main Street" or "Tertiary Road"
|
||||
// If `verbose=true`, include both preset name and feature name.
|
||||
// "Tertiary Road Main Street"
|
||||
//
|
||||
export function utilDisplayLabel(entity, graphOrGeometry, verbose) {
|
||||
var result;
|
||||
var displayName = utilDisplayName(entity);
|
||||
if (displayName) {
|
||||
// use the display name if there is one
|
||||
return displayName;
|
||||
}
|
||||
var preset = typeof graphOrGeometry === 'string' ?
|
||||
presetManager.matchTags(entity.tags, graphOrGeometry) :
|
||||
presetManager.match(entity, graphOrGeometry);
|
||||
if (preset && preset.name()) {
|
||||
// use the preset name if there is a match
|
||||
return preset.name();
|
||||
var presetName = preset && (preset.suggestion ? preset.subtitle() : preset.name());
|
||||
|
||||
if (verbose) {
|
||||
result = [presetName, displayName].filter(Boolean).join(' ');
|
||||
} else {
|
||||
result = displayName || presetName;
|
||||
}
|
||||
// fallback to the display type (node/way/relation)
|
||||
return utilDisplayType(entity.id);
|
||||
|
||||
// Fallback to the OSM type (node/way/relation)
|
||||
return result || utilDisplayType(entity.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ export function validationHelpRequest(context) {
|
||||
severity: 'warning',
|
||||
message: function(context) {
|
||||
var entity = context.hasEntity(this.entityIds[0]);
|
||||
return entity ? t.html('issues.fixme_tag.message', { feature: utilDisplayLabel(entity, context.graph()) }) : '';
|
||||
return entity ? t.html('issues.fixme_tag.message', {
|
||||
feature: utilDisplayLabel(entity, context.graph(), true /* verbose */)
|
||||
}) : '';
|
||||
},
|
||||
dynamicFixes: function() {
|
||||
return [
|
||||
|
||||
@@ -35,7 +35,7 @@ export function validationIncompatibleSource() {
|
||||
message: function(context) {
|
||||
var entity = context.hasEntity(this.entityIds[0]);
|
||||
return entity ? t.html('issues.incompatible_source.' + invalidSource.id + '.feature.message', {
|
||||
feature: utilDisplayLabel(entity, context.graph())
|
||||
feature: utilDisplayLabel(entity, context.graph(), true /* verbose */)
|
||||
}) : '';
|
||||
},
|
||||
reference: getReference(invalidSource.id),
|
||||
|
||||
@@ -89,7 +89,7 @@ export function validationMismatchedGeometry() {
|
||||
message: function(context) {
|
||||
var entity = context.hasEntity(this.entityIds[0]);
|
||||
return entity ? t.html('issues.tag_suggests_area.message', {
|
||||
feature: utilDisplayLabel(entity, 'area'),
|
||||
feature: utilDisplayLabel(entity, 'area', true /* verbose */),
|
||||
tag: utilTagText({ tags: tagSuggestingArea })
|
||||
}) : '';
|
||||
},
|
||||
@@ -162,7 +162,7 @@ export function validationMismatchedGeometry() {
|
||||
message: function(context) {
|
||||
var entity = context.hasEntity(this.entityIds[0]);
|
||||
return entity ? t.html('issues.vertex_as_point.message', {
|
||||
feature: utilDisplayLabel(entity, 'vertex')
|
||||
feature: utilDisplayLabel(entity, 'vertex', true /* verbose */)
|
||||
}) : '';
|
||||
},
|
||||
reference: function showReference(selection) {
|
||||
@@ -185,7 +185,7 @@ export function validationMismatchedGeometry() {
|
||||
message: function(context) {
|
||||
var entity = context.hasEntity(this.entityIds[0]);
|
||||
return entity ? t.html('issues.point_as_vertex.message', {
|
||||
feature: utilDisplayLabel(entity, 'point')
|
||||
feature: utilDisplayLabel(entity, 'point', true /* verbose */)
|
||||
}) : '';
|
||||
},
|
||||
reference: function showReference(selection) {
|
||||
@@ -264,7 +264,7 @@ export function validationMismatchedGeometry() {
|
||||
message: function(context) {
|
||||
var entity = context.hasEntity(this.entityIds[0]);
|
||||
return entity ? t.html('issues.' + referenceId + '.message', {
|
||||
feature: utilDisplayLabel(entity, targetGeom)
|
||||
feature: utilDisplayLabel(entity, targetGeom, true /* verbose */)
|
||||
}) : '';
|
||||
},
|
||||
reference: function showReference(selection) {
|
||||
@@ -371,7 +371,7 @@ export function validationMismatchedGeometry() {
|
||||
message: function(context) {
|
||||
var entity = context.hasEntity(this.entityIds[0]);
|
||||
return entity ? t.html('issues.unclosed_multipolygon_part.message', {
|
||||
feature: utilDisplayLabel(entity, context.graph())
|
||||
feature: utilDisplayLabel(entity, context.graph(), true /* verbose */)
|
||||
}) : '';
|
||||
},
|
||||
reference: showReference,
|
||||
|
||||
@@ -1,57 +1,26 @@
|
||||
import { t } from '../core/localizer';
|
||||
import { matcher } from 'name-suggestion-index';
|
||||
import * as countryCoder from '@ideditor/country-coder';
|
||||
|
||||
import { presetManager } from '../presets';
|
||||
import { fileFetcher } from '../core/file_fetcher';
|
||||
import { actionChangePreset } from '../actions/change_preset';
|
||||
import { actionChangeTags } from '../actions/change_tags';
|
||||
import { actionUpgradeTags } from '../actions/upgrade_tags';
|
||||
import { fileFetcher } from '../core';
|
||||
import { presetManager } from '../presets';
|
||||
import { services } from '../services';
|
||||
import { osmIsOldMultipolygonOuterMember, osmOldMultipolygonOuterMemberOfRelation } from '../osm/multipolygon';
|
||||
import { utilDisplayLabel, utilTagDiff } from '../util';
|
||||
import { utilDisplayLabel, utilHashcode, utilTagDiff } from '../util';
|
||||
import { validationIssue, validationIssueFix } from '../core/validation';
|
||||
|
||||
|
||||
let _dataDeprecated;
|
||||
let _nsi;
|
||||
|
||||
export function validationOutdatedTags() {
|
||||
const type = 'outdated_tags';
|
||||
const nsiKeys = ['amenity', 'shop', 'tourism', 'leisure', 'office'];
|
||||
let _waitingForDeprecated = true;
|
||||
let _dataDeprecated;
|
||||
|
||||
// A concern here in switching to async data means that `_dataDeprecated`
|
||||
// and `_nsi` will not be available at first, so the data on early tiles
|
||||
// may not have tags validated fully.
|
||||
|
||||
// initialize deprecated tags array
|
||||
// fetch deprecated tags
|
||||
fileFetcher.get('deprecated')
|
||||
.then(d => _dataDeprecated = d)
|
||||
.catch(() => { /* ignore */ });
|
||||
|
||||
fileFetcher.get('nsi_brands')
|
||||
.then(d => {
|
||||
_nsi = {
|
||||
brands: d.brands,
|
||||
matcher: matcher(),
|
||||
wikidata: {},
|
||||
wikipedia: {}
|
||||
};
|
||||
|
||||
// initialize name-suggestion-index matcher
|
||||
_nsi.matcher.buildMatchIndex(d.brands);
|
||||
|
||||
// index all known wikipedia and wikidata tags
|
||||
Object.keys(d.brands).forEach(kvnd => {
|
||||
const brand = d.brands[kvnd];
|
||||
const wd = brand.tags['brand:wikidata'];
|
||||
const wp = brand.tags['brand:wikipedia'];
|
||||
if (wd) { _nsi.wikidata[wd] = kvnd; }
|
||||
if (wp) { _nsi.wikipedia[wp] = kvnd; }
|
||||
});
|
||||
|
||||
return _nsi;
|
||||
})
|
||||
.catch(() => { /* ignore */ });
|
||||
.catch(() => { /* ignore */ })
|
||||
.finally(() => _waitingForDeprecated = false);
|
||||
|
||||
|
||||
function oldTagIssues(entity, graph) {
|
||||
@@ -59,8 +28,9 @@ export function validationOutdatedTags() {
|
||||
let preset = presetManager.match(entity, graph);
|
||||
let subtype = 'deprecated_tags';
|
||||
if (!preset) return [];
|
||||
if (!entity.hasInterestingTags()) return [];
|
||||
|
||||
// upgrade preset..
|
||||
// Upgrade preset, if a replacement is available..
|
||||
if (preset.replacement) {
|
||||
const newPreset = presetManager.item(preset.replacement);
|
||||
graph = actionChangePreset(entity.id, preset, newPreset, true /* skip field defaults */)(graph);
|
||||
@@ -68,7 +38,7 @@ export function validationOutdatedTags() {
|
||||
preset = newPreset;
|
||||
}
|
||||
|
||||
// upgrade tags..
|
||||
// Upgrade deprecated tags..
|
||||
if (_dataDeprecated) {
|
||||
const deprecatedTags = entity.deprecatedTags(_dataDeprecated);
|
||||
if (deprecatedTags.length) {
|
||||
@@ -79,7 +49,7 @@ export function validationOutdatedTags() {
|
||||
}
|
||||
}
|
||||
|
||||
// add missing addTags..
|
||||
// Add missing addTags from the detected preset
|
||||
let newTags = Object.assign({}, entity.tags); // shallow copy
|
||||
if (preset.tags !== preset.addTags) {
|
||||
Object.keys(preset.addTags).forEach(k => {
|
||||
@@ -93,67 +63,27 @@ export function validationOutdatedTags() {
|
||||
});
|
||||
}
|
||||
|
||||
if (_nsi) {
|
||||
// Do `wikidata` or `wikipedia` identify this entity as a brand? #6416
|
||||
// If so, these tags can be swapped to `brand:wikidata`/`brand:wikipedia`
|
||||
let isBrand;
|
||||
if (newTags.wikidata) { // try matching `wikidata`
|
||||
isBrand = _nsi.wikidata[newTags.wikidata];
|
||||
}
|
||||
if (!isBrand && newTags.wikipedia) { // fallback to `wikipedia`
|
||||
isBrand = _nsi.wikipedia[newTags.wikipedia];
|
||||
}
|
||||
if (isBrand && !newTags.office) { // but avoid doing this for corporate offices
|
||||
if (newTags.wikidata) {
|
||||
newTags['brand:wikidata'] = newTags.wikidata;
|
||||
delete newTags.wikidata;
|
||||
}
|
||||
if (newTags.wikipedia) {
|
||||
newTags['brand:wikipedia'] = newTags.wikipedia;
|
||||
delete newTags.wikipedia;
|
||||
}
|
||||
// I considered setting `name` and other tags here, but they aren't unique per wikidata
|
||||
// (Q2759586 -> in USA "Papa John's", in Russia "Папа Джонс")
|
||||
// So users will really need to use a preset or assign `name` themselves.
|
||||
}
|
||||
|
||||
// try key/value|name match against name-suggestion-index
|
||||
if (newTags.name) {
|
||||
for (let i = 0; i < nsiKeys.length; i++) {
|
||||
const k = nsiKeys[i];
|
||||
if (!newTags[k]) continue;
|
||||
|
||||
const center = entity.extent(graph).center();
|
||||
const countryCode = countryCoder.iso1A2Code(center);
|
||||
const match = _nsi.matcher.matchKVN(k, newTags[k], newTags.name, countryCode && countryCode.toLowerCase());
|
||||
if (!match) continue;
|
||||
|
||||
// for now skip ambiguous matches (like Target~(USA) vs Target~(Australia))
|
||||
if (match.d) continue;
|
||||
|
||||
const brand = _nsi.brands[match.kvnd];
|
||||
if (brand && brand.tags['brand:wikidata'] &&
|
||||
brand.tags['brand:wikidata'] !== entity.tags['not:brand:wikidata']) {
|
||||
subtype = 'noncanonical_brand';
|
||||
|
||||
const keepTags = ['takeaway'].reduce((acc, k) => {
|
||||
if (newTags[k]) {
|
||||
acc[k] = newTags[k];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
nsiKeys.forEach(k => delete newTags[k]);
|
||||
Object.assign(newTags, brand.tags, keepTags);
|
||||
break;
|
||||
}
|
||||
// Attempt to match a canonical record in the name-suggestion-index.
|
||||
const nsi = services.nsi;
|
||||
let waitingForNsi = false;
|
||||
if (nsi) {
|
||||
waitingForNsi = (nsi.status() === 'loading');
|
||||
if (!waitingForNsi) {
|
||||
const loc = entity.extent(graph).center();
|
||||
const result = nsi.upgradeTags(newTags, loc);
|
||||
if (result) {
|
||||
newTags = result;
|
||||
subtype = 'noncanonical_brand';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let issues = [];
|
||||
issues.provisional = (_waitingForDeprecated || waitingForNsi);
|
||||
|
||||
// determine diff
|
||||
const tagDiff = utilTagDiff(oldTags, newTags);
|
||||
if (!tagDiff.length) return [];
|
||||
if (!tagDiff.length) return issues;
|
||||
|
||||
const isOnlyAddingTags = tagDiff.every(d => d.type === '+');
|
||||
|
||||
@@ -168,14 +98,14 @@ export function validationOutdatedTags() {
|
||||
// don't allow autofixing brand tags
|
||||
let autoArgs = subtype !== 'noncanonical_brand' ? [doUpgrade, t('issues.fix.upgrade_tags.annotation')] : null;
|
||||
|
||||
return [new validationIssue({
|
||||
issues.push(new validationIssue({
|
||||
type: type,
|
||||
subtype: subtype,
|
||||
severity: 'warning',
|
||||
message: showMessage,
|
||||
reference: showReference,
|
||||
entityIds: [entity.id],
|
||||
hash: JSON.stringify(tagDiff),
|
||||
hash: utilHashcode(JSON.stringify(tagDiff)),
|
||||
dynamicFixes: () => {
|
||||
return [
|
||||
new validationIssueFix({
|
||||
@@ -187,7 +117,8 @@ export function validationOutdatedTags() {
|
||||
})
|
||||
];
|
||||
}
|
||||
})];
|
||||
}));
|
||||
return issues;
|
||||
|
||||
|
||||
function doUpgrade(graph) {
|
||||
@@ -215,7 +146,9 @@ export function validationOutdatedTags() {
|
||||
if (subtype === 'noncanonical_brand' && isOnlyAddingTags) {
|
||||
messageID += '_incomplete';
|
||||
}
|
||||
return t.html(messageID, { feature: utilDisplayLabel(currEntity, context.graph()) });
|
||||
return t.html(messageID, {
|
||||
feature: utilDisplayLabel(currEntity, context.graph(), true /* verbose */)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -302,7 +235,7 @@ export function validationOutdatedTags() {
|
||||
if (!currMultipolygon) return '';
|
||||
|
||||
return t.html('issues.old_multipolygon.message',
|
||||
{ multipolygon: utilDisplayLabel(currMultipolygon, context.graph()) }
|
||||
{ multipolygon: utilDisplayLabel(currMultipolygon, context.graph(), true /* verbose */) }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
import { fileFetcher } from '../core/file_fetcher';
|
||||
import { t, localizer } from '../core/localizer';
|
||||
import { presetManager } from '../presets';
|
||||
import { validationIssue, validationIssueFix } from '../core/validation';
|
||||
import { actionChangeTags } from '../actions/change_tags';
|
||||
import { presetManager } from '../presets';
|
||||
import { services } from '../services';
|
||||
import { t, localizer } from '../core/localizer';
|
||||
import { validationIssue, validationIssueFix } from '../core/validation';
|
||||
|
||||
|
||||
let _discardNameRegexes = [];
|
||||
|
||||
export function validationSuspiciousName() {
|
||||
const type = 'suspicious_name';
|
||||
const keysToTestForGenericValues = [
|
||||
'aerialway', 'aeroway', 'amenity', 'building', 'craft', 'highway',
|
||||
'leisure', 'railway', 'man_made', 'office', 'shop', 'tourism', 'waterway'
|
||||
];
|
||||
|
||||
// A concern here in switching to async data means that `_nsiFilters` will not
|
||||
// be available at first, so the data on early tiles may not have tags validated fully.
|
||||
|
||||
fileFetcher.get('nsi_filters')
|
||||
.then(filters => {
|
||||
// known list of generic names (e.g. "bar")
|
||||
_discardNameRegexes = filters.discardNames
|
||||
.map(discardName => new RegExp(discardName, 'i'));
|
||||
})
|
||||
.catch(() => { /* ignore */ });
|
||||
let _waitingForNsi = false;
|
||||
|
||||
|
||||
function isDiscardedSuggestionName(lowercaseName) {
|
||||
return _discardNameRegexes.some(regex => regex.test(lowercaseName));
|
||||
// Attempt to match a generic record in the name-suggestion-index.
|
||||
function isGenericMatchInNsi(tags) {
|
||||
const nsi = services.nsi;
|
||||
if (nsi) {
|
||||
_waitingForNsi = (nsi.status() === 'loading');
|
||||
if (!_waitingForNsi) {
|
||||
return nsi.isGenericName(tags);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// test if the name is just the key or tag value (e.g. "park")
|
||||
|
||||
// Test if the name is just the key or tag value (e.g. "park")
|
||||
function nameMatchesRawTag(lowercaseName, tags) {
|
||||
for (let i = 0; i < keysToTestForGenericValues.length; i++) {
|
||||
let key = keysToTestForGenericValues[i];
|
||||
@@ -50,7 +47,7 @@ export function validationSuspiciousName() {
|
||||
|
||||
function isGenericName(name, tags) {
|
||||
name = name.toLowerCase();
|
||||
return nameMatchesRawTag(name, tags) || isDiscardedSuggestionName(name);
|
||||
return nameMatchesRawTag(name, tags) || isGenericMatchInNsi(tags);
|
||||
}
|
||||
|
||||
function makeGenericNameIssue(entityId, nameKey, genericName, langCode) {
|
||||
@@ -69,7 +66,7 @@ export function validationSuspiciousName() {
|
||||
},
|
||||
reference: showReference,
|
||||
entityIds: [entityId],
|
||||
hash: nameKey + '=' + genericName,
|
||||
hash: `${nameKey}=${genericName}`,
|
||||
dynamicFixes: function() {
|
||||
return [
|
||||
new validationIssueFix({
|
||||
@@ -115,7 +112,7 @@ export function validationSuspiciousName() {
|
||||
},
|
||||
reference: showReference,
|
||||
entityIds: [entityId],
|
||||
hash: nameKey + '=' + incorrectName,
|
||||
hash: `${nameKey}=${incorrectName}`,
|
||||
dynamicFixes: function() {
|
||||
return [
|
||||
new validationIssueFix({
|
||||
@@ -147,18 +144,21 @@ export function validationSuspiciousName() {
|
||||
|
||||
|
||||
let validation = function checkGenericName(entity) {
|
||||
// a generic name is okay if it's a known brand or entity
|
||||
if (entity.hasWikidata()) return [];
|
||||
const tags = entity.tags;
|
||||
|
||||
// a generic name is allowed if it's a known brand or entity
|
||||
const hasWikidata = (!!tags.wikidata || !!tags['brand:wikidata'] || !!tags['operator:wikidata']);
|
||||
if (hasWikidata) return [];
|
||||
|
||||
let issues = [];
|
||||
const notNames = (entity.tags['not:name'] || '').split(';');
|
||||
const notNames = (tags['not:name'] || '').split(';');
|
||||
|
||||
for (let key in entity.tags) {
|
||||
for (let key in tags) {
|
||||
const m = key.match(/^name(?:(?::)([a-zA-Z_-]+))?$/);
|
||||
if (!m) continue;
|
||||
|
||||
const langCode = m.length >= 2 ? m[1] : null;
|
||||
const value = entity.tags[key];
|
||||
const value = tags[key];
|
||||
if (notNames.length) {
|
||||
for (let i in notNames) {
|
||||
const notName = notNames[i];
|
||||
@@ -168,7 +168,8 @@ export function validationSuspiciousName() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isGenericName(value, entity.tags)) {
|
||||
if (isGenericName(value, tags)) {
|
||||
issues.provisional = _waitingForNsi; // retry later if we are waiting on NSI to finish loading
|
||||
issues.push(makeGenericNameIssue(entity.id, key, value, langCode));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,11 +76,13 @@ export function validationUnsquareWay(context) {
|
||||
severity: 'warning',
|
||||
message: function(context) {
|
||||
var entity = context.hasEntity(this.entityIds[0]);
|
||||
return entity ? t.html('issues.unsquare_way.message', { feature: utilDisplayLabel(entity, context.graph()) }) : '';
|
||||
return entity ? t.html('issues.unsquare_way.message', {
|
||||
feature: utilDisplayLabel(entity, context.graph())
|
||||
}) : '';
|
||||
},
|
||||
reference: showReference,
|
||||
entityIds: [entity.id],
|
||||
hash: JSON.stringify(autoArgs !== undefined) + degreeThreshold,
|
||||
hash: degreeThreshold,
|
||||
dynamicFixes: function() {
|
||||
return [
|
||||
new validationIssueFix({
|
||||
|
||||
+6
-4
@@ -42,8 +42,9 @@
|
||||
"translations": "node scripts/update_locales.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ideditor/country-coder": "^4.1.0",
|
||||
"@ideditor/location-conflation": "~0.9.0",
|
||||
"@ideditor/country-coder": "~5.0.3",
|
||||
"@ideditor/location-conflation": "~1.0.2",
|
||||
"@mapbox/geojson-area": "^0.2.2",
|
||||
"@mapbox/sexagesimal": "1.2.0",
|
||||
"@mapbox/togeojson": "0.16.0",
|
||||
"@mapbox/vector-tile": "^1.3.1",
|
||||
@@ -98,7 +99,7 @@
|
||||
"minimist": "^1.2.3",
|
||||
"mocha": "^7.0.1",
|
||||
"mocha-phantomjs-core": "^2.1.0",
|
||||
"name-suggestion-index": "4.0.2",
|
||||
"name-suggestion-index": "~6.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
"npm-run-all": "^4.0.0",
|
||||
"object-inspect": "1.9.0",
|
||||
@@ -117,7 +118,8 @@
|
||||
"smash": "0.0",
|
||||
"static-server": "^2.2.1",
|
||||
"svg-sprite": "1.5.0",
|
||||
"uglify-js": "~3.13.0"
|
||||
"uglify-js": "~3.13.0",
|
||||
"vparse": "~1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
|
||||
+2
-1
@@ -81,6 +81,7 @@
|
||||
'spec/core/file_fetcher.js',
|
||||
'spec/core/graph.js',
|
||||
'spec/core/history.js',
|
||||
'spec/core/locations.js',
|
||||
'spec/core/tree.js',
|
||||
'spec/core/validator.js',
|
||||
|
||||
@@ -206,4 +207,4 @@
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -0,0 +1,149 @@
|
||||
describe('iD.coreLocations', function() {
|
||||
var locationManager, loco;
|
||||
|
||||
var colorado = {
|
||||
type: 'Feature',
|
||||
id: 'colorado.geojson',
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-107.9197, 41.0039],
|
||||
[-102.0539, 41.0039],
|
||||
[-102.043, 36.9948],
|
||||
[-109.0425, 37.0003],
|
||||
[-109.048, 40.9984],
|
||||
[-107.9197, 41.0039]
|
||||
]
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var fc = { type: 'FeatureCollection', features: [colorado] };
|
||||
|
||||
|
||||
beforeEach(function() {
|
||||
// make a new one each time, so we aren't accidentally testing the "global" locationManager
|
||||
locationManager = iD.coreLocations();
|
||||
loco = locationManager.loco();
|
||||
});
|
||||
|
||||
|
||||
describe('#mergeCustomGeoJSON', function() {
|
||||
it('merges geojson into lococation-conflation cache', function() {
|
||||
locationManager.mergeCustomGeoJSON(fc);
|
||||
expect(loco._cache['colorado.geojson']).to.be.eql(colorado);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('#mergeLocationSets', function() {
|
||||
it('returns a promise rejected if not passed an array', function(done) {
|
||||
var prom = locationManager.mergeLocationSets({});
|
||||
prom
|
||||
.then(function() {
|
||||
throw new Error('This was supposed to fail, but somehow succeeded.');
|
||||
})
|
||||
.catch(function(err) {
|
||||
expect(/^nothing to do/.test(err)).to.be.true;
|
||||
})
|
||||
.finally(done);
|
||||
|
||||
window.setTimeout(function() {}, 20); // async - to let the promise settle in phantomjs
|
||||
});
|
||||
|
||||
it('resolves locationSets, assigning locationSetID', function(done) {
|
||||
var data = [
|
||||
{ id: 'world', locationSet: { include: ['001'] } },
|
||||
{ id: 'usa', locationSet: { include: ['usa'] } }
|
||||
];
|
||||
var prom = locationManager.mergeLocationSets(data);
|
||||
prom
|
||||
.then(function(data) {
|
||||
expect(data).to.be.a('array');
|
||||
expect(data[0]).locationSetID.to.eql('+[Q2]');
|
||||
expect(data[1]).locationSetID.to.eql('+[Q30]');
|
||||
})
|
||||
.finally(done);
|
||||
|
||||
window.setTimeout(function() {}, 20); // async - to let the promise settle in phantomjs
|
||||
});
|
||||
|
||||
it('resolves locationSets, falls back to world locationSetID on errror', function(done) {
|
||||
var data = [
|
||||
{ id: 'bogus1', locationSet: { foo: 'bar' } },
|
||||
{ id: 'bogus2', locationSet: { include: ['fake.geojson'] } }
|
||||
];
|
||||
var prom = locationManager.mergeLocationSets(data);
|
||||
prom
|
||||
.then(function(data) {
|
||||
expect(data).to.be.a('array');
|
||||
expect(data[0]).locationSetID.to.eql('+[Q2]');
|
||||
expect(data[1]).locationSetID.to.eql('+[Q2]');
|
||||
})
|
||||
.finally(done);
|
||||
|
||||
window.setTimeout(function() {}, 20); // async - to let the promise settle in phantomjs
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('#locationSetID', function() {
|
||||
it('calculates a locationSetID for a locationSet', function() {
|
||||
expect(locationManager.locationSetID({ include: ['usa'] })).to.be.eql('+[Q30]');
|
||||
});
|
||||
|
||||
it('falls back to the world locationSetID in case of errors', function() {
|
||||
expect(locationManager.locationSetID({ foo: 'bar' })).to.be.eql('+[Q2]');
|
||||
expect(locationManager.locationSetID({ include: ['fake.geojson'] })).to.be.eql('+[Q2]');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('#feature', function() {
|
||||
it('has the world locationSet pre-resolved', function() {
|
||||
var result = locationManager.feature('+[Q2]');
|
||||
expect(result).to.include({ type: 'Feature', id: '+[Q2]' });
|
||||
});
|
||||
|
||||
it('falls back to the world locationSetID in case of errors', function() {
|
||||
var result = locationManager.feature('fake');
|
||||
expect(result).to.include({ type: 'Feature', id: '+[Q2]' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('#locationsAt', function() {
|
||||
it('has the world locationSet pre-resolved', function() {
|
||||
var result1 = locationManager.locationsAt([-108.557, 39.065]); // Grand Junction
|
||||
expect(result1).to.be.an('object').that.has.all.keys('+[Q2]');
|
||||
var result2 = locationManager.locationsAt([-74.481, 40.797]); // Morristown
|
||||
expect(result2).to.be.an('object').that.has.all.keys('+[Q2]');
|
||||
var result3 = locationManager.locationsAt([13.575, 41.207,]); // Gaeta
|
||||
expect(result3).to.be.an('object').that.has.all.keys('+[Q2]');
|
||||
});
|
||||
|
||||
it('returns valid locations at a given lon,lat', function(done) {
|
||||
// setup, load colorado.geojson and resolve some locationSets
|
||||
locationManager.mergeCustomGeoJSON(fc);
|
||||
locationManager.mergeLocationSets([
|
||||
{ id: 'OSM-World', locationSet: { include: ['001'] } },
|
||||
{ id: 'OSM-USA', locationSet: { include: ['us'] } },
|
||||
{ id: 'OSM-Colorado', locationSet: { include: ['colorado.geojson'] } }
|
||||
])
|
||||
.then(function() {
|
||||
var result1 = locationManager.locationsAt([-108.557, 39.065]); // Grand Junction
|
||||
expect(result1).to.be.an('object').that.has.all.keys('+[Q2]', '+[Q30]', '+[colorado.geojson]');
|
||||
var result2 = locationManager.locationsAt([-74.481, 40.797]); // Morristown
|
||||
expect(result2).to.be.an('object').that.has.all.keys('+[Q2]', '+[Q30]');
|
||||
var result3 = locationManager.locationsAt([13.575, 41.207,]); // Gaeta
|
||||
expect(result3).to.be.an('object').that.has.all.keys('+[Q2]');
|
||||
})
|
||||
.finally(done);
|
||||
|
||||
window.setTimeout(function() {}, 20); // async - to let the promise settle in phantomjs
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -24,20 +24,27 @@ describe('iD.coreValidator', function () {
|
||||
expect(issues).to.have.lengthOf(0);
|
||||
});
|
||||
|
||||
it('populates issues on validate', function() {
|
||||
it('validate returns a promise, fulfilled when the validation has completed', function(done) {
|
||||
createInvalidWay();
|
||||
var validator = new iD.coreValidator(context);
|
||||
validator.init();
|
||||
var issues = validator.getIssues();
|
||||
expect(issues).to.have.lengthOf(0);
|
||||
|
||||
validator.validate();
|
||||
issues = validator.getIssues();
|
||||
expect(issues).to.have.lengthOf(1);
|
||||
var issue = issues[0];
|
||||
expect(issue.type).to.eql('missing_tag');
|
||||
expect(issue.entityIds).to.have.lengthOf(1);
|
||||
expect(issue.entityIds[0]).to.eql('w-1');
|
||||
var prom = validator.validate();
|
||||
prom
|
||||
.then(function() {
|
||||
issues = validator.getIssues();
|
||||
expect(issues).to.have.lengthOf(1);
|
||||
var issue = issues[0];
|
||||
expect(issue.type).to.eql('missing_tag');
|
||||
expect(issue.entityIds).to.have.lengthOf(1);
|
||||
expect(issue.entityIds[0]).to.eql('w-1');
|
||||
|
||||
})
|
||||
.finally(done);
|
||||
|
||||
window.setTimeout(function() {}, 20); // async - to let the promise settle in phantomjs
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -275,20 +275,6 @@ describe('iD.osmEntity', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#hasWikidata', function () {
|
||||
it('returns false if entity has no tags', function () {
|
||||
expect(iD.osmEntity().hasWikidata()).to.be.not.ok;
|
||||
});
|
||||
|
||||
it('returns true if entity has a wikidata tag', function () {
|
||||
expect(iD.osmEntity({ tags: { wikidata: 'Q18275868' } }).hasWikidata()).to.be.ok;
|
||||
});
|
||||
|
||||
it('returns true if entity has a brand:wikidata tag', function () {
|
||||
expect(iD.osmEntity({ tags: { 'brand:wikidata': 'Q18275868' } }).hasWikidata()).to.be.ok;
|
||||
});
|
||||
});
|
||||
|
||||
describe('#hasInterestingTags', function () {
|
||||
it('returns false if the entity has no tags', function () {
|
||||
expect(iD.osmEntity().hasInterestingTags()).to.equal(false);
|
||||
|
||||
@@ -9,17 +9,17 @@ describe('iD.presetCategory', function() {
|
||||
var residential = iD.presetPreset('highway/residential',
|
||||
{ tags: { highway: 'residential' }, geometry: ['line'] }
|
||||
);
|
||||
var all = iD.presetCollection([residential]);
|
||||
var allPresets = { 'highway/residential': residential };
|
||||
|
||||
|
||||
it('maps members names to preset instances', function() {
|
||||
var c = iD.presetCategory('road', category, all);
|
||||
var c = iD.presetCategory('road', category, allPresets);
|
||||
expect(c.members.collection[0]).to.eql(residential);
|
||||
});
|
||||
|
||||
describe('#matchGeometry', function() {
|
||||
it('matches the type of an entity', function() {
|
||||
var c = iD.presetCategory('road', category, all);
|
||||
var c = iD.presetCategory('road', category, allPresets);
|
||||
expect(c.matchGeometry('line')).to.eql(true);
|
||||
expect(c.matchGeometry('point')).to.eql(false);
|
||||
});
|
||||
|
||||
@@ -24,10 +24,10 @@ iD.fileFetcher.cache().preset_categories = {};
|
||||
iD.fileFetcher.cache().preset_defaults = {};
|
||||
iD.fileFetcher.cache().preset_fields = {};
|
||||
iD.fileFetcher.cache().preset_presets = {};
|
||||
|
||||
// Initializing `coreContext` initializes `_validator`, which tries loading:
|
||||
iD.fileFetcher.cache().deprecated = [];
|
||||
iD.fileFetcher.cache().nsi_brands = [];
|
||||
iD.fileFetcher.cache().nsi_filters = { discardNames: [] };
|
||||
|
||||
// Initializing `coreContext` initializes `_uploader`, which tries loading:
|
||||
iD.fileFetcher.cache().discarded = {};
|
||||
|
||||
|
||||
@@ -2,11 +2,39 @@ describe('iD.validations.suspicious_name', function () {
|
||||
var context;
|
||||
|
||||
before(function() {
|
||||
iD.fileFetcher.cache().nsi_filters = { discardNames: ['^stores?$'] };
|
||||
iD.services.nsi = iD.serviceNsi;
|
||||
iD.fileFetcher.cache().nsi_presets = { presets: {} };
|
||||
iD.fileFetcher.cache().nsi_features = { type: 'FeatureCollection', features: [] };
|
||||
iD.fileFetcher.cache().nsi_dissolved = { dissolved: {} };
|
||||
iD.fileFetcher.cache().nsi_replacements = { replacements: {} };
|
||||
|
||||
iD.fileFetcher.cache().nsi_trees = {
|
||||
trees: {
|
||||
brands: {
|
||||
mainTag: 'brand:wikidata'
|
||||
}
|
||||
}
|
||||
};
|
||||
iD.fileFetcher.cache().nsi_data = {
|
||||
nsi: {
|
||||
'brands/shop/supermarket': {
|
||||
properties: {
|
||||
path: 'brands/shop/supermarket',
|
||||
exclude: {
|
||||
generic: ['^(mini|super)?\\s?(market|mart|mercado)( municipal)?$' ],
|
||||
named: ['^(famiglia cooperativa|семейный)$']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
iD.fileFetcher.cache().nsi_generics = {
|
||||
genericWords: ['^stores?$']
|
||||
};
|
||||
});
|
||||
|
||||
after(function() {
|
||||
iD.fileFetcher.cache().nsi_filters = { discardNames: [] };
|
||||
delete iD.services.nsi;
|
||||
});
|
||||
|
||||
beforeEach(function() {
|
||||
@@ -86,8 +114,33 @@ describe('iD.validations.suspicious_name', function () {
|
||||
}, 20);
|
||||
});
|
||||
|
||||
it('flags feature with a known generic name', function(done) {
|
||||
createWay({ shop: 'supermarket', name: 'Store' });
|
||||
it('ignores feature matching excludeNamed pattern in name-suggestion-index', function(done) {
|
||||
createWay({ shop: 'supermarket', name: 'famiglia cooperativa' });
|
||||
var validator = iD.validationSuspiciousName(context);
|
||||
window.setTimeout(function() { // async, so data will be available
|
||||
var issues = validate(validator);
|
||||
expect(issues).to.have.lengthOf(0);
|
||||
done();
|
||||
}, 20);
|
||||
});
|
||||
|
||||
it('flags feature matching a excludeGeneric pattern in name-suggestion-index', function(done) {
|
||||
createWay({ shop: 'supermarket', name: 'super mercado' });
|
||||
var validator = iD.validationSuspiciousName(context);
|
||||
window.setTimeout(function() { // async, so data will be available
|
||||
var issues = validate(validator);
|
||||
expect(issues).to.have.lengthOf(1);
|
||||
var issue = issues[0];
|
||||
expect(issue.type).to.eql('suspicious_name');
|
||||
expect(issue.subtype).to.eql('generic_name');
|
||||
expect(issue.entityIds).to.have.lengthOf(1);
|
||||
expect(issue.entityIds[0]).to.eql('w-1');
|
||||
done();
|
||||
}, 20);
|
||||
});
|
||||
|
||||
it('flags feature matching a global exclude pattern in name-suggestion-index', function(done) {
|
||||
createWay({ shop: 'supermarket', name: 'store' });
|
||||
var validator = iD.validationSuspiciousName(context);
|
||||
window.setTimeout(function() { // async, so data will be available
|
||||
var issues = validate(validator);
|
||||
|
||||
Reference in New Issue
Block a user