Files
iD/modules/ui/fields/address.js
Martin Raifer 6798f33598 link label of address field to housenumber input element
this makes the most sense, as it should be almost always present in the address field and be the most "unique/important" value to edit for an address

closes #11004
2025-04-29 14:40:35 +02:00

493 lines
16 KiB
JavaScript

import { dispatch as d3_dispatch } from 'd3-dispatch';
import { select as d3_select } from 'd3-selection';
import * as countryCoder from '@rapideditor/country-coder';
import { presetManager } from '../../presets';
import { fileFetcher } from '../../core/file_fetcher';
import { geoChooseEdge, geoSphericalDistance, geoPolygonContainsPolygon, geoPointInPolygon } from '../../geo';
import { uiCombobox } from '../combobox';
import { utilArrayUniqBy, utilGetSetValue, utilNoAuto, utilRebind, utilTotalExtent, utilTriggerEvent } from '../../util';
import { t } from '../../core/localizer';
export function uiFieldAddress(field, context) {
var dispatch = d3_dispatch('change');
var _selection = d3_select(null);
var _wrap = d3_select(null);
var addrField = presetManager.field('address'); // needed for placeholder strings
var _entityIDs = [];
var _tags;
var _countryCode;
var _addressFormats = [{
format: [
['housenumber', 'street'],
['city', 'postcode']
]
}];
fileFetcher.get('address_formats')
.then(function(d) {
_addressFormats = d;
if (!_selection.empty()) {
_selection.call(address);
}
})
.catch(function() { /* ignore */ });
function getNear(isAddressable, type, searchRadius, resultProp) {
var extent = combinedEntityExtent();
var l = extent.center();
var box = extent.padByMeters(searchRadius);
var features = context.history().intersects(box)
.filter(isAddressable)
.map(d => {
let dist = geoSphericalDistance(d.extent(context.graph()).center(), l);
if (d.geometry(context.graph()) === 'line') {
var loc = context.projection([
(extent[0][0] + extent[1][0]) / 2,
(extent[0][1] + extent[1][1]) / 2
]);
var choice = geoChooseEdge(context.graph().childNodes(d), loc, context.projection);
dist = geoSphericalDistance(choice.loc, l);
}
const value = resultProp && d.tags[resultProp] ? d.tags[resultProp] : d.tags.name;
let title = value;
if (type === 'street') {
title = `${addrField.t('placeholders.street')}: ${title}`;
} else if (type === 'place') {
title = `${addrField.t('placeholders.place')}: ${title}`;
}
return {
title,
value,
dist,
type,
klass: `address-${type}`
};
})
.sort(function(a, b) {
return a.dist - b.dist;
});
return utilArrayUniqBy(features, 'value');
}
function getEnclosing(isAddressable, type, resultProp) {
var extent = combinedEntityExtent();
var features = context.history().intersects(extent)
.filter(isAddressable)
.map(d => {
if (d.geometry(context.graph()) !== 'area') {
return false;
}
const geom = d.asGeoJSON(context.graph()).coordinates[0];
if (!geoPolygonContainsPolygon(geom, extent.polygon())) {
return false;
}
const value = resultProp && d.tags[resultProp] ? d.tags[resultProp] : d.tags.name;
return {
title: value,
value,
dist: 0,
geom,
type,
klass: `address-${type}`
};
}).filter(Boolean);
return utilArrayUniqBy(features, 'value');
}
function getNearStreets() {
function isAddressable(d) {
return d.tags.highway && d.tags.name && d.type === 'way';
}
return getNear(isAddressable, 'street', 200);
}
function getNearPlaces() {
function isAddressable(d) {
if (d.tags.name) {
if (d.tags.place) return true;
if (d.tags.boundary === 'administrative' && d.tags.admin_level > 8) return true;
}
return false;
}
return getNear(isAddressable, 'place', 200);
}
function getNearCities() {
function isAddressable(d) {
if (d.tags.name) {
if (d.tags.boundary === 'administrative' && d.tags.admin_level === '8') return true;
if (d.tags.border_type === 'city') return true;
if (d.tags.place === 'city' || d.tags.place === 'town' || d.tags.place === 'village') return true;
}
if (d.tags[`${field.key}:city`]) return true;
return false;
}
return getNear(isAddressable, 'city', 200, `${field.key}:city`);
}
function getNearPostcodes() {
const postcodes = []
.concat(getNearValues('postcode'))
.concat(getNear(d => d.tags.postal_code, 'postcode', 200, 'postal_code'));
return utilArrayUniqBy(postcodes, item => item.value);
}
function getNearValues(key) {
const tagKey = `${field.key}:${key}`;
function hasTag(d) {
return _entityIDs.indexOf(d.id) === -1 && d.tags[tagKey];
}
return getNear(hasTag, key, 200, tagKey);
}
function getEnclosingValues(key) {
const tagKey = `${field.key}:${key}`;
// 1. areas encompassing the feature that have the address tag
function hasTag(d) {
return _entityIDs.indexOf(d.id) === -1 && d.tags[tagKey];
}
const enclosingAddresses = getEnclosing(hasTag, key, tagKey);
// 2. also include addresses from points which are encompassed by
// the same building area as the current feature
function isBuilding(d) {
return _entityIDs.indexOf(d.id) === -1 && d.tags.building && d.tags.building !== 'no';
}
const enclosingBuildings = getEnclosing(isBuilding, 'building', 'building').map(d => d.geom);
function isInNearbyBuilding(d) {
return hasTag(d) &&
d.type === 'node' &&
enclosingBuildings.some(geom =>
geoPointInPolygon(d.loc, geom) ||
geom.indexOf(d.loc) !== -1
);
}
const nearPointAddresses = getNear(isInNearbyBuilding, key, 100, tagKey);
return utilArrayUniqBy([
...enclosingAddresses,
...nearPointAddresses
], 'value').sort((a, b) => a.value > b.value ? 1 : -1);
}
function updateForCountryCode() {
if (!_countryCode) return;
var addressFormat;
for (var i = 0; i < _addressFormats.length; i++) {
var format = _addressFormats[i];
if (!format.countryCodes) {
addressFormat = format; // choose the default format, keep going
} else if (format.countryCodes.indexOf(_countryCode) !== -1) {
addressFormat = format; // choose the country format, stop here
break;
}
}
const maybeDropdowns = new Set([
'housenumber',
'housename'
]);
const dropdowns = new Set([
'block_number',
'city',
'country',
'county',
'district',
'floor',
'hamlet',
'neighbourhood',
'place',
'postcode',
'province',
'quarter',
'state',
'street',
'street+place',
'subdistrict',
'suburb',
'town',
...maybeDropdowns
]);
var widths = addressFormat.widths || {
housenumber: 1/5, unit: 1/5, street: 1/2, place: 1/2,
city: 2/3, state: 1/4, postcode: 1/3
};
function row(r) {
// Normalize widths.
var total = r.reduce(function(sum, key) {
return sum + (widths[key] || 0.5);
}, 0);
return r.map(function(key) {
return {
id: key,
width: (widths[key] || 0.5) / total
};
});
}
var rows = _wrap.selectAll('.addr-row')
.data(addressFormat.format, function(d) {
return d.toString();
});
rows.exit()
.remove();
rows
.enter()
.append('div')
.attr('class', 'addr-row')
.selectAll('input')
.data(row)
.enter()
.append('input')
.property('type', 'text')
.attr('id', d => d.id === 'housenumber' ? field.domId : null)
.attr('class', function (d) { return 'addr-' + d.id; })
.call(utilNoAuto)
.each(addDropdown)
.call(updatePlaceholder)
.style('width', function (d) { return d.width * 100 + '%'; });
function addDropdown(d) {
if (!dropdowns.has(d.id)) {
return false; // not a dropdown
}
var nearValues;
switch (d.id) {
case 'street':
nearValues = getNearStreets;
break;
case 'place':
nearValues = getNearPlaces;
break;
case 'street+place':
nearValues = () => []
.concat(getNearStreets())
.concat(getNearPlaces());
d.isAutoStreetPlace = true;
d.id = _tags[`${field.key}:place`] ? 'place' : 'street';
break;
case 'city':
nearValues = getNearCities;
break;
case 'postcode':
nearValues = getNearPostcodes;
break;
case 'housenumber':
case 'housename':
nearValues = getEnclosingValues;
break;
default:
nearValues = getNearValues;
}
if (maybeDropdowns.has(d.id)) {
const candidates = nearValues(d.id);
// only add dropdown if there are possible values for the
// corresponding tag: e.g. only show ▼ caret for
// housenumber/housename if the feature is actually
// encompassed by another feature with such an address
if (candidates.length === 0) return false;
}
d3_select(this)
.call(uiCombobox(context, `address-${d.isAutoStreetPlace ? 'street-place' : d.id}`)
.minItems(1)
.caseSensitive(true)
.fetcher(function(typedValue, callback) {
typedValue = typedValue.toLowerCase();
callback(nearValues(d.id)
.filter(v => v.value.toLowerCase().indexOf(typedValue) !== -1));
})
.on('accept', function(selected) {
if (d.isAutoStreetPlace) {
// set subtag depending on selected entry
d.id = selected ? selected.type : 'street';
utilTriggerEvent(d3_select(this), 'change');
}
})
);
}
_wrap.selectAll('input')
.on('blur', change())
.on('change', change());
_wrap.selectAll('input:not(.combobox-input)')
.on('input', change(true));
if (_tags) updateTags(_tags);
}
function address(selection) {
_selection = selection;
_wrap = selection.selectAll('.form-field-input-wrap')
.data([0]);
_wrap = _wrap.enter()
.append('div')
.attr('class', 'form-field-input-wrap form-field-input-' + field.type)
.merge(_wrap);
var extent = combinedEntityExtent();
if (extent) {
var countryCode;
if (context.inIntro()) {
// localize the address format for the walkthrough
countryCode = t('intro.graph.countrycode');
} else {
var center = extent.center();
countryCode = countryCoder.iso1A2Code(center);
}
if (countryCode) {
_countryCode = countryCode.toLowerCase();
updateForCountryCode();
}
}
}
function change(onInput) {
return function() {
var tags = {};
_wrap.selectAll('input')
.each(function (subfield) {
var key = field.key + ':' + subfield.id;
var value = this.value;
if (!onInput) value = context.cleanTagValue(value);
// don't override multiple values with blank string
if (Array.isArray(_tags[key]) && !value) return;
if (subfield.isAutoStreetPlace) {
if (subfield.id === 'street') {
tags[`${field.key}:place`] = undefined;
} else if (subfield.id === 'place') {
tags[`${field.key}:street`] = undefined;
}
}
tags[key] = value || undefined;
});
Object.keys(tags)
.filter(k => tags[k])
.forEach(k => _tags[k] = tags[k]);
dispatch.call('change', this, tags, onInput);
};
}
function updatePlaceholder(inputSelection) {
return inputSelection.attr('placeholder', function(subfield) {
if (_tags && Array.isArray(_tags[field.key + ':' + subfield.id])) {
return t('inspector.multiple_values');
}
if (subfield.isAutoStreetPlace) {
return `${getLocalPlaceholder('street')} / ${getLocalPlaceholder('place')}`;
}
return getLocalPlaceholder(subfield.id);
});
}
function getLocalPlaceholder(key) {
if (_countryCode) {
var localkey = key + '!' + _countryCode;
var tkey = addrField.hasTextForStringId('placeholders.' + localkey) ? localkey : key;
return addrField.t('placeholders.' + tkey);
}
}
function updateTags(tags) {
utilGetSetValue(_wrap.selectAll('input'), subfield => {
var val;
if (subfield.isAutoStreetPlace) {
const streetKey = `${field.key}:street`;
const placeKey = `${field.key}:place`;
if (tags[streetKey] !== undefined || tags[placeKey] === undefined) {
val = tags[streetKey];
subfield.id = 'street';
} else {
val = tags[placeKey];
subfield.id = 'place';
}
} else {
val = tags[`${field.key}:${subfield.id}`];
}
return typeof val === 'string' ? val : '';
})
.attr('title', function(subfield) {
var val = tags[field.key + ':' + subfield.id];
return (val && Array.isArray(val)) ? val.filter(Boolean).join('\n') : undefined;
})
.classed('mixed', function(subfield) {
return Array.isArray(tags[field.key + ':' + subfield.id]);
})
.call(updatePlaceholder);
}
function combinedEntityExtent() {
return _entityIDs && _entityIDs.length && utilTotalExtent(_entityIDs, context.graph());
}
address.entityIDs = function(val) {
if (!arguments.length) return _entityIDs;
_entityIDs = val;
return address;
};
address.tags = function(tags) {
_tags = tags;
updateTags(tags);
};
address.focus = function() {
var node = _wrap.selectAll('input').node();
if (node) node.focus();
};
return utilRebind(address, dispatch, 'on');
}