modules/modes/select.js (525 lines of code) (raw):

import { select as d3_select } from 'd3-selection'; import { geoMetersToLat, geoMetersToLon } from '@id-sdk/math'; import { utilArrayIntersection, utilArrayUnion, utilDeepMemberSelector, utilEntityOrDeepMemberSelector, utilEntitySelector, utilGetAllNodes } from '@id-sdk/util'; import { t } from '../core/localizer'; import { actionAddMidpoint } from '../actions/add_midpoint'; import { actionDeleteRelation } from '../actions/delete_relation'; import { actionMove } from '../actions/move'; import { actionScale } from '../actions/scale'; import { behaviorBreathe } from '../behavior/breathe'; import { behaviorHover } from '../behavior/hover'; import { behaviorLasso } from '../behavior/lasso'; import { behaviorPaste } from '../behavior/paste'; import { behaviorSelect } from '../behavior/select'; import { operationMove } from '../operations/move'; import { prefs } from '../core/preferences'; import { geoChooseEdge } from '../geo'; import { modeBrowse } from './browse'; import { modeDragNode } from './drag_node'; import { modeDragNote } from './drag_note'; import { osmNode, osmWay } from '../osm'; import * as Operations from '../operations/index'; import { uiCmd } from '../ui/cmd'; import { utilKeybinding, utilTotalExtent } from '../util'; export function modeSelect(context, selectedIDs) { var mode = { id: 'select', button: 'browse' }; var keybinding = utilKeybinding('select'); var _breatheBehavior = behaviorBreathe(context); var _modeDragNode = modeDragNode(context); var _selectBehavior; var _behaviors = []; var _operations = []; var _newFeature = false; var _follow = false; // `_focusedParentWayId` is used when we visit a vertex with multiple // parents, and we want to remember which parent line we started on. var _focusedParentWayId; var _focusedVertexIds; function singular() { if (selectedIDs && selectedIDs.length === 1) { return context.hasEntity(selectedIDs[0]); } } function selectedEntities() { return selectedIDs.map(function(id) { return context.hasEntity(id); }).filter(Boolean); } function checkSelectedIDs() { var ids = []; if (Array.isArray(selectedIDs)) { ids = selectedIDs.filter(function(id) { return context.hasEntity(id); }); } if (!ids.length) { context.enter(modeBrowse(context)); return false; } else if ((selectedIDs.length > 1 && ids.length === 1) || (selectedIDs.length === 1 && ids.length > 1)) { // switch between single- and multi-select UI context.enter(modeSelect(context, ids)); return false; } selectedIDs = ids; return true; } // find the parent ways for nextVertex, previousVertex, and selectParent function parentWaysIdsOfSelection(onlyCommonParents) { var graph = context.graph(); var parents = []; for (var i = 0; i < selectedIDs.length; i++) { var entity = context.hasEntity(selectedIDs[i]); if (!entity || entity.geometry(graph) !== 'vertex') { return []; // selection includes some non-vertices } var currParents = graph.parentWays(entity).map(function(w) { return w.id; }); if (!parents.length) { parents = currParents; continue; } parents = (onlyCommonParents ? utilArrayIntersection : utilArrayUnion)(parents, currParents); if (!parents.length) { return []; } } return parents; } // find the child nodes for selected ways function childNodeIdsOfSelection(onlyCommon) { var graph = context.graph(); var childs = []; for (var i = 0; i < selectedIDs.length; i++) { var entity = context.hasEntity(selectedIDs[i]); if (!entity || !['area', 'line'].includes(entity.geometry(graph))){ return []; // selection includes non-area/non-line } var currChilds = graph.childNodes(entity).map(function(node) { return node.id; }); if (!childs.length) { childs = currChilds; continue; } childs = (onlyCommon ? utilArrayIntersection : utilArrayUnion)(childs, currChilds); if (!childs.length) { return []; } } return childs; } function checkFocusedParent() { if (_focusedParentWayId) { var parents = parentWaysIdsOfSelection(true); if (parents.indexOf(_focusedParentWayId) === -1) _focusedParentWayId = null; } } function parentWayIdForVertexNavigation() { var parentIds = parentWaysIdsOfSelection(true); if (_focusedParentWayId && parentIds.indexOf(_focusedParentWayId) !== -1) { // prefer the previously seen parent return _focusedParentWayId; } return parentIds.length ? parentIds[0] : null; } mode.selectedIDs = function(val) { if (!arguments.length) return selectedIDs; selectedIDs = val; return mode; }; mode.zoomToSelected = function() { context.map().zoomToEase(selectedEntities()); }; mode.newFeature = function(val) { if (!arguments.length) return _newFeature; _newFeature = val; return mode; }; mode.selectBehavior = function(val) { if (!arguments.length) return _selectBehavior; _selectBehavior = val; return mode; }; mode.follow = function(val) { if (!arguments.length) return _follow; _follow = val; return mode; }; function loadOperations() { _operations.forEach(function(operation) { if (operation.behavior) { context.uninstall(operation.behavior); } }); _operations = Object.values(Operations) .map(function(o) { return o(context, selectedIDs); }) .filter(function(o) { return o.id !== 'delete' && o.id !== 'downgrade' && o.id !== 'copy'; }) .concat([ // group copy/downgrade/delete operation together at the end of the list Operations.operationCopy(context, selectedIDs), Operations.operationDowngrade(context, selectedIDs), Operations.operationDelete(context, selectedIDs) ]).filter(function(operation) { return operation.available(); }); _operations.forEach(function(operation) { if (operation.behavior) { context.install(operation.behavior); } }); // remove any displayed menu context.ui().closeEditMenu(); } mode.operations = function() { return _operations; }; mode.enter = function() { if (!checkSelectedIDs()) return; context.features().forceVisible(selectedIDs); _modeDragNode.restoreSelectedIDs(selectedIDs); loadOperations(); if (!_behaviors.length) { if (!_selectBehavior) _selectBehavior = behaviorSelect(context); _behaviors = [ behaviorPaste(context), _breatheBehavior, behaviorHover(context).on('hover', context.ui().sidebar.hoverModeSelect), _selectBehavior, behaviorLasso(context), _modeDragNode.behavior, modeDragNote(context).behavior ]; } _behaviors.forEach(context.install); keybinding .on(t('inspector.zoom_to.key'), mode.zoomToSelected) .on(['[', 'pgup'], previousVertex) .on([']', 'pgdown'], nextVertex) .on(['{', uiCmd('⌘['), 'home'], firstVertex) .on(['}', uiCmd('⌘]'), 'end'], lastVertex) .on(uiCmd('⇧←'), nudgeSelection([-10, 0])) .on(uiCmd('⇧↑'), nudgeSelection([0, -10])) .on(uiCmd('⇧→'), nudgeSelection([10, 0])) .on(uiCmd('⇧↓'), nudgeSelection([0, 10])) .on(uiCmd('⇧⌥←'), nudgeSelection([-100, 0])) .on(uiCmd('⇧⌥↑'), nudgeSelection([0, -100])) .on(uiCmd('⇧⌥→'), nudgeSelection([100, 0])) .on(uiCmd('⇧⌥↓'), nudgeSelection([0, 100])) .on(utilKeybinding.plusKeys.map((key) => uiCmd('⇧' + key)), scaleSelection(1.05)) .on(utilKeybinding.plusKeys.map((key) => uiCmd('⇧⌥' + key)), scaleSelection(Math.pow(1.05, 5))) .on(utilKeybinding.minusKeys.map((key) => uiCmd('⇧' + key)), scaleSelection(1/1.05)) .on(utilKeybinding.minusKeys.map((key) => uiCmd('⇧⌥' + key)), scaleSelection(1/Math.pow(1.05, 5))) .on(['\\', 'pause'], focusNextParent) .on(uiCmd('⌘↑'), selectParent) .on(uiCmd('⌘↓'), selectChild) .on('⎋', esc, true); d3_select(document) .call(keybinding); context.ui().sidebar .select(selectedIDs, _newFeature); context.history() .on('change.select', function() { loadOperations(); // reselect after change in case relation members were removed or added selectElements(); }) .on('undone.select', checkSelectedIDs) .on('redone.select', checkSelectedIDs); context.map() .on('drawn.select', selectElements) .on('crossEditableZoom.select', function() { selectElements(); _breatheBehavior.restartIfNeeded(context.surface()); }); context.map().doubleUpHandler() .on('doubleUp.modeSelect', didDoubleUp); selectElements(); if (_follow) { var extent = utilTotalExtent(selectedIDs, context.graph()); var loc = extent.center(); context.map().centerEase(loc); // we could enter the mode multiple times, so reset follow for next time _follow = false; } function nudgeSelection(delta) { return function() { // prevent nudging during low zoom selection if (!context.map().withinEditableZoom()) return; var moveOp = operationMove(context, selectedIDs); if (moveOp.disabled()) { context.ui().flash .duration(4000) .iconName('#iD-operation-' + moveOp.id) .iconClass('operation disabled') .label(moveOp.tooltip)(); } else { context.perform(actionMove(selectedIDs, delta, context.projection), moveOp.annotation()); context.validator().validate(); } }; } function scaleSelection(factor) { return function() { // prevent scaling during low zoom selection if (!context.map().withinEditableZoom()) return; let nodes = utilGetAllNodes(selectedIDs, context.graph()); let isUp = factor > 1; // can only scale if multiple nodes are selected if (nodes.length <= 1) return; let extent = utilTotalExtent(selectedIDs, context.graph()); // These disabled checks would normally be handled by an operation // object, but we don't want an actual scale operation at this point. function scalingDisabled() { const allowLargeEdits = prefs('rapid-internal-feature.allowLargeEdits') === 'true'; if (tooSmall()) { return 'too_small'; } else if (!allowLargeEdits && extent.percentContainedIn(context.map().extent()) < 0.8) { return 'too_large'; } else if (someMissing() || selectedIDs.some(incompleteRelation)) { return 'not_downloaded'; } else if (selectedIDs.some(context.hasHiddenConnections)) { return 'connected_to_hidden'; } return false; function tooSmall() { if (isUp) return false; let dLon = Math.abs(extent.max[0] - extent.min[0]); let dLat = Math.abs(extent.max[1] - extent.min[1]); return dLon < geoMetersToLon(1, extent.max[1]) && dLat < geoMetersToLat(1); } function someMissing() { if (context.inIntro()) return false; let osm = context.connection(); if (osm) { let missing = nodes.filter(function(n) { return !osm.isDataLoaded(n.loc); }); if (missing.length) { missing.forEach(function(loc) { context.loadTileAtLoc(loc); }); return true; } } return false; } function incompleteRelation(id) { let entity = context.entity(id); return entity.type === 'relation' && !entity.isComplete(context.graph()); } } const disabled = scalingDisabled(); if (disabled) { let multi = (selectedIDs.length === 1 ? 'single' : 'multiple'); context.ui().flash .duration(4000) .iconName('#iD-icon-no') .iconClass('operation disabled') .label(t('operations.scale.' + disabled + '.' + multi))(); } else { const pivot = context.projection(extent.center()); const annotation = t('operations.scale.annotation.' + (isUp ? 'up' : 'down') + '.feature', { n: selectedIDs.length }); context.perform(actionScale(selectedIDs, pivot, factor, context.projection), annotation); context.validator().validate(); } }; } function didDoubleUp(d3_event, loc) { if (!context.map().withinEditableZoom()) return; var target = d3_select(d3_event.target); var datum = target.datum(); var entity = datum && datum.properties && datum.properties.entity; if (!entity) return; if (entity instanceof osmWay && target.classed('target')) { var choice = geoChooseEdge(context.graph().childNodes(entity), loc, context.projection); var prev = entity.nodes[choice.index - 1]; var next = entity.nodes[choice.index]; context.perform( actionAddMidpoint({ loc: choice.loc, edge: [prev, next] }, osmNode()), t('operations.add.annotation.vertex') ); context.validator().validate(); } else if (entity.type === 'midpoint') { context.perform( actionAddMidpoint({ loc: entity.loc, edge: entity.edge }, osmNode()), t('operations.add.annotation.vertex') ); context.validator().validate(); } } function selectElements() { if (!checkSelectedIDs()) return; var surface = context.surface(); surface.selectAll('.selected-member') .classed('selected-member', false); surface.selectAll('.selected') .classed('selected', false); surface.selectAll('.related') .classed('related', false); // reload `_focusedParentWayId` based on the current selection checkFocusedParent(); if (_focusedParentWayId) { surface.selectAll(utilEntitySelector([_focusedParentWayId])) .classed('related', true); } if (context.map().withinEditableZoom()) { // Apply selection styling if not in wide selection surface .selectAll(utilDeepMemberSelector(selectedIDs, context.graph(), true /* skipMultipolgonMembers */)) .classed('selected-member', true); surface .selectAll(utilEntityOrDeepMemberSelector(selectedIDs, context.graph())) .classed('selected', true); } } function esc() { if (context.container().select('.combobox').size()) return; context.enter(modeBrowse(context)); } function firstVertex(d3_event) { d3_event.preventDefault(); var entity = singular(); var parentId = parentWayIdForVertexNavigation(); var way; if (entity && entity.type === 'way') { way = entity; } else if (parentId) { way = context.entity(parentId); } _focusedParentWayId = way && way.id; if (way) { context.enter( mode.selectedIDs([way.first()]) .follow(true) ); } } function lastVertex(d3_event) { d3_event.preventDefault(); var entity = singular(); var parentId = parentWayIdForVertexNavigation(); var way; if (entity && entity.type === 'way') { way = entity; } else if (parentId) { way = context.entity(parentId); } _focusedParentWayId = way && way.id; if (way) { context.enter( mode.selectedIDs([way.last()]) .follow(true) ); } } function previousVertex(d3_event) { d3_event.preventDefault(); var parentId = parentWayIdForVertexNavigation(); _focusedParentWayId = parentId; if (!parentId) return; var way = context.entity(parentId); var length = way.nodes.length; var curr = way.nodes.indexOf(selectedIDs[0]); var index = -1; if (curr > 0) { index = curr - 1; } else if (way.isClosed()) { index = length - 2; } if (index !== -1) { context.enter( mode.selectedIDs([way.nodes[index]]) .follow(true) ); } } function nextVertex(d3_event) { d3_event.preventDefault(); var parentId = parentWayIdForVertexNavigation(); _focusedParentWayId = parentId; if (!parentId) return; var way = context.entity(parentId); var length = way.nodes.length; var curr = way.nodes.indexOf(selectedIDs[0]); var index = -1; if (curr < length - 1) { index = curr + 1; } else if (way.isClosed()) { index = 0; } if (index !== -1) { context.enter( mode.selectedIDs([way.nodes[index]]) .follow(true) ); } } function focusNextParent(d3_event) { d3_event.preventDefault(); var parents = parentWaysIdsOfSelection(true); if (!parents || parents.length < 2) return; var index = parents.indexOf(_focusedParentWayId); if (index < 0 || index > parents.length - 2) { _focusedParentWayId = parents[0]; } else { _focusedParentWayId = parents[index + 1]; } var surface = context.surface(); surface.selectAll('.related') .classed('related', false); if (_focusedParentWayId) { surface.selectAll(utilEntitySelector([_focusedParentWayId])) .classed('related', true); } } function selectParent(d3_event) { d3_event.preventDefault(); var currentSelectedIds = mode.selectedIDs(); var parentIds = _focusedParentWayId ? [_focusedParentWayId] : parentWaysIdsOfSelection(false); if (!parentIds.length) return; context.enter( mode.selectedIDs(parentIds) ); // set this after re-entering the selection since we normally want it cleared on exit _focusedVertexIds = currentSelectedIds; } function selectChild(d3_event) { d3_event.preventDefault(); var currentSelectedIds = mode.selectedIDs(); var childIds = _focusedVertexIds ? _focusedVertexIds.filter(id => context.hasEntity(id)) : childNodeIdsOfSelection(true); if (!childIds || !childIds.length) return; if (currentSelectedIds.length === 1) _focusedParentWayId = currentSelectedIds[0]; context.enter( mode.selectedIDs(childIds) ); } }; mode.exit = function() { // we could enter the mode multiple times but it's only new the first time _newFeature = false; _focusedVertexIds = null; _operations.forEach(function(operation) { if (operation.behavior) { context.uninstall(operation.behavior); } }); _operations = []; _behaviors.forEach(context.uninstall); d3_select(document) .call(keybinding.unbind); context.ui().closeEditMenu(); context.history() .on('change.select', null) .on('undone.select', null) .on('redone.select', null); var surface = context.surface(); surface .selectAll('.selected-member') .classed('selected-member', false); surface .selectAll('.selected') .classed('selected', false); surface .selectAll('.highlighted') .classed('highlighted', false); surface .selectAll('.related') .classed('related', false); context.map().on('drawn.select', null); context.ui().sidebar.hide(); context.features().forceVisible([]); var entity = singular(); if (_newFeature && entity && entity.type === 'relation' && // no tags Object.keys(entity.tags).length === 0 && // no parent relations context.graph().parentRelations(entity).length === 0 && // no members or one member with no role (entity.members.length === 0 || (entity.members.length === 1 && !entity.members[0].role)) ) { // the user added this relation but didn't edit it at all, so just delete it var deleteAction = actionDeleteRelation(entity.id, true /* don't delete untagged members */); context.perform(deleteAction, t('operations.delete.annotation.relation')); context.validator().validate(); } }; return mode; }