modules/behavior/draw_way.js (324 lines of code) (raw):
import { dispatch as d3_dispatch } from 'd3-dispatch';
import {
select as d3_select
} from 'd3-selection';
import { presetManager } from '../presets';
import { t } from '../core/localizer';
import { actionAddMidpoint } from '../actions/add_midpoint';
import { actionMoveNode } from '../actions/move_node';
import { actionNoop } from '../actions/noop';
import { behaviorDraw } from './draw';
import { geoChooseEdge, geoHasSelfIntersections } from '../geo';
import { modeBrowse } from '../modes/browse';
import { modeSelect } from '../modes/select';
import { osmNode } from '../osm/node';
import { utilRebind } from '../util/rebind';
import { utilKeybinding } from '../util';
export function behaviorDrawWay(context, wayID, mode, startGraph) {
var dispatch = d3_dispatch('rejectedSelfIntersection');
var behavior = behaviorDraw(context);
// Must be set by `drawWay.nodeIndex` before each install of this behavior.
var _nodeIndex;
var _origWay;
var _wayGeometry;
var _headNodeID;
var _annotation;
var _pointerHasMoved = false;
// The osmNode to be placed.
// This is temporary and just follows the mouse cursor until an "add" event occurs.
var _drawNode;
var _didResolveTempEdit = false;
function createDrawNode(loc) {
// don't make the draw node until we actually need it
_drawNode = osmNode({ loc: loc });
context.pauseChangeDispatch();
context.replace(function actionAddDrawNode(graph) {
// add the draw node to the graph and insert it into the way
var way = graph.entity(wayID);
return graph
.replace(_drawNode)
.replace(way.addNode(_drawNode.id, _nodeIndex));
}, _annotation);
context.resumeChangeDispatch();
setActiveElements();
}
function removeDrawNode() {
context.pauseChangeDispatch();
context.replace(
function actionDeleteDrawNode(graph) {
var way = graph.entity(wayID);
return graph
.replace(way.removeNode(_drawNode.id))
.remove(_drawNode);
},
_annotation
);
_drawNode = undefined;
context.resumeChangeDispatch();
}
function keydown(d3_event) {
if (d3_event.keyCode === utilKeybinding.modifierCodes.alt) {
if (context.surface().classed('nope')) {
context.surface()
.classed('nope-suppressed', true);
}
context.surface()
.classed('nope', false)
.classed('nope-disabled', true);
}
}
function keyup(d3_event) {
if (d3_event.keyCode === utilKeybinding.modifierCodes.alt) {
if (context.surface().classed('nope-suppressed')) {
context.surface()
.classed('nope', true);
}
context.surface()
.classed('nope-suppressed', false)
.classed('nope-disabled', false);
}
}
function allowsVertex(d) {
return d.geometry(context.graph()) === 'vertex' || presetManager.allowsVertex(d, context.graph());
}
// related code
// - `mode/drag_node.js` `doMove()`
// - `behavior/draw.js` `click()`
// - `behavior/draw_way.js` `move()`
function move(d3_event, datum) {
var loc = context.map().mouseCoordinates();
if (!_drawNode) createDrawNode(loc);
context.surface().classed('nope-disabled', d3_event.altKey);
var targetLoc = datum && datum.properties && datum.properties.entity &&
allowsVertex(datum.properties.entity) && datum.properties.entity.loc;
var targetNodes = datum && datum.properties && datum.properties.nodes;
if (targetLoc) { // snap to node/vertex - a point target with `.loc`
loc = targetLoc;
} else if (targetNodes) { // snap to way - a line target with `.nodes`
var choice = geoChooseEdge(targetNodes, context.map().mouse(), context.projection, _drawNode.id);
if (choice) {
loc = choice.loc;
}
}
context.replace(actionMoveNode(_drawNode.id, loc), _annotation);
_drawNode = context.entity(_drawNode.id);
checkGeometry(true /* includeDrawNode */);
}
// Check whether this edit causes the geometry to break.
// If so, class the surface with a nope cursor.
// `includeDrawNode` - Only check the relevant line segments if finishing drawing
function checkGeometry(includeDrawNode) {
var nopeDisabled = context.surface().classed('nope-disabled');
var isInvalid = isInvalidGeometry(includeDrawNode);
if (nopeDisabled) {
context.surface()
.classed('nope', false)
.classed('nope-suppressed', isInvalid);
} else {
context.surface()
.classed('nope', isInvalid)
.classed('nope-suppressed', false);
}
}
function isInvalidGeometry(includeDrawNode) {
var testNode = _drawNode;
// we only need to test the single way we're drawing
var parentWay = context.graph().entity(wayID);
var nodes = context.graph().childNodes(parentWay).slice(); // shallow copy
if (includeDrawNode) {
if (parentWay.isClosed()) {
// don't test the last segment for closed ways - #4655
// (still test the first segment)
nodes.pop();
}
} else { // discount the draw node
if (parentWay.isClosed()) {
if (nodes.length < 3) return false;
if (_drawNode) nodes.splice(-2, 1);
testNode = nodes[nodes.length - 2];
} else {
// there's nothing we need to test if we ignore the draw node on open ways
return false;
}
}
return testNode && geoHasSelfIntersections(nodes, testNode.id);
}
function undone() {
// undoing removed the temp edit
_didResolveTempEdit = true;
context.pauseChangeDispatch();
var nextMode;
if (context.graph() === startGraph) {
// We've undone back to the initial state before we started drawing.
// Just exit the draw mode without undoing whatever we did before
// we entered the draw mode.
nextMode = modeSelect(context, [wayID]);
} else {
// The `undo` only removed the temporary edit, so here we have to
// manually undo to actually remove the last node we added. We can't
// use the `undo` function since the initial "add" graph doesn't have
// an annotation and so cannot be undone to.
context.pop(1);
// continue drawing
nextMode = mode;
}
// clear the redo stack by adding and removing a blank edit
context.perform(actionNoop());
context.pop(1);
context.resumeChangeDispatch();
context.enter(nextMode);
}
function setActiveElements() {
if (!_drawNode) return;
context.surface().selectAll('.' + _drawNode.id)
.classed('active', true);
}
function resetToStartGraph() {
while (context.graph() !== startGraph) {
context.pop();
}
}
var drawWay = function(surface) {
_drawNode = undefined;
_didResolveTempEdit = false;
_origWay = context.entity(wayID);
if (typeof _nodeIndex === 'number') {
_headNodeID = _origWay.nodes[_nodeIndex];
} else if (_origWay.isClosed()) {
_headNodeID = _origWay.nodes[_origWay.nodes.length - 2];
} else {
_headNodeID = _origWay.nodes[_origWay.nodes.length - 1];
}
_wayGeometry = _origWay.geometry(context.graph());
_annotation = t((_origWay.nodes.length === (_origWay.isClosed() ? 2 : 1) ?
'operations.start.annotation.' :
'operations.continue.annotation.') + _wayGeometry
);
_pointerHasMoved = false;
// Push an annotated state for undo to return back to.
// We must make sure to replace or remove it later.
context.pauseChangeDispatch();
context.perform(actionNoop(), _annotation);
context.resumeChangeDispatch();
behavior.hover()
.initialNodeID(_headNodeID);
behavior
.on('move', function() {
_pointerHasMoved = true;
move.apply(this, arguments);
})
.on('down', function() {
move.apply(this, arguments);
})
.on('downcancel', function() {
if (_drawNode) removeDrawNode();
})
.on('click', drawWay.add)
.on('clickWay', drawWay.addWay)
.on('clickNode', drawWay.addNode)
.on('undo', context.undo)
.on('cancel', drawWay.cancel)
.on('finish', drawWay.finish);
d3_select(window)
.on('keydown.drawWay', keydown)
.on('keyup.drawWay', keyup);
context.map()
.dblclickZoomEnable(false)
.on('drawn.draw', setActiveElements);
setActiveElements();
surface.call(behavior);
context.history()
.on('undone.draw', undone);
};
drawWay.off = function(surface) {
if (!_didResolveTempEdit) {
// Drawing was interrupted unexpectedly.
// This can happen if the user changes modes,
// clicks geolocate button, a hashchange event occurs, etc.
context.pauseChangeDispatch();
resetToStartGraph();
context.resumeChangeDispatch();
}
_drawNode = undefined;
_nodeIndex = undefined;
context.map()
.on('drawn.draw', null);
surface.call(behavior.off)
.selectAll('.active')
.classed('active', false);
surface
.classed('nope', false)
.classed('nope-suppressed', false)
.classed('nope-disabled', false);
d3_select(window)
.on('keydown.drawWay', null)
.on('keyup.drawWay', null);
context.history()
.on('undone.draw', null);
};
function attemptAdd(d, loc, doAdd) {
if (_drawNode) {
// move the node to the final loc in case move wasn't called
// consistently (e.g. on touch devices)
context.replace(actionMoveNode(_drawNode.id, loc), _annotation);
_drawNode = context.entity(_drawNode.id);
} else {
createDrawNode(loc);
}
checkGeometry(true /* includeDrawNode */);
if ((d && d.properties && d.properties.nope) || context.surface().classed('nope')) {
if (!_pointerHasMoved) {
// prevent the temporary draw node from appearing on touch devices
removeDrawNode();
}
dispatch.call('rejectedSelfIntersection', this);
return; // can't click here
}
context.pauseChangeDispatch();
doAdd();
// we just replaced the temporary edit with the real one
_didResolveTempEdit = true;
context.resumeChangeDispatch();
context.enter(mode);
}
// Accept the current position of the drawing node
drawWay.add = function(loc, d) {
attemptAdd(d, loc, function() {
// don't need to do anything extra
});
};
// Connect the way to an existing way
drawWay.addWay = function(loc, edge, d) {
attemptAdd(d, loc, function() {
context.replace(
actionAddMidpoint({ loc: loc, edge: edge }, _drawNode),
_annotation
);
});
};
// Connect the way to an existing node
drawWay.addNode = function(node, d) {
// finish drawing if the mapper targets the prior node
if (node.id === _headNodeID ||
// or the first node when drawing an area
(_origWay.isClosed() && node.id === _origWay.first())) {
drawWay.finish();
return;
}
attemptAdd(d, node.loc, function() {
context.replace(
function actionReplaceDrawNode(graph) {
// remove the temporary draw node and insert the existing node
// at the same index
graph = graph
.replace(graph.entity(wayID).removeNode(_drawNode.id))
.remove(_drawNode);
return graph
.replace(graph.entity(wayID).addNode(node.id, _nodeIndex));
},
_annotation
);
});
};
// Finish the draw operation, removing the temporary edit.
// If the way has enough nodes to be valid, it's selected.
// Otherwise, delete everything and return to browse mode.
drawWay.finish = function() {
checkGeometry(false /* includeDrawNode */);
if (context.surface().classed('nope')) {
dispatch.call('rejectedSelfIntersection', this);
return; // can't click here
}
context.pauseChangeDispatch();
// remove the temporary edit
context.pop(1);
_didResolveTempEdit = true;
context.resumeChangeDispatch();
var way = context.hasEntity(wayID);
if (!way || way.isDegenerate()) {
drawWay.cancel();
return;
}
window.setTimeout(function() {
context.map().dblclickZoomEnable(true);
}, 1000);
var isNewFeature = !mode.isContinuing;
context.enter(modeSelect(context, [wayID]).newFeature(isNewFeature));
};
// Cancel the draw operation, delete everything, and return to browse mode.
drawWay.cancel = function() {
context.pauseChangeDispatch();
resetToStartGraph();
context.resumeChangeDispatch();
window.setTimeout(function() {
context.map().dblclickZoomEnable(true);
}, 1000);
context.surface()
.classed('nope', false)
.classed('nope-disabled', false)
.classed('nope-suppressed', false);
context.enter(modeBrowse(context));
};
drawWay.nodeIndex = function(val) {
if (!arguments.length) return _nodeIndex;
_nodeIndex = val;
return drawWay;
};
drawWay.activeID = function() {
if (!arguments.length) return _drawNode && _drawNode.id;
// no assign
return drawWay;
};
return utilRebind(drawWay, dispatch, 'on');
}