Merge pull request #8305 from openstreetmap/nsi-v5

Name-suggestion-index v6
This commit is contained in:
Milos Brzakovic
2021-07-06 11:32:13 +02:00
committed by GitHub
38 changed files with 27438 additions and 16091 deletions
+7 -4
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -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;
}
-2
View File
@@ -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',
+1
View File
@@ -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';
+270
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -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;
},
+2 -2
View File
@@ -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
+11 -13
View File
@@ -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
View File
@@ -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
+7 -1
View File
@@ -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
View File
@@ -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
};
+676
View File
@@ -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
};
+9 -1
View File
@@ -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
View File
@@ -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');
}
+26 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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');
}
+12 -3
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+3 -1
View File
@@ -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 [
+1 -1
View File
@@ -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),
+5 -5
View File
@@ -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,
+36 -103
View File
@@ -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 */) }
);
}
+30 -29
View File
@@ -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));
}
}
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+149
View File
@@ -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
});
});
});
+15 -8
View File
@@ -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
});
});
-14
View File
@@ -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);
+3 -3
View File
@@ -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);
});
+2 -2
View File
@@ -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 = {};
+57 -4
View File
@@ -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);