Skip to content

Move and resize line shapes #2594

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
May 9, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build/plotcss.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ var rules = {
"X .cursor-nw-resize": "cursor:nw-resize;",
"X .cursor-n-resize": "cursor:n-resize;",
"X .cursor-ne-resize": "cursor:ne-resize;",
"X .cursor-grab": "cursor:-webkit-grab;cursor:grab;",
"X .modebar": "position:absolute;top:2px;right:2px;z-index:1001;background:rgba(255,255,255,0.7);",
"X .modebar--hover": "opacity:0;-webkit-transition:opacity 0.3s ease 0s;-moz-transition:opacity 0.3s ease 0s;-ms-transition:opacity 0.3s ease 0s;-o-transition:opacity 0.3s ease 0s;transition:opacity 0.3s ease 0s;",
"X:hover .modebar--hover": "opacity:1;",
Expand Down
3 changes: 2 additions & 1 deletion src/components/annotations/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ function drawRaw(gd, options, index, subplotId, xa, ya) {

var annTextGroupInner = annTextGroup.append('g')
.style('pointer-events', textEvents ? 'all' : null)
.call(setCursor, 'default')
.call(setCursor, 'pointer')
.on('click', function() {
gd._dragging = false;

Expand Down Expand Up @@ -533,6 +533,7 @@ function drawRaw(gd, options, index, subplotId, xa, ya) {
var arrowDrag = arrowGroup.append('path')
.classed('annotation-arrow', true)
.classed('anndrag', true)
.classed('cursor-move', true)
.attr({
d: 'M3,3H-3V-3H3ZM0,0L' + (tailX - arrowDragHeadX) + ',' + (tailY - arrowDragHeadY),
transform: 'translate(' + arrowDragHeadX + ',' + arrowDragHeadY + ')'
Expand Down
19 changes: 1 addition & 18 deletions src/components/shapes/calc_autorange.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function calcPaddingOptions(lineWidth, sizeMode, v0, v1, path, isYAxis) {

if(sizeMode === 'pixel') {
var coords = path ?
extractPathCoords(path, isYAxis ? constants.paramIsY : constants.paramIsX) :
helpers.extractPathCoords(path, isYAxis ? constants.paramIsY : constants.paramIsX) :
[v0, v1];
var maxValue = Lib.aggNums(Math.max, null, coords),
minValue = Lib.aggNums(Math.min, null, coords),
Expand All @@ -79,23 +79,6 @@ function calcPaddingOptions(lineWidth, sizeMode, v0, v1, path, isYAxis) {
}
}

function extractPathCoords(path, paramsToUse) {
var extractedCoordinates = [];

var segments = path.match(constants.segmentRE);
segments.forEach(function(segment) {
var relevantParamIdx = paramsToUse[segment.charAt(0)].drawn;
if(relevantParamIdx === undefined) return;

var params = segment.substr(1).match(constants.paramRE);
if(!params || params.length < relevantParamIdx) return;

extractedCoordinates.push(params[relevantParamIdx]);
});

return extractedCoordinates;
}

function shapeBounds(ax, v0, v1, path, paramsToUse) {
var convertVal = (ax.type === 'category') ? ax.r2c : ax.d2c;

Expand Down
261 changes: 216 additions & 45 deletions src/components/shapes/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,77 +108,143 @@ function drawOne(gd, index) {
.call(Color.fill, options.fillcolor)
.call(Drawing.dashLine, options.line.dash, options.line.width);

// note that for layer="below" the clipAxes can be different from the
// subplot we're drawing this in. This could cause problems if the shape
// spans two subplots. See https://github.com/plotly/plotly.js/issues/1452
var clipAxes = (options.xref + options.yref).replace(/paper/g, '');

path.call(Drawing.setClipUrl, clipAxes ?
('clip' + gd._fullLayout._uid + clipAxes) :
null
);
setClipPath(path, gd, options);

if(gd._context.edits.shapePosition) setupDragElement(gd, path, options, index);
if(gd._context.edits.shapePosition) setupDragElement(gd, path, options, index, shapeLayer);
}
}

function setupDragElement(gd, shapePath, shapeOptions, index) {
function setClipPath(shapePath, gd, shapeOptions) {
// note that for layer="below" the clipAxes can be different from the
// subplot we're drawing this in. This could cause problems if the shape
// spans two subplots. See https://github.com/plotly/plotly.js/issues/1452
var clipAxes = (shapeOptions.xref + shapeOptions.yref).replace(/paper/g, '');

shapePath.call(Drawing.setClipUrl, clipAxes ?
('clip' + gd._fullLayout._uid + clipAxes) :
null
);
}

function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer) {
var MINWIDTH = 10,
MINHEIGHT = 10;

var xPixelSized = shapeOptions.xsizemode === 'pixel',
yPixelSized = shapeOptions.ysizemode === 'pixel';
yPixelSized = shapeOptions.ysizemode === 'pixel',
isLine = shapeOptions.type === 'line',
isPath = shapeOptions.type === 'path';

var update;
var x0, y0, x1, y1, xAnchor, yAnchor, astrX0, astrY0, astrX1, astrY1, astrXAnchor, astrYAnchor;
var n0, s0, w0, e0, astrN, astrS, astrW, astrE, optN, optS, optW, optE;
var pathIn, astrPath;

var xa, ya, x2p, y2p, p2x, p2y;
// setup conversion functions
var xa = Axes.getFromId(gd, shapeOptions.xref),
ya = Axes.getFromId(gd, shapeOptions.yref),
x2p = helpers.getDataToPixel(gd, xa),
y2p = helpers.getDataToPixel(gd, ya, true),
p2x = helpers.getPixelToData(gd, xa),
p2y = helpers.getPixelToData(gd, ya, true);

var sensoryElement = obtainSensoryElement();
var dragOptions = {
element: shapePath.node(),
element: sensoryElement.node(),
gd: gd,
prepFn: startDrag,
doneFn: endDrag
doneFn: endDrag,
clickFn: abortDrag
},
dragMode;

dragElement.init(dragOptions);

shapePath.node().onmousemove = updateDragMode;
sensoryElement.node().onmousemove = updateDragMode;

function updateDragMode(evt) {
// element might not be on screen at time of setup,
// so obtain bounding box here
var dragBBox = dragOptions.element.getBoundingClientRect();

// choose 'move' or 'resize'
// based on initial position of cursor within the drag element
var w = dragBBox.right - dragBBox.left,
h = dragBBox.bottom - dragBBox.top,
x = evt.clientX - dragBBox.left,
y = evt.clientY - dragBBox.top,
cursor = (w > MINWIDTH && h > MINHEIGHT && !evt.shiftKey) ?
dragElement.getCursor(x / w, 1 - y / h) :
'move';

setCursor(shapePath, cursor);

// possible values 'move', 'sw', 'w', 'se', 'e', 'ne', 'n', 'nw' and 'w'
dragMode = cursor.split('-')[0];
function obtainSensoryElement() {
return isLine ? createLineDragHandles() : shapePath;
}

function startDrag(evt) {
// setup conversion functions
xa = Axes.getFromId(gd, shapeOptions.xref);
ya = Axes.getFromId(gd, shapeOptions.yref);
function createLineDragHandles() {
var minSensoryWidth = 10,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might want to move this into https://github.com/plotly/plotly.js/blob/master/src/constants/interactions.js?

This behavior for annotations is even more ugly and hard-coded

.attr({
d: 'M3,3H-3V-3H3ZM0,0L' + (tailX - arrowDragHeadX) + ',' + (tailY - arrowDragHeadY),
transform: 'translate(' + arrowDragHeadX + ',' + arrowDragHeadY + ')'
})
.style('stroke-width', (strokewidth + 6) + 'px')

It wouldn't need to be changed now, but if we were to move this to a constants file and reuse it in annotations, I like the Math.max logic you have here better than the width + 6 currently in annotations (though the little-bit-extra around the head should be preserved).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per @etpinard 's comment below, feel free to defer all of ^^ to #679

sensoryWidth = Math.max(shapeOptions.line.width, minSensoryWidth);

// Helper shapes group
// Note that by setting the `data-index` attr, it is ensured that
// the helper group is purged in this modules `draw` function
var g = shapeLayer.append('g')
.attr('data-index', index);

// Helper path for moving
g.append('path')
.attr('d', shapePath.attr('d'))
.style({
'cursor': 'move',
'stroke-width': sensoryWidth,
'stroke-opacity': '0' // ensure not visible
});

// Helper circles for resizing
var circleStyle = {
'fill-opacity': '0' // ensure not visible
};
var circleRadius = sensoryWidth / 2 > minSensoryWidth ? sensoryWidth / 2 : minSensoryWidth;

g.append('circle')
.attr({
'data-line-point': 'start-point',
'cx': xPixelSized ? x2p(shapeOptions.xanchor) + shapeOptions.x0 : x2p(shapeOptions.x0),
'cy': yPixelSized ? y2p(shapeOptions.yanchor) - shapeOptions.y0 : y2p(shapeOptions.y0),
'r': circleRadius
})
.style(circleStyle)
.classed('cursor-grab', true);

g.append('circle')
.attr({
'data-line-point': 'end-point',
'cx': xPixelSized ? x2p(shapeOptions.xanchor) + shapeOptions.x1 : x2p(shapeOptions.x1),
'cy': yPixelSized ? y2p(shapeOptions.yanchor) - shapeOptions.y1 : y2p(shapeOptions.y1),
'r': circleRadius
})
.style(circleStyle)
.classed('cursor-grab', true);

return g;
}

x2p = helpers.getDataToPixel(gd, xa);
y2p = helpers.getDataToPixel(gd, ya, true);
p2x = helpers.getPixelToData(gd, xa);
p2y = helpers.getPixelToData(gd, ya, true);
function updateDragMode(evt) {
if(isLine) {
if(evt.target.tagName === 'path') {
dragMode = 'move';
} else {
dragMode = evt.target.attributes['data-line-point'].value === 'start-point' ?
'resize-over-start-point' : 'resize-over-end-point';
}
} else {
// element might not be on screen at time of setup,
// so obtain bounding box here
var dragBBox = dragOptions.element.getBoundingClientRect();

// choose 'move' or 'resize'
// based on initial position of cursor within the drag element
var w = dragBBox.right - dragBBox.left,
h = dragBBox.bottom - dragBBox.top,
x = evt.clientX - dragBBox.left,
y = evt.clientY - dragBBox.top,
cursor = (!isPath && w > MINWIDTH && h > MINHEIGHT && !evt.shiftKey) ?
dragElement.getCursor(x / w, 1 - y / h) :
'move';

setCursor(shapePath, cursor);

// possible values 'move', 'sw', 'w', 'se', 'e', 'ne', 'n', 'nw' and 'w'
dragMode = cursor.split('-')[0];
}
}

function startDrag(evt) {
// setup update strings and initial values
var astr = 'shapes[' + index + ']';

Expand Down Expand Up @@ -231,14 +297,24 @@ function setupDragElement(gd, shapePath, shapeOptions, index) {

// setup dragMode and the corresponding handler
updateDragMode(evt);
renderVisualCues(shapeLayer, shapeOptions);
deactivateClipPathTemporarily(shapePath, shapeOptions, gd);
dragOptions.moveFn = (dragMode === 'move') ? moveShape : resizeShape;
}

function endDrag() {
setCursor(shapePath);
removeVisualCues(shapeLayer);

// Don't rely on clipPath being activated during re-layout
setClipPath(shapePath, gd, shapeOptions);
Registry.call('relayout', gd, update);
}

function abortDrag() {
removeVisualCues(shapeLayer);
}

function moveShape(dx, dy) {
if(shapeOptions.type === 'path') {
var noOp = function(coord) { return coord; },
Expand Down Expand Up @@ -279,11 +355,12 @@ function setupDragElement(gd, shapePath, shapeOptions, index) {
}

shapePath.attr('d', getPathString(gd, shapeOptions));
renderVisualCues(shapeLayer, shapeOptions);
}

function resizeShape(dx, dy) {
if(shapeOptions.type === 'path') {
// TODO: implement path resize
if(isPath) {
// TODO: implement path resize, don't forget to update dragMode code
var noOp = function(coord) { return coord; },
moveX = noOp,
moveY = noOp;
Expand All @@ -305,6 +382,19 @@ function setupDragElement(gd, shapePath, shapeOptions, index) {
shapeOptions.path = movePath(pathIn, moveX, moveY);
update[astrPath] = shapeOptions.path;
}
else if(isLine) {
if(dragMode === 'resize-over-start-point') {
var newX0 = x0 + dx;
var newY0 = yPixelSized ? y0 - dy : y0 + dy;
update[astrX0] = shapeOptions.x0 = xPixelSized ? newX0 : p2x(newX0);
update[astrY0] = shapeOptions.y0 = yPixelSized ? newY0 : p2y(newY0);
} else if(dragMode === 'resize-over-end-point') {
var newX1 = x1 + dx;
var newY1 = yPixelSized ? y1 - dy : y1 + dy;
update[astrX1] = shapeOptions.x1 = xPixelSized ? newX1 : p2x(newX1);
update[astrY1] = shapeOptions.y1 = yPixelSized ? newY1 : p2y(newY1);
}
}
else {
var newN = (~dragMode.indexOf('n')) ? n0 + dy : n0,
newS = (~dragMode.indexOf('s')) ? s0 + dy : s0,
Expand All @@ -330,6 +420,87 @@ function setupDragElement(gd, shapePath, shapeOptions, index) {
}

shapePath.attr('d', getPathString(gd, shapeOptions));
renderVisualCues(shapeLayer, shapeOptions);
}

function renderVisualCues(shapeLayer, shapeOptions) {
if(xPixelSized || yPixelSized) {
renderAnchor();
}

function renderAnchor() {
var isNotPath = shapeOptions.type !== 'path';

// d3 join with dummy data to satisfy d3 data-binding
var visualCues = shapeLayer.selectAll('.visual-cue').data([0]);

// Enter
var strokeWidth = 1;
visualCues.enter()
.append('path')
.attr({
'fill': '#fff',
'fill-rule': 'evenodd',
'stroke': '#000',
'stroke-width': strokeWidth
})
.classed('visual-cue', true);

// Update
var posX = x2p(
xPixelSized ?
shapeOptions.xanchor :
Lib.midRange(
isNotPath ?
[shapeOptions.x0, shapeOptions.x1] :
helpers.extractPathCoords(shapeOptions.path, constants.paramIsX))
);
var posY = y2p(
yPixelSized ?
shapeOptions.yanchor :
Lib.midRange(
isNotPath ?
[shapeOptions.y0, shapeOptions.y1] :
helpers.extractPathCoords(shapeOptions.path, constants.paramIsY))
);

posX = helpers.roundPositionForSharpStrokeRendering(posX, strokeWidth);
posY = helpers.roundPositionForSharpStrokeRendering(posY, strokeWidth);

if(xPixelSized && yPixelSized) {
var crossPath = 'M' + (posX - 1 - strokeWidth) + ',' + (posY - 1 - strokeWidth) +
'h-8v2h8 v8h2v-8 h8v-2h-8 v-8h-2 Z';
visualCues.attr('d', crossPath);
} else if(xPixelSized) {
var vBarPath = 'M' + (posX - 1 - strokeWidth) + ',' + (posY - 9 - strokeWidth) +
'v18 h2 v-18 Z';
visualCues.attr('d', vBarPath);
} else {
var hBarPath = 'M' + (posX - 9 - strokeWidth) + ',' + (posY - 1 - strokeWidth) +
'h18 v2 h-18 Z';
visualCues.attr('d', hBarPath);
}
}
}

function removeVisualCues(shapeLayer) {
shapeLayer.selectAll('.visual-cue').remove();
}

function deactivateClipPathTemporarily(shapePath, shapeOptions, gd) {
var xref = shapeOptions.xref,
yref = shapeOptions.yref,
xa = Axes.getFromId(gd, xref),
ya = Axes.getFromId(gd, yref);

var clipAxes = '';
if(xref !== 'paper' && !xa.autorange) clipAxes += xref;
if(yref !== 'paper' && !ya.autorange) clipAxes += yref;

shapePath.call(Drawing.setClipUrl, clipAxes ?
'clip' + gd._fullLayout._uid + clipAxes :
null
);
}
}

Expand Down
Loading