diff --git a/css/80_app.css b/css/80_app.css
index 13f256df46f..522eafa81d9 100644
--- a/css/80_app.css
+++ b/css/80_app.css
@@ -2885,9 +2885,88 @@ img.tag-reference-wiki-image {
margin: 0;
padding-bottom: 10px;
}
+
+.member-list .member-row label {
+ border-bottom: none;
+}
+
+.member-list .member-row div.form-field-input-wrap {
+ border: 1px solid var(--border-color);
+ border-top: none;
+ border-right: none;
+}
+.member-list .member-row div.form-field-input-wrap input {
+ border-top: 1px solid var(--border-color);
+ flex: 1 1 auto;
+ min-width: 80px;
+}
+.member-list .member-row div.form-field-input-wrap .combobox-caret,
+.member-list .member-row div.form-field-input-wrap button {
+ flex: 0 0 auto;
+}
+.section-raw-member-editor .member-list .member-row div.form-field-input-wrap input {
+ border-bottom: none;
+ border-radius: 0;
+}
+.section-raw-membership-editor .member-list .member-row div.form-field-input-wrap input {
+ border-bottom: none;
+ border-left: none; /* todo: rtl layout */
+ border-bottom-right-radius: 0; /* todo: rtl layout */
+}
+.member-list .member-row div.form-field-input-wrap button {
+ border-bottom: none;
+ border-top: 1px solid var(--border-color);
+}
+
+.member-list .grab-icon svg.icon {
+ padding-top: 4px;
+ height: 20px;
+ fill: var(--text-color);
+ opacity: .5;
+}
+
+.member-list .member-row.member-connects {
+ padding-bottom: 0;
+ margin-bottom: -1px;
+}
+.member-list .member-row.member-connects-prev label {
+ border-radius: 0;
+}
+.member-list .member-row.member-connects-next div.form-field-input-wrap {
+ border-radius: 0;
+}
+.member-list .member-row.member-connects-next button {
+ border-radius: 0;
+}
+
+.members-download button,
+.members-download button.loading {
+ width: 32px;
+ background: none;
+ margin-right: -5px;
+}
+.members-download button .icon {
+ fill: var(--text-color);
+ opacity: .5;
+}
+
+
/* only the raw-member-editor is reorederable, not the raw-membership-editor */
-.section-raw-member-editor .member-row .label-text { cursor: grab; }
-.section-raw-member-editor .member-row .label-text:active { cursor: grabbing; }
+.section-raw-member-editor .member-row label,
+.section-raw-member-editor .member-row div.form-field-input-wrap {
+ cursor: grab;
+}
+.section-raw-member-editor .member-row:active label,
+.section-raw-member-editor .member-row:active div.form-field-input-wrap {
+ cursor: grabbing;
+}
+.section-raw-member-editor .member-row:hover label,
+.section-raw-member-editor .member-row:hover div.form-field-input-wrap {
+ background: var(--bg-color-3);
+}
+.section-raw-member-editor .member-row:hover .grab-icon svg.icon {
+ opacity: .8;
+}
.section-raw-member-editor .member-row .member-entity-name,
.section-raw-membership-editor .member-row .member-entity-name {
diff --git a/data/core.yaml b/data/core.yaml
index dcbbc2e45c4..c2cbdbc5161 100644
--- a/data/core.yaml
+++ b/data/core.yaml
@@ -314,7 +314,12 @@ en:
relation: This can't be disconnected because it connects members of a relation.
merge:
title: Merge
- description: Merge these features.
+ description:
+ join: Join these lines.
+ merge_polygon: Combine the selected areas.
+ merge_members: Add these features as members to the selected relation.
+ merge_nodes: Merge these points.
+ merge: Merge these features.
key: C
annotation:
one: "Merged a feature."
@@ -328,6 +333,12 @@ en:
conflicting_relations: These features can't be merged because they belong to conflicting relations.
paths_intersect: These features can't be merged because the resulting path would intersect itself.
too_many_vertices: These features can't be merged because the resulting path would have too many points.
+ members_remove:
+ title: Remove Members
+ description: Remove one or more selected members from the selected relation.
+ annotation:
+ one: "Remove a member from the relation."
+ other: "Merged {n} members from the relation."
move:
title: Move
description:
@@ -715,7 +726,10 @@ en:
feature_type: Feature Type
fields: Fields
tags: Tags
- members: Members
+ members:
+ title: Members
+ grab: Drag and drop to reorder members
+ download_all: Download all members
relations: Relations
features: Features
title_count: "{title} ({count})"
diff --git a/modules/actions/index.js b/modules/actions/index.js
index ca8fe5f3d66..f59fbae8d92 100644
--- a/modules/actions/index.js
+++ b/modules/actions/index.js
@@ -17,9 +17,11 @@ export { actionDiscardTags } from './discard_tags';
export { actionDisconnect } from './disconnect';
export { actionExtract } from './extract';
export { actionJoin } from './join';
+export { actionMembersRemove } from './members_remove';
export { actionMerge } from './merge';
export { actionMergeNodes } from './merge_nodes';
export { actionMergePolygon } from './merge_polygon';
+export { actionMergeMembers } from './merge_members';
export { actionMergeRemoteChanges } from './merge_remote_changes';
export { actionMove } from './move';
export { actionMoveMember } from './move_member';
diff --git a/modules/actions/join.js b/modules/actions/join.js
index 7bd9bd3108e..912d569737a 100644
--- a/modules/actions/join.js
+++ b/modules/actions/join.js
@@ -209,6 +209,8 @@ export function actionJoin(ids) {
isFinite(tagsB[key]));
}
+ action.id = 'join';
+
return action;
}
diff --git a/modules/actions/members_remove.js b/modules/actions/members_remove.js
new file mode 100644
index 00000000000..ddab2d6ab99
--- /dev/null
+++ b/modules/actions/members_remove.js
@@ -0,0 +1,59 @@
+import { actionDeleteMembers } from './delete_members';
+
+
+// `actionMembersRemove` removes selected members from a single relation
+//
+// * there must be only one relation in the selection
+// * all other selected entities are members of the relation
+// * the operation removes all occurrences of the features from
+// being a member of the relation
+
+export function actionMembersRemove(entityIDs) {
+
+ // export function actionDeleteMembers(relationId, memberIndexes) {
+
+ var action = function(graph) {
+ let relation;
+ for (const entityID of entityIDs) {
+ var entity = graph.entity(entityID);
+ if (entity.type === 'relation') {
+ relation = entity;
+ }
+ }
+ let memberIndices = [];
+ for (let i = 0; i < relation.members.length; i++) {
+ if (entityIDs.indexOf(relation.members[i].id) > 0) {
+ memberIndices.push(i);
+ }
+ }
+
+ graph = actionDeleteMembers(relation.id, memberIndices)(graph);
+
+ // only keep relation in new selection (see operation/merge.js)
+ entityIDs.splice(0, entityIDs.indexOf(relation.id));
+ entityIDs.splice(1, entityIDs.length - 1);
+
+ return graph;
+ };
+
+
+ action.disabled = function(graph) {
+ let relation;
+ for (const entityID of entityIDs) {
+ var entity = graph.entity(entityID);
+ if (entity.type === 'relation') {
+ if (relation !== undefined) return 'not_eligible';
+ relation = entity;
+ }
+ }
+ if (relation === undefined) {
+ return 'not_eligible';
+ }
+ if (entityIDs.some(entityID => entityID !== relation.id &&
+ !relation.members.find(member => member.id === entityID))) {
+ return 'not_eligible';
+ }
+ };
+
+ return action;
+}
diff --git a/modules/actions/merge.js b/modules/actions/merge.js
index 289ce46afb3..85038736211 100644
--- a/modules/actions/merge.js
+++ b/modules/actions/merge.js
@@ -116,6 +116,8 @@ export function actionMerge(ids) {
}
};
+ action.id = 'merge';
+
return action;
}
diff --git a/modules/actions/merge_members.js b/modules/actions/merge_members.js
new file mode 100644
index 00000000000..28b1574f86b
--- /dev/null
+++ b/modules/actions/merge_members.js
@@ -0,0 +1,53 @@
+import { actionAddMember } from './add_member';
+
+
+// `actionMergeMembers` adds new members to a single relation
+//
+// * there must be only one relation in the selection
+// * all other selected entities are added as new members to the relation
+// * sorting is done "automagically" when applicable (e.g. connecting to
+// existing members of a route), otherwise they will be appended at the
+// end of the members list
+// * members are added using an empty role
+
+export function actionMergeMembers(entityIDs) {
+
+ var action = function(graph) {
+ let relationID;
+ let newMembers = [];
+ for (const entityID of entityIDs) {
+ var entity = graph.entity(entityID);
+ if (entity.type === 'relation') {
+ relationID = entityID;
+ } else {
+ newMembers.push({
+ id: entity.id,
+ type: entity.type,
+ role: ''
+ });
+ }
+ }
+
+ for (const member of newMembers) {
+ graph = actionAddMember(relationID, member)(graph);
+ }
+
+ // only keep relation in new selection (see operation/merge.js)
+ entityIDs.splice(0, entityIDs.indexOf(relationID));
+ entityIDs.splice(1, entityIDs.length - 1);
+
+ return graph;
+ };
+
+
+ action.disabled = function(graph) {
+ const relationCount = entityIDs.filter(entityID =>
+ graph.entity(entityID).type === 'relation')
+ .length;
+ if (relationCount !== 1) return 'not_eligible';
+ };
+
+ action.id = 'merge_members';
+
+ return action;
+}
diff --git a/modules/actions/merge_nodes.js b/modules/actions/merge_nodes.js
index 26f73eb0093..1322e69f77a 100644
--- a/modules/actions/merge_nodes.js
+++ b/modules/actions/merge_nodes.js
@@ -58,5 +58,7 @@ export function actionMergeNodes(nodeIDs, loc) {
return actionConnect(nodeIDs).disabled(graph);
};
+ action.id = 'merge_nodes';
+
return action;
}
diff --git a/modules/actions/merge_polygon.js b/modules/actions/merge_polygon.js
index 0f29cd5b5e5..07b2e397d9e 100644
--- a/modules/actions/merge_polygon.js
+++ b/modules/actions/merge_polygon.js
@@ -153,6 +153,8 @@ export function actionMergePolygon(ids, newRelationId) {
}
};
+ action.id = 'merge_polygon';
+
return action;
}
diff --git a/modules/operations/index.js b/modules/operations/index.js
index c76dd5a1f4f..de2d11dbb3e 100644
--- a/modules/operations/index.js
+++ b/modules/operations/index.js
@@ -5,6 +5,7 @@ export { operationDelete } from './delete';
export { operationDisconnect } from './disconnect';
export { operationDowngrade } from './downgrade';
export { operationExtract } from './extract';
+export { operationMembersRemove } from './members_remove';
export { operationMerge } from './merge';
export { operationMove } from './move';
export { operationOrthogonalize } from './orthogonalize';
diff --git a/modules/operations/members_remove.js b/modules/operations/members_remove.js
new file mode 100644
index 00000000000..48766c3749c
--- /dev/null
+++ b/modules/operations/members_remove.js
@@ -0,0 +1,52 @@
+import { t } from '../core/localizer';
+
+import { actionMembersRemove } from '../actions/members_remove';
+
+import { behaviorOperation } from '../behavior/operation';
+import { modeSelect } from '../modes/select';
+
+export function operationMembersRemove(context, selectedIDs) {
+
+ var _action = actionMembersRemove(selectedIDs);
+
+ var operation = function() {
+
+ if (operation.disabled()) return;
+
+ context.perform(_action, operation.annotation());
+
+ context.validator().validate();
+
+ var resultIDs = selectedIDs.filter(context.hasEntity);
+ if (resultIDs.length > 1) {
+ var interestingIDs = resultIDs.filter(function(id) {
+ return context.entity(id).hasInterestingTags();
+ });
+ if (interestingIDs.length) resultIDs = interestingIDs;
+ }
+ context.enter(modeSelect(context, resultIDs));
+ };
+
+ operation.available = function() {
+ return !_action.disabled(context.graph());
+ };
+
+ operation.disabled = function() {
+ return _action.disabled(context.graph());
+ };
+
+ operation.tooltip = function() {
+ return t.append('operations.members_remove.description');
+ };
+
+ operation.annotation = function() {
+ return t('operations.members_remove.annotation', { n: selectedIDs.length });
+ };
+
+ operation.id = 'members_remove';
+ operation.keys = [];
+ operation.title = t.append('operations.members_remove.title');
+ operation.behavior = behaviorOperation(context).which(operation);
+
+ return operation;
+}
diff --git a/modules/operations/merge.js b/modules/operations/merge.js
index 60b4baed1b1..ad5fc116033 100644
--- a/modules/operations/merge.js
+++ b/modules/operations/merge.js
@@ -4,6 +4,7 @@ import { actionJoin } from '../actions/join';
import { actionMerge } from '../actions/merge';
import { actionMergeNodes } from '../actions/merge_nodes';
import { actionMergePolygon } from '../actions/merge_polygon';
+import { actionMergeMembers } from '../actions/merge_members';
import { behaviorOperation } from '../behavior/operation';
import { modeSelect } from '../modes/select';
@@ -15,18 +16,21 @@ export function operationMerge(context, selectedIDs) {
function getAction() {
// prefer a non-disabled action first
- var join = actionJoin(selectedIDs);
+ const join = actionJoin(selectedIDs);
if (!join.disabled(context.graph())) return join;
- var merge = actionMerge(selectedIDs);
+ const merge = actionMerge(selectedIDs);
if (!merge.disabled(context.graph())) return merge;
- var mergePolygon = actionMergePolygon(selectedIDs);
+ const mergePolygon = actionMergePolygon(selectedIDs);
if (!mergePolygon.disabled(context.graph())) return mergePolygon;
- var mergeNodes = actionMergeNodes(selectedIDs);
+ const mergeNodes = actionMergeNodes(selectedIDs);
if (!mergeNodes.disabled(context.graph())) return mergeNodes;
+ const mergeWithRelation = actionMergeMembers(selectedIDs);
+ if (!mergeWithRelation.disabled(context.graph())) return mergeWithRelation;
+
// otherwise prefer an action with an interesting disabled reason
if (join.disabled(context.graph()) !== 'not_eligible') return join;
if (merge.disabled(context.graph()) !== 'not_eligible') return merge;
@@ -83,7 +87,7 @@ export function operationMerge(context, selectedIDs) {
}
return t.append('operations.merge.' + disabled);
}
- return t.append('operations.merge.description');
+ return t.append(`operations.merge.description.${_action.id}`);
};
operation.annotation = function() {
diff --git a/modules/ui/edit_menu.js b/modules/ui/edit_menu.js
index c91827b7c66..47083db2d0b 100644
--- a/modules/ui/edit_menu.js
+++ b/modules/ui/edit_menu.js
@@ -107,7 +107,7 @@ export function uiEditMenu(context) {
var tooltip = uiTooltip()
.heading(() => d.title)
.title(d.tooltip)
- .keys([d.keys[0]]);
+ .keys(d.keys.slice(0, 1));
_tooltips.push(tooltip);
diff --git a/modules/ui/sections/raw_member_editor.js b/modules/ui/sections/raw_member_editor.js
index 8671760243d..88b06cc8e12 100644
--- a/modules/ui/sections/raw_member_editor.js
+++ b/modules/ui/sections/raw_member_editor.js
@@ -16,7 +16,7 @@ import { svgIcon } from '../../svg/icon';
import { services } from '../../services';
import { uiCombobox } from '../combobox';
import { uiSection } from '../section';
-import { utilDisplayName, utilDisplayType, utilHighlightEntities, utilNoAuto, utilUniqueDomId } from '../../util';
+import { utilArrayGroupBy, utilDisplayName, utilDisplayType, utilHighlightEntities, utilNoAuto, utilUniqueDomId } from '../../util';
export function uiSectionRawMemberEditor(context) {
@@ -34,7 +34,7 @@ export function uiSectionRawMemberEditor(context) {
var gt = entity.members.length > _maxMembers ? '>' : '';
var count = gt + entity.members.slice(0, _maxMembers).length;
- return t.append('inspector.title_count', { title: t('inspector.members'), count: count });
+ return t.append('inspector.title_count', { title: t('inspector.members.title'), count: count });
})
.disclosureContent(renderDisclosureContent);
@@ -126,15 +126,138 @@ export function uiSectionRawMemberEditor(context) {
var memberships = [];
var entity = context.entity(entityID);
- entity.members.slice(0, _maxMembers).forEach(function(member, index) {
+
+ const graph = context.graph();
+ const downloadMembers = selection.selectAll('.members-download')
+ .data(entity.members.every(m => graph.hasEntity(m.id)) ? []: [0]);
+ const downloadMembersEnter = downloadMembers.enter()
+ //.append('div')
+ .insert('div', ':first-child')
+ .classed('members-download', true)
+ .classed('section-footer', true)
+ .append('a')
+ .attr('role', 'button')
+ .on('click', function (d3_event) {
+ d3_event.preventDefault();
+ const button = d3_select(this).select('button');
+ // display the loading indicator
+ button.classed('loading', true);
+ context.loadEntity(entity.id, () => section.reRender());
+ });
+ downloadMembersEnter
+ .call(t.append('inspector.members.download_all'));
+ downloadMembersEnter
+ .append('button')
+ .attr('title', t('icons.download'))
+ .call(svgIcon('#iD-icon-load'));
+ downloadMembers.exit().remove();
+
+
+ function connects(memberA, memberB, direction, ignoreNode) {
+ const entityA = context.hasEntity(memberA.id);
+ const entityB = context.hasEntity(memberB.id);
+ if (entityA === undefined || entityA.type !== 'way') return false;
+ if (entityB === undefined || entityB.type !== 'way') return false;
+ // determine valid connection points between A and B
+ const pointsA = [];
+ const pointsB = [];
+ if (memberA.role === 'forward' && direction || memberA.role === 'backward' && !direction) {
+ pointsA.push(entityA.nodes[entityA.nodes.length - 1]);
+ } else if (memberA.role === 'backward' && direction || memberA.role === 'forward' && !direction) {
+ pointsA.push(entityA.nodes[0]);
+ } else if (entityA.tags.junction === 'roundabout' && entityA.isClosed()) {
+ entityA.nodes.forEach(n => pointsA.push(n));
+ } else {
+ pointsA.push(entityA.nodes[entityA.nodes.length - 1]);
+ pointsA.push(entityA.nodes[0]);
+ }
+ if (memberB.role === 'forward' && direction || memberB.role === 'backward' && !direction) {
+ pointsB.push(entityB.nodes[0]);
+ } else if (memberB.role === 'backward' && direction || memberB.role === 'forward' && !direction) {
+ pointsB.push(entityB.nodes[entityB.nodes.length - 1]);
+ } else if (entityB.tags.junction === 'roundabout' && entityB.isClosed()) {
+ entityB.nodes.forEach(n => pointsB.push(n));
+ } else {
+ pointsB.push(entityB.nodes[entityB.nodes.length - 1]);
+ pointsB.push(entityB.nodes[0]);
+ }
+ return pointsA.find(idA =>
+ idA !== ignoreNode &&
+ pointsB.indexOf(idA) !== -1);
+ }
+
+ const members = entity.members.slice(0, _maxMembers);
+
+ const forwardConnections = members.map(() => undefined);
+ let thatIndex = 0;
+ let that = members[thatIndex];
+ let lastConnectionVertex;
+ for (let i = 1; i < members.length; i++) {
+ const cur = members[i];
+ const connectionVertex = connects(that, cur, true, lastConnectionVertex);
+ if (connectionVertex) {
+ forwardConnections[thatIndex] = true;
+ lastConnectionVertex = connectionVertex;
+ } else if (cur.role !== 'forward' && cur.role !== 'backward') {
+ forwardConnections[thatIndex] = false;
+ lastConnectionVertex = undefined;
+ } else {
+ // role is forward or backward -> skip current member and try next
+ continue;
+ }
+ that = cur;
+ thatIndex = i;
+ }
+ const backwardConnections = members.map(() => undefined);
+ thatIndex = members.length - 1;
+ that = members[thatIndex];
+ lastConnectionVertex = undefined;
+ for (let i = members.length - 2; i >= 0; i--) {
+ const cur = members[i];
+ const connectionVertex = connects(cur, that, false, lastConnectionVertex);
+ if (connectionVertex) {
+ backwardConnections[thatIndex] = true;
+ lastConnectionVertex = connectionVertex;
+ } else if (cur.role !== 'forward' && cur.role !== 'backward') {
+ backwardConnections[thatIndex] = false;
+ lastConnectionVertex = undefined;
+ } else {
+ continue;
+ }
+ that = cur;
+ thatIndex = i;
+ }
+ const loopsConnections = members.map(() => undefined);
+ for (let i = 0; i < members.length; i++) {
+ if (forwardConnections[i] === false || i === members.length - 1) {
+ // check if current segment forms a loop
+ let j = i - 1;
+ while (j >= 0 && forwardConnections[j] !== false) {
+ j--;
+ }
+ if (i !== j + 1 && connects(members[i], members[j + 1], true)) {
+ loopsConnections[i] = true;
+ loopsConnections[j + 1] = true;
+ }
+ }
+ }
+
+ members.forEach(function(member, index) {
+ const memberEntity = context.hasEntity(member.id);
memberships.push({
index: index,
id: member.id,
type: member.type,
role: member.role,
relation: entity,
- member: context.hasEntity(member.id),
- domId: utilUniqueDomId(entityID + '-member-' + index)
+ member: memberEntity,
+ domId: utilUniqueDomId(entityID + '-member-' + index),
+ connections: {
+ next: forwardConnections[index],
+ prev: backwardConnections[index],
+ joined: forwardConnections[index] || backwardConnections[index + 1],
+ loops: loopsConnections[index]
+ }
});
});
@@ -148,10 +271,11 @@ export function uiSectionRawMemberEditor(context) {
var items = list.selectAll('li')
- .data(memberships, function(d) {
- return osmEntity.key(d.relation) + ',' + d.index + ',' +
- (d.member ? osmEntity.key(d.member) : 'incomplete');
- });
+ .data(memberships, d =>
+ osmEntity.key(d.relation) + ',' + d.index + ','
+ + (d.member ? osmEntity.key(d.member) : 'incomplete') + ','
+ + Object.values(d.connections).join('-')
+ );
items.exit()
.each(unbind)
@@ -159,18 +283,53 @@ export function uiSectionRawMemberEditor(context) {
var itemsEnter = items.enter()
.append('li')
- .attr('class', 'member-row form-field')
- .classed('member-incomplete', function(d) { return !d.member; });
+ .classed('member-row form-field', true)
+ .classed('member-incomplete', d => !d.member)
+ .classed('member-connects', d => d.connections.joined)
+ .classed('member-connects-prev', d => d.connections.prev)
+ .classed('member-connects-next', d => d.connections.next);
itemsEnter
.each(function(d) {
- var item = d3_select(this);
+ const item = d3_select(this);
- var label = item
+ const label = item
.append('label')
- .attr('class', 'field-label')
+ .classed('field-label', true)
.attr('for', d.domId);
+ const wrap = item
+ .append('div')
+ .classed('form-field-input-wrap', true)
+ .classed('form-field-input-member', true);
+
+ wrap
+ .append('span')
+ .classed('grab-icon', true)
+ .attr('title', t('inspector.members.grab'))
+ .each(function(d) {
+ if (d.connections.prev && d.connections.next || d.connections.loops) {
+ d3_select(this).call(svgIcon('#iD-icon-grab-connects-both'));
+ } else if (d.connections.prev) {
+ d3_select(this).call(svgIcon('#iD-icon-grab-connects-prev'));
+ } else if (d.connections.next) {
+ d3_select(this).call(svgIcon('#iD-icon-grab-connects-next'));
+ } else {
+ d3_select(this).call(svgIcon('#iD-icon-grab'));
+ }
+ });
+
+ wrap.append('input')
+ .attr('class', 'member-role')
+ .attr('id', d => d.domId)
+ .property('type', 'text')
+ .attr('placeholder', t('inspector.role'))
+ .call(utilNoAuto);
+
+ if (taginfo) {
+ wrap.each(bindTypeahead);
+ }
+
if (d.member) {
// highlight the member feature in the map while hovering on the list item
item
@@ -203,15 +362,31 @@ export function uiSectionRawMemberEditor(context) {
.style('border-color', d => d.member.type === 'relation' && d.member.tags.colour)
.text(function(d) { return utilDisplayName(d.member); });
- label
+ wrap
.append('button')
+ .classed('form-field-button', true)
.attr('title', t('icons.remove'))
- .attr('class', 'remove member-delete')
+ .classed('remove', true)
+ .classed('member-delete', true)
.call(svgIcon('#iD-operation-delete'));
- label
+ wrap.select('.grab-icon')
+ .each(function(d) {
+ if (d.connections.prev && d.connections.next) {
+ d3_select(this).call(svgIcon('#iD-icon-grab-connects-both'));
+ } else if (d.connections.prev) {
+ d3_select(this).call(svgIcon('#iD-icon-grab-connects-prev'));
+ } else if (d.connections.next) {
+ d3_select(this).call(svgIcon('#iD-icon-grab-connects-next'));
+ } else {
+ d3_select(this).call(svgIcon('#iD-icon-grab'));
+ }
+ });
+
+ wrap
.append('button')
.attr('class', 'member-zoom')
+ .classed('form-field-button', true)
.attr('title', t('icons.zoom_to'))
.call(svgIcon('#iD-icon-framed-dot', 'monochrome'))
.on('click', zoomToMember);
@@ -231,33 +406,16 @@ export function uiSectionRawMemberEditor(context) {
.attr('class', 'member-entity-name')
.call(t.append('inspector.incomplete', { id: d.id }));
- label
+ wrap
.append('button')
.attr('class', 'member-download')
+ .classed('form-field-button', true)
.attr('title', t('icons.download'))
.call(svgIcon('#iD-icon-load'))
.on('click', downloadMember);
}
});
- var wrapEnter = itemsEnter
- .append('div')
- .attr('class', 'form-field-input-wrap form-field-input-member');
-
- wrapEnter
- .append('input')
- .attr('class', 'member-role')
- .attr('id', function(d) {
- return d.domId;
- })
- .property('type', 'text')
- .attr('placeholder', t('inspector.role'))
- .call(utilNoAuto);
-
- if (taginfo) {
- wrapEnter.each(bindTypeahead);
- }
-
// update
items = items
.merge(itemsEnter)
@@ -271,6 +429,23 @@ export function uiSectionRawMemberEditor(context) {
items.select('button.member-delete')
.on('click', deleteMember);
+ const dupeLabels = new WeakSet(Object.values(
+ utilArrayGroupBy(items.selectAll('.label-text').nodes(), 'textContent'))
+ .filter(v => v.length > 1)
+ .flat());
+
+ items.select('.label-text').each(function() {
+ const label = d3_select(this);
+ const entityName = label.select('.member-entity-name');
+ if (dupeLabels.has(this)) {
+ // Dedupe identical names in hover text by appending entity id - see #2891, #10184
+ label.attr('title', d => `${entityName.text()} ${d.id}`);
+ } else {
+ // set full label also as hover text: useful if a (long) label is cut off with an … ellipsis
+ label.attr('title', () => entityName.text());
+ }
+ });
+
var dragOrigin, targetIndex;
items.call(d3_drag()
diff --git a/modules/ui/sections/raw_membership_editor.js b/modules/ui/sections/raw_membership_editor.js
index 4efda067c26..7c0db7f762d 100644
--- a/modules/ui/sections/raw_membership_editor.js
+++ b/modules/ui/sections/raw_membership_editor.js
@@ -423,33 +423,7 @@ export function uiSectionRawMembershipEditor(context) {
return utilDisplayName(d.relation, matched.suggestion);
});
- labelEnter
- .append('button')
- .attr('class', 'members-download')
- .attr('title', t('icons.download'))
- .call(svgIcon('#iD-icon-load'))
- .on('click', downloadMembers);
-
- labelEnter
- .append('button')
- .attr('class', 'remove member-delete')
- .attr('title', t('icons.remove'))
- .call(svgIcon('#iD-operation-delete'))
- .on('click', deleteMembership);
-
- labelEnter
- .append('button')
- .attr('class', 'member-zoom')
- .attr('title', t('icons.zoom_to'))
- .call(svgIcon('#iD-icon-framed-dot', 'monochrome'))
- .on('click', zoomToRelation);
-
items = items.merge(itemsEnter);
- items.selectAll('button.members-download')
- .classed('hide', d => {
- const graph = context.graph();
- return d.relation.members.every(m => graph.hasEntity(m.id));
- });
const dupeLabels = new WeakSet(Object.values(
utilArrayGroupBy(items.selectAll('.label-text').nodes(), 'textContent'))
@@ -495,6 +469,36 @@ export function uiSectionRawMembershipEditor(context) {
.on('blur', changeRole)
.on('change', changeRole);
+ wrapEnter
+ .append('button')
+ .classed('members-download', true)
+ .classed('form-field-button', true)
+ .attr('title', t('icons.download'))
+ .call(svgIcon('#iD-icon-load'))
+ .on('click', downloadMembers);
+
+ items.selectAll('button.members-download')
+ .classed('hide', d => {
+ const graph = context.graph();
+ return d.relation.members.every(m => graph.hasEntity(m.id));
+ });
+
+ wrapEnter
+ .append('button')
+ .classed('remove member-delete', true)
+ .classed('form-field-button', true)
+ .attr('title', t('icons.remove'))
+ .call(svgIcon('#iD-operation-delete'))
+ .on('click', deleteMembership);
+
+ wrapEnter
+ .append('button')
+ .classed('member-zoom', true)
+ .classed('form-field-button', true)
+ .attr('title', t('icons.zoom_to'))
+ .call(svgIcon('#iD-icon-framed-dot', 'monochrome'))
+ .on('click', zoomToRelation);
+
if (taginfo) {
wrapEnter.each(bindTypeahead);
}
diff --git a/svg/iD-sprite/icons/icon-grab-connects-both.svg b/svg/iD-sprite/icons/icon-grab-connects-both.svg
new file mode 100644
index 00000000000..94eef349250
--- /dev/null
+++ b/svg/iD-sprite/icons/icon-grab-connects-both.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/svg/iD-sprite/icons/icon-grab-connects-next.svg b/svg/iD-sprite/icons/icon-grab-connects-next.svg
new file mode 100644
index 00000000000..a252c308457
--- /dev/null
+++ b/svg/iD-sprite/icons/icon-grab-connects-next.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/svg/iD-sprite/icons/icon-grab-connects-prev.svg b/svg/iD-sprite/icons/icon-grab-connects-prev.svg
new file mode 100644
index 00000000000..aafe166c71d
--- /dev/null
+++ b/svg/iD-sprite/icons/icon-grab-connects-prev.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/svg/iD-sprite/icons/icon-grab.svg b/svg/iD-sprite/icons/icon-grab.svg
new file mode 100644
index 00000000000..b4c81f6d0f2
--- /dev/null
+++ b/svg/iD-sprite/icons/icon-grab.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/svg/iD-sprite/operations/operation-members_remove.svg b/svg/iD-sprite/operations/operation-members_remove.svg
new file mode 100644
index 00000000000..17e3fa215ac
--- /dev/null
+++ b/svg/iD-sprite/operations/operation-members_remove.svg
@@ -0,0 +1,5 @@
+
+
+