modules/ui/entity_editor.js (303 lines of code) (raw):

import { dispatch as d3_dispatch } from 'd3-dispatch'; import { utilArrayIdentical, utilCleanTags } from '@id-sdk/util'; import deepEqual from 'fast-deep-equal'; import { presetManager } from '../presets'; import { t, localizer } from '../core/localizer'; import { actionChangeTags } from '../actions/change_tags'; import { modeBrowse } from '../modes/browse'; import { svgIcon } from '../svg/icon'; import { utilRebind } from '../util'; import { uiSectionEntityIssues } from './sections/entity_issues'; import { uiSectionFeatureType } from './sections/feature_type'; import { uiSectionPresetFields } from './sections/preset_fields'; import { uiSectionRawMemberEditor } from './sections/raw_member_editor'; import { uiSectionRawMembershipEditor } from './sections/raw_membership_editor'; import { uiSectionRawTagEditor } from './sections/raw_tag_editor'; import { uiSectionSelectionList } from './sections/selection_list'; export function uiEntityEditor(context) { var dispatch = d3_dispatch('choose'); var _state = 'select'; var _coalesceChanges = false; var _modified = false; var _base; var _entityIDs; var _activePresets = []; var _newFeature; var _sections; // Returns a single object containing the tags of all the given entities. // Example: // { // highway: 'service', // service: 'parking_aisle' // } // + // { // highway: 'service', // service: 'driveway', // width: '3' // } // = // { // highway: 'service', // service: [ 'driveway', 'parking_aisle' ], // width: [ '3', undefined ] // } function getCombinedTags(entityIDs, graph) { var tags = {}; var tagCounts = {}; var allKeys = new Set(); var entities = entityIDs.map(function(entityID) { return graph.hasEntity(entityID); }).filter(Boolean); // gather the aggregate keys entities.forEach(function(entity) { var keys = Object.keys(entity.tags).filter(Boolean); keys.forEach(function(key) { allKeys.add(key); }); }); entities.forEach(function(entity) { allKeys.forEach(function(key) { var value = entity.tags[key]; // purposely allow `undefined` if (!tags.hasOwnProperty(key)) { // first value, set as raw tags[key] = value; } else { if (!Array.isArray(tags[key])) { if (tags[key] !== value) { // first alternate value, replace single value with array tags[key] = [tags[key], value]; } } else { // type is array if (tags[key].indexOf(value) === -1) { // subsequent alternate value, add to array tags[key].push(value); } } } var tagHash = key + '=' + value; if (!tagCounts[tagHash]) tagCounts[tagHash] = 0; tagCounts[tagHash] += 1; }); }); for (var key in tags) { if (!Array.isArray(tags[key])) continue; // sort values by frequency then alphabetically tags[key] = tags[key].sort(function(val1, val2) { var key = key; // capture var count2 = tagCounts[key + '=' + val2]; var count1 = tagCounts[key + '=' + val1]; if (count2 !== count1) { return count2 - count1; } if (val2 && val1) { return val1.localeCompare(val2); } return val1 ? 1 : -1; }); } return tags; } function entityEditor(selection) { var combinedTags = getCombinedTags(_entityIDs, context.graph()); // Header var header = selection.selectAll('.header') .data([0]); // Enter var headerEnter = header.enter() .append('div') .attr('class', 'header fillL'); headerEnter .append('button') .attr('class', 'preset-reset preset-choose') .call(svgIcon((localizer.textDirection() === 'rtl') ? '#iD-icon-forward' : '#iD-icon-backward')); headerEnter .append('button') .attr('class', 'close') .on('click', function() { context.enter(modeBrowse(context)); }) .call(svgIcon(_modified ? '#iD-icon-apply' : '#iD-icon-close')); headerEnter .append('h3'); // Update header = header .merge(headerEnter); header.selectAll('h3') .html(_entityIDs.length === 1 ? t.html('inspector.edit') : t.html('rapid_multiselect')); header.selectAll('.preset-reset') .on('click', function() { dispatch.call('choose', this, _activePresets); }); // Body var body = selection.selectAll('.inspector-body') .data([0]); // Enter var bodyEnter = body.enter() .append('div') .attr('class', 'entity-editor inspector-body sep-top'); // Update body = body .merge(bodyEnter); if (!_sections) { _sections = [ uiSectionSelectionList(context), uiSectionFeatureType(context).on('choose', function(presets) { dispatch.call('choose', this, presets); }), uiSectionEntityIssues(context), uiSectionPresetFields(context).on('change', changeTags).on('revert', revertTags), uiSectionRawTagEditor('raw-tag-editor', context).on('change', changeTags), uiSectionRawMemberEditor(context), uiSectionRawMembershipEditor(context) ]; } _sections.forEach(function(section) { if (section.entityIDs) { section.entityIDs(_entityIDs); } if (section.presets) { section.presets(_activePresets); } if (section.tags) { section.tags(combinedTags); } if (section.state) { section.state(_state); } body.call(section.render); }); context.history() .on('change.entity-editor', historyChanged); function historyChanged(difference) { if (selection.selectAll('.entity-editor').empty()) return; if (_state === 'hide') return; var significant = !difference || difference.didChange.properties || difference.didChange.addition || difference.didChange.deletion; if (!significant) return; _entityIDs = _entityIDs.filter(context.hasEntity); if (!_entityIDs.length) return; var priorActivePreset = _activePresets.length === 1 && _activePresets[0]; loadActivePresets(); var graph = context.graph(); entityEditor.modified(_base !== graph); entityEditor(selection); if (priorActivePreset && _activePresets.length === 1 && priorActivePreset !== _activePresets[0]) { // flash the button to indicate the preset changed context.container().selectAll('.entity-editor button.preset-reset .label') .style('background-color', '#fff') .transition() .duration(750) .style('background-color', null); } } } // Tag changes that fire on input can all get coalesced into a single // history operation when the user leaves the field. #2342 // Use explicit entityIDs in case the selection changes before the event is fired. function changeTags(entityIDs, changed, onInput) { var actions = []; for (var i in entityIDs) { var entityID = entityIDs[i]; var entity = context.entity(entityID); var tags = Object.assign({}, entity.tags); // shallow copy for (var k in changed) { if (!k) continue; // No op for source=digitalglobe or source=maxar on ML roads. TODO: switch to check on __fbid__ if (entity.__fbid__ && k === 'source' && (entity.tags.source === 'digitalglobe' || entity.tags.source === 'maxar')) continue; var v = changed[k]; if (v !== undefined || tags.hasOwnProperty(k)) { tags[k] = v; } } if (!onInput) { tags = utilCleanTags(tags); } if (!deepEqual(entity.tags, tags)) { actions.push(actionChangeTags(entityID, tags)); } } if (actions.length) { var combinedAction = function(graph) { actions.forEach(function(action) { graph = action(graph); }); return graph; }; var annotation = t('operations.change_tags.annotation'); if (_coalesceChanges) { context.overwrite(combinedAction, annotation); } else { context.perform(combinedAction, annotation); _coalesceChanges = !!onInput; } } // if leaving field (blur event), rerun validation if (!onInput) { context.validator().validate(); } } function revertTags(keys) { var actions = []; for (var i in _entityIDs) { var entityID = _entityIDs[i]; var original = context.graph().base().entities[entityID]; var changed = {}; for (var j in keys) { var key = keys[j]; changed[key] = original ? original.tags[key] : undefined; } var entity = context.entity(entityID); var tags = Object.assign({}, entity.tags); // shallow copy for (var k in changed) { if (!k) continue; var v = changed[k]; if (v !== undefined || tags.hasOwnProperty(k)) { tags[k] = v; } } tags = utilCleanTags(tags); if (!deepEqual(entity.tags, tags)) { actions.push(actionChangeTags(entityID, tags)); } } if (actions.length) { var combinedAction = function(graph) { actions.forEach(function(action) { graph = action(graph); }); return graph; }; var annotation = t('operations.change_tags.annotation'); if (_coalesceChanges) { context.overwrite(combinedAction, annotation); } else { context.perform(combinedAction, annotation); _coalesceChanges = false; } } context.validator().validate(); } entityEditor.modified = function(val) { if (!arguments.length) return _modified; _modified = val; return entityEditor; }; entityEditor.state = function(val) { if (!arguments.length) return _state; _state = val; return entityEditor; }; entityEditor.entityIDs = function(val) { if (!arguments.length) return _entityIDs; // always reload these even if the entityIDs are unchanged, since we // could be reselecting after something like dragging a node _base = context.graph(); _coalesceChanges = false; if (val && _entityIDs && utilArrayIdentical(_entityIDs, val)) return entityEditor; // exit early if no change _entityIDs = val; loadActivePresets(true); return entityEditor .modified(false); }; entityEditor.newFeature = function(val) { if (!arguments.length) return _newFeature; _newFeature = val; return entityEditor; }; function loadActivePresets(isForNewSelection) { var graph = context.graph(); var counts = {}; for (var i in _entityIDs) { var entity = graph.hasEntity(_entityIDs[i]); if (!entity) return; var match = presetManager.match(entity, graph); if (!counts[match.id]) counts[match.id] = 0; counts[match.id] += 1; } var matches = Object.keys(counts).sort(function(p1, p2) { return counts[p2] - counts[p1]; }).map(function(pID) { return presetManager.item(pID); }); if (!isForNewSelection) { // A "weak" preset doesn't set any tags. (e.g. "Address") var weakPreset = _activePresets.length === 1 && !_activePresets[0].isFallback() && Object.keys(_activePresets[0].addTags || {}).length === 0; // Don't replace a weak preset with a fallback preset (e.g. "Point") if (weakPreset && matches.length === 1 && matches[0].isFallback()) return; } entityEditor.presets(matches); } entityEditor.presets = function(val) { if (!arguments.length) return _activePresets; // don't reload the same preset if (!utilArrayIdentical(val, _activePresets)) { _activePresets = val; } return entityEditor; }; return utilRebind(entityEditor, dispatch, 'on'); }