From 27bb36d6f894f86d2c6c1907cbef848ff7b69405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 5 Aug 2016 18:14:38 -0400 Subject: [PATCH 1/5] cleanup: break up annotations into files - rename Annotations.drawAll -> Annotations.draw - make old Annotatios.draw a private method in annotations/draw.js --- .../annotations/annotation_defaults.js | 103 ++ src/components/annotations/calc_autorange.js | 85 ++ src/components/annotations/defaults.js | 25 + src/components/annotations/draw.js | 753 +++++++++++++ src/components/annotations/draw_arrow_head.js | 117 +++ src/components/annotations/index.js | 994 +----------------- src/plot_api/plot_api.js | 9 +- src/plots/plots.js | 2 +- 8 files changed, 1099 insertions(+), 989 deletions(-) create mode 100644 src/components/annotations/annotation_defaults.js create mode 100644 src/components/annotations/calc_autorange.js create mode 100644 src/components/annotations/defaults.js create mode 100644 src/components/annotations/draw.js create mode 100644 src/components/annotations/draw_arrow_head.js diff --git a/src/components/annotations/annotation_defaults.js b/src/components/annotations/annotation_defaults.js new file mode 100644 index 00000000000..36acea5d457 --- /dev/null +++ b/src/components/annotations/annotation_defaults.js @@ -0,0 +1,103 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Lib = require('../../lib'); +var Color = require('../color'); +var Axes = require('../../plots/cartesian/axes'); + +var attributes = require('./attributes'); + + +module.exports = function handleAnnotationDefaults(annIn, fullLayout) { + var annOut = {}; + + function coerce(attr, dflt) { + return Lib.coerce(annIn, annOut, attributes, attr, dflt); + } + + coerce('opacity'); + coerce('align'); + coerce('bgcolor'); + + var borderColor = coerce('bordercolor'), + borderOpacity = Color.opacity(borderColor); + + coerce('borderpad'); + + var borderWidth = coerce('borderwidth'); + var showArrow = coerce('showarrow'); + + if(showArrow) { + coerce('arrowcolor', borderOpacity ? annOut.bordercolor : Color.defaultLine); + coerce('arrowhead'); + coerce('arrowsize'); + coerce('arrowwidth', ((borderOpacity && borderWidth) || 1) * 2); + coerce('ax'); + coerce('ay'); + coerce('axref'); + coerce('ayref'); + + // if you have one part of arrow length you should have both + Lib.noneOrAll(annIn, annOut, ['ax', 'ay']); + } + + coerce('text', showArrow ? ' ' : 'new text'); + coerce('textangle'); + Lib.coerceFont(coerce, 'font', fullLayout.font); + + // positioning + var axLetters = ['x', 'y']; + for(var i = 0; i < 2; i++) { + var axLetter = axLetters[i], + tdMock = {_fullLayout: fullLayout}; + + // xref, yref + var axRef = Axes.coerceRef(annIn, annOut, tdMock, axLetter); + + // TODO: should be refactored in conjunction with Axes axref, ayref + var aaxRef = Axes.coerceARef(annIn, annOut, tdMock, axLetter); + + // x, y + var defaultPosition = 0.5; + if(axRef !== 'paper') { + var ax = Axes.getFromId(tdMock, axRef); + defaultPosition = ax.range[0] + defaultPosition * (ax.range[1] - ax.range[0]); + + // convert date or category strings to numbers + if(['date', 'category'].indexOf(ax.type) !== -1 && + typeof annIn[axLetter] === 'string') { + var newval; + if(ax.type === 'date') { + newval = Lib.dateTime2ms(annIn[axLetter]); + if(newval !== false) annIn[axLetter] = newval; + + if(aaxRef === axRef) { + var newvalB = Lib.dateTime2ms(annIn['a' + axLetter]); + if(newvalB !== false) annIn['a' + axLetter] = newvalB; + } + } + else if((ax._categories || []).length) { + newval = ax._categories.indexOf(annIn[axLetter]); + if(newval !== -1) annIn[axLetter] = newval; + } + } + } + coerce(axLetter, defaultPosition); + + // xanchor, yanchor + if(!showArrow) coerce(axLetter + 'anchor'); + } + + // if you have one coordinate you should have both + Lib.noneOrAll(annIn, annOut, ['x', 'y']); + + return annOut; +}; diff --git a/src/components/annotations/calc_autorange.js b/src/components/annotations/calc_autorange.js new file mode 100644 index 00000000000..ba2261f3f4c --- /dev/null +++ b/src/components/annotations/calc_autorange.js @@ -0,0 +1,85 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Lib = require('../../lib'); +var Axes = require('../../plots/cartesian/axes'); + +var draw = require('./draw').draw; + + +module.exports = function calcAutorange(gd) { + var fullLayout = gd._fullLayout, + annotationList = fullLayout.annotations; + + if(!annotationList.length || !gd._fullData.length) return; + + var annotationAxes = {}; + annotationList.forEach(function(ann) { + annotationAxes[ann.xref] = true; + annotationAxes[ann.yref] = true; + }); + + var autorangedAnnos = Axes.list(gd).filter(function(ax) { + return ax.autorange && annotationAxes[ax._id]; + }); + if(!autorangedAnnos.length) return; + + return Lib.syncOrAsync([ + draw, + annAutorange + ], gd); +}; + +function annAutorange(gd) { + var fullLayout = gd._fullLayout; + + // find the bounding boxes for each of these annotations' + // relative to their anchor points + // use the arrow and the text bg rectangle, + // as the whole anno may include hidden text in its bbox + fullLayout.annotations.forEach(function(ann) { + var xa = Axes.getFromId(gd, ann.xref), + ya = Axes.getFromId(gd, ann.yref); + + if(!(xa || ya)) return; + + var halfWidth = (ann._xsize || 0) / 2, + xShift = ann._xshift || 0, + halfHeight = (ann._ysize || 0) / 2, + yShift = ann._yshift || 0, + leftSize = halfWidth - xShift, + rightSize = halfWidth + xShift, + topSize = halfHeight - yShift, + bottomSize = halfHeight + yShift; + + if(ann.showarrow) { + var headSize = 3 * ann.arrowsize * ann.arrowwidth; + leftSize = Math.max(leftSize, headSize); + rightSize = Math.max(rightSize, headSize); + topSize = Math.max(topSize, headSize); + bottomSize = Math.max(bottomSize, headSize); + } + + if(xa && xa.autorange) { + Axes.expand(xa, [xa.l2c(ann.x)], { + ppadplus: rightSize, + ppadminus: leftSize + }); + } + + if(ya && ya.autorange) { + Axes.expand(ya, [ya.l2c(ann.y)], { + ppadplus: bottomSize, + ppadminus: topSize + }); + } + }); +} diff --git a/src/components/annotations/defaults.js b/src/components/annotations/defaults.js new file mode 100644 index 00000000000..a8d56cd1d45 --- /dev/null +++ b/src/components/annotations/defaults.js @@ -0,0 +1,25 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var handleAnnotationDefaults = require('./annotation_defaults'); + + +module.exports = function supplyLayoutDefaults(layoutIn, layoutOut) { + var containerIn = layoutIn.annotations || [], + containerOut = layoutOut.annotations = []; + + for(var i = 0; i < containerIn.length; i++) { + var annIn = containerIn[i] || {}, + annOut = handleAnnotationDefaults(annIn, layoutOut); + + containerOut.push(annOut); + } +}; diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js new file mode 100644 index 00000000000..665759756f6 --- /dev/null +++ b/src/components/annotations/draw.js @@ -0,0 +1,753 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var d3 = require('d3'); +var isNumeric = require('fast-isnumeric'); + +var Plotly = require('../../plotly'); +var Plots = require('../../plots/plots'); +var Lib = require('../../lib'); +var Axes = require('../../plots/cartesian/axes'); +var Color = require('../color'); +var Drawing = require('../drawing'); +var svgTextUtils = require('../../lib/svg_text_utils'); +var setCursor = require('../../lib/setcursor'); +var dragElement = require('../dragelement'); + +var handleAnnotationDefaults = require('./annotation_defaults'); +var supplyLayoutDefaults = require('./defaults'); +var arrowhead = require('./draw_arrow_head'); + + +// Annotations are stored in gd.layout.annotations, an array of objects +// index can point to one item in this array, +// or non-numeric to simply add a new one +// or -1 to modify all existing +// opt can be the full options object, or one key (to be set to value) +// or undefined to simply redraw +// if opt is blank, val can be 'add' or a full options object to add a new +// annotation at that point in the array, or 'remove' to delete this one + +module.exports = { + draw: draw, + drawOne: drawOne +}; + +function draw(gd) { + var fullLayout = gd._fullLayout; + + fullLayout._infolayer.selectAll('.annotation').remove(); + + for(var i = 0; i < fullLayout.annotations.length; i++) { + drawOne(gd, i); + } + + return Plots.previousPromises(gd); +} + +function drawOne(gd, index, opt, value) { + var layout = gd.layout, + fullLayout = gd._fullLayout, + i; + + if(!isNumeric(index) || index === -1) { + + // no index provided - we're operating on ALL annotations + if(!index && Array.isArray(value)) { + // a whole annotation array is passed in + // (as in, redo of delete all) + layout.annotations = value; + supplyLayoutDefaults(layout, fullLayout); + draw(gd); + return; + } + else if(value === 'remove') { + // delete all + delete layout.annotations; + fullLayout.annotations = []; + draw(gd); + return; + } + else if(opt && value !== 'add') { + // make the same change to all annotations + for(i = 0; i < fullLayout.annotations.length; i++) { + drawOne(gd, i, opt, value); + } + return; + } + else { + // add a new empty annotation + index = fullLayout.annotations.length; + fullLayout.annotations.push({}); + } + } + + if(!opt && value) { + if(value === 'remove') { + fullLayout._infolayer.selectAll('.annotation[data-index="' + index + '"]') + .remove(); + fullLayout.annotations.splice(index, 1); + layout.annotations.splice(index, 1); + for(i = index; i < fullLayout.annotations.length; i++) { + fullLayout._infolayer + .selectAll('.annotation[data-index="' + (i + 1) + '"]') + .attr('data-index', String(i)); + + // redraw all annotations past the removed one, + // so they bind to the right events + drawOne(gd, i); + } + return; + } + else if(value === 'add' || Lib.isPlainObject(value)) { + fullLayout.annotations.splice(index, 0, {}); + + var rule = Lib.isPlainObject(value) ? + Lib.extendFlat({}, value) : + {text: 'New text'}; + + if(layout.annotations) { + layout.annotations.splice(index, 0, rule); + } else { + layout.annotations = [rule]; + } + + for(i = fullLayout.annotations.length - 1; i > index; i--) { + fullLayout._infolayer + .selectAll('.annotation[data-index="' + (i - 1) + '"]') + .attr('data-index', String(i)); + drawOne(gd, i); + } + } + } + + // remove the existing annotation if there is one + fullLayout._infolayer.selectAll('.annotation[data-index="' + index + '"]').remove(); + + // remember a few things about what was already there, + var optionsIn = layout.annotations[index], + oldPrivate = fullLayout.annotations[index]; + + // not sure how we're getting here... but C12 is seeing a bug + // where we fail here when they add/remove annotations + if(!optionsIn) return; + + var oldRef = {xref: optionsIn.xref, yref: optionsIn.yref}; + + // alter the input annotation as requested + var optionsEdit = {}; + if(typeof opt === 'string' && opt) optionsEdit[opt] = value; + else if(Lib.isPlainObject(opt)) optionsEdit = opt; + + var optionKeys = Object.keys(optionsEdit); + for(i = 0; i < optionKeys.length; i++) { + var k = optionKeys[i]; + Lib.nestedProperty(optionsIn, k).set(optionsEdit[k]); + } + + var gs = fullLayout._size; + + var axLetters = ['x', 'y']; + for(i = 0; i < 2; i++) { + var axLetter = axLetters[i]; + // if we don't have an explicit position already, + // don't set one just because we're changing references + // or axis type. + // the defaults will be consistent most of the time anyway, + // except in log/linear changes + if(optionsEdit[axLetter] !== undefined || + optionsIn[axLetter] === undefined) { + continue; + } + + var axOld = Axes.getFromId(gd, Axes.coerceRef(oldRef, {}, gd, axLetter)), + axNew = Axes.getFromId(gd, Axes.coerceRef(optionsIn, {}, gd, axLetter)), + position = optionsIn[axLetter], + axTypeOld = oldPrivate['_' + axLetter + 'type']; + + if(optionsEdit[axLetter + 'ref'] !== undefined) { + var autoAnchor = optionsIn[axLetter + 'anchor'] === 'auto', + plotSize = (axLetter === 'x' ? gs.w : gs.h), + halfSizeFrac = (oldPrivate['_' + axLetter + 'size'] || 0) / + (2 * plotSize); + if(axOld && axNew) { // data -> different data + // go to the same fraction of the axis length + // whether or not these axes share a domain + + // first convert to fraction of the axis + position = (position - axOld.range[0]) / + (axOld.range[1] - axOld.range[0]); + + // then convert to new data coordinates at the same fraction + position = axNew.range[0] + + position * (axNew.range[1] - axNew.range[0]); + } + else if(axOld) { // data -> paper + // first convert to fraction of the axis + position = (position - axOld.range[0]) / + (axOld.range[1] - axOld.range[0]); + + // next scale the axis to the whole plot + position = axOld.domain[0] + + position * (axOld.domain[1] - axOld.domain[0]); + + // finally see if we need to adjust auto alignment + // because auto always means middle / center alignment for data, + // but it changes for page alignment based on the closest side + if(autoAnchor) { + var posPlus = position + halfSizeFrac, + posMinus = position - halfSizeFrac; + if(position + posMinus < 2 / 3) position = posMinus; + else if(position + posPlus > 4 / 3) position = posPlus; + } + } + else if(axNew) { // paper -> data + // first see if we need to adjust auto alignment + if(autoAnchor) { + if(position < 1 / 3) position += halfSizeFrac; + else if(position > 2 / 3) position -= halfSizeFrac; + } + + // next convert to fraction of the axis + position = (position - axNew.domain[0]) / + (axNew.domain[1] - axNew.domain[0]); + + // finally convert to data coordinates + position = axNew.range[0] + + position * (axNew.range[1] - axNew.range[0]); + } + } + + if(axNew && axNew === axOld && axTypeOld) { + if(axTypeOld === 'log' && axNew.type !== 'log') { + position = Math.pow(10, position); + } + else if(axTypeOld !== 'log' && axNew.type === 'log') { + position = (position > 0) ? + Math.log(position) / Math.LN10 : undefined; + } + } + + optionsIn[axLetter] = position; + } + + var options = handleAnnotationDefaults(optionsIn, fullLayout); + fullLayout.annotations[index] = options; + + var xa = Axes.getFromId(gd, options.xref), + ya = Axes.getFromId(gd, options.yref), + annPosPx = {x: 0, y: 0}, + textangle = +options.textangle || 0; + + // create the components + // made a single group to contain all, so opacity can work right + // with border/arrow together this could handle a whole bunch of + // cleanup at this point, but works for now + var anngroup = fullLayout._infolayer.append('g') + .classed('annotation', true) + .attr('data-index', String(index)) + .style('opacity', options.opacity) + .on('click', function() { + gd._dragging = false; + gd.emit('plotly_clickannotation', { + index: index, + annotation: optionsIn, + fullAnnotation: options + }); + }); + + // another group for text+background so that they can rotate together + var anng = anngroup.append('g') + .classed('annotation-text-g', true) + .attr('data-index', String(index)); + + var ann = anng.append('g'); + + var borderwidth = options.borderwidth, + borderpad = options.borderpad, + borderfull = borderwidth + borderpad; + + var annbg = ann.append('rect') + .attr('class', 'bg') + .style('stroke-width', borderwidth + 'px') + .call(Color.stroke, options.bordercolor) + .call(Color.fill, options.bgcolor); + + var font = options.font; + + var anntext = ann.append('text') + .classed('annotation', true) + .attr('data-unformatted', options.text) + .text(options.text); + + function textLayout(s) { + s.call(Drawing.font, font) + .attr({ + 'text-anchor': { + left: 'start', + right: 'end' + }[options.align] || 'middle' + }); + + svgTextUtils.convertToTspans(s, drawGraphicalElements); + return s; + } + + function drawGraphicalElements() { + + // make sure lines are aligned the way they will be + // at the end, even if their position changes + anntext.selectAll('tspan.line').attr({y: 0, x: 0}); + + var mathjaxGroup = ann.select('.annotation-math-group'), + hasMathjax = !mathjaxGroup.empty(), + anntextBB = Drawing.bBox( + (hasMathjax ? mathjaxGroup : anntext).node()), + annwidth = anntextBB.width, + annheight = anntextBB.height, + outerwidth = Math.round(annwidth + 2 * borderfull), + outerheight = Math.round(annheight + 2 * borderfull); + + + // save size in the annotation object for use by autoscale + options._w = annwidth; + options._h = annheight; + + function shiftFraction(v, anchor) { + if(anchor === 'auto') { + if(v < 1 / 3) anchor = 'left'; + else if(v > 2 / 3) anchor = 'right'; + else anchor = 'center'; + } + return { + center: 0, + middle: 0, + left: 0.5, + bottom: -0.5, + right: -0.5, + top: 0.5 + }[anchor]; + } + + var annotationIsOffscreen = false; + ['x', 'y'].forEach(function(axLetter) { + var axRef = options[axLetter + 'ref'] || axLetter, + ax = Axes.getFromId(gd, axRef), + dimAngle = (textangle + (axLetter === 'x' ? 0 : 90)) * Math.PI / 180, + annSize = outerwidth * Math.abs(Math.cos(dimAngle)) + + outerheight * Math.abs(Math.sin(dimAngle)), + anchor = options[axLetter + 'anchor'], + alignPosition; + + // calculate pixel position + if(ax) { + // hide the annotation if it's pointing + // outside the visible plot (as long as the axis + // isn't autoranged - then we need to draw it + // anyway to get its bounding box) + if(!ax.autorange && ((options[axLetter] - ax.range[0]) * + (options[axLetter] - ax.range[1]) > 0)) { + if(options['a' + axLetter + 'ref'] === axRef) { + if((options['a' + axLetter] - ax.range[0]) * + (options['a' + axLetter] - ax.range[1]) > 0) { + annotationIsOffscreen = true; + } + } else { + annotationIsOffscreen = true; + } + + if(annotationIsOffscreen) return; + } + annPosPx[axLetter] = ax._offset + ax.l2p(options[axLetter]); + alignPosition = 0.5; + } + else { + alignPosition = options[axLetter]; + if(axLetter === 'y') alignPosition = 1 - alignPosition; + annPosPx[axLetter] = (axLetter === 'x') ? + (gs.l + gs.w * alignPosition) : + (gs.t + gs.h * alignPosition); + } + + var alignShift = 0; + if(options['a' + axLetter + 'ref'] === axRef) { + annPosPx['aa' + axLetter] = ax._offset + ax.l2p(options['a' + axLetter]); + } else { + if(options.showarrow) { + alignShift = options['a' + axLetter]; + } + else { + alignShift = annSize * shiftFraction(alignPosition, anchor); + } + annPosPx[axLetter] += alignShift; + } + + // save the current axis type for later log/linear changes + options['_' + axLetter + 'type'] = ax && ax.type; + + // save the size and shift in this dim for autorange + options['_' + axLetter + 'size'] = annSize; + options['_' + axLetter + 'shift'] = alignShift; + }); + + if(annotationIsOffscreen) { + ann.remove(); + return; + } + + var arrowX, arrowY; + + // make sure the arrowhead (if there is one) + // and the annotation center are visible + if(options.showarrow) { + if(options.axref === options.xref) { + //we don't want to constrain if the tail is absolute + //or the slope (which is meaningful) will change. + arrowX = annPosPx.x; + } else { + arrowX = Lib.constrain(annPosPx.x - options.ax, 1, fullLayout.width - 1); + } + + if(options.ayref === options.yref) { + //we don't want to constrain if the tail is absolute + //or the slope (which is meaningful) will change. + arrowY = annPosPx.y; + } else { + arrowY = Lib.constrain(annPosPx.y - options.ay, 1, fullLayout.height - 1); + } + } + annPosPx.x = Lib.constrain(annPosPx.x, 1, fullLayout.width - 1); + annPosPx.y = Lib.constrain(annPosPx.y, 1, fullLayout.height - 1); + + var texty = borderfull - anntextBB.top, + textx = borderfull - anntextBB.left; + + if(hasMathjax) { + mathjaxGroup.select('svg').attr({x: borderfull - 1, y: borderfull}); + } + else { + anntext.attr({x: textx, y: texty}); + anntext.selectAll('tspan.line').attr({y: texty, x: textx}); + } + + annbg.call(Drawing.setRect, borderwidth / 2, borderwidth / 2, + outerwidth - borderwidth, outerheight - borderwidth); + + var annX = 0, annY = 0; + if(options.axref === options.xref) { + annX = Math.round(annPosPx.aax - outerwidth / 2); + } else { + annX = Math.round(annPosPx.x - outerwidth / 2); + } + + if(options.ayref === options.yref) { + annY = Math.round(annPosPx.aay - outerheight / 2); + } else { + annY = Math.round(annPosPx.y - outerheight / 2); + } + + ann.call(Lib.setTranslate, annX, annY); + + var annbase = 'annotations[' + index + ']'; + + // add the arrow + // uses options[arrowwidth,arrowcolor,arrowhead] for styling + var drawArrow = function(dx, dy) { + d3.select(gd) + .selectAll('.annotation-arrow-g[data-index="' + index + '"]') + .remove(); + // find where to start the arrow: + // at the border of the textbox, if that border is visible, + // or at the edge of the lines of text, if the border is hidden + // TODO: tspan bounding box fails in chrome + // looks like there may be a cross-browser solution, see + // http://stackoverflow.com/questions/5364980/ + // how-to-get-the-width-of-an-svg-tspan-element + var arrowX0, arrowY0; + + if(options.axref === options.xref) { + arrowX0 = annPosPx.aax + dx; + } else { + arrowX0 = annPosPx.x + dx; + } + + if(options.ayref === options.yref) { + arrowY0 = annPosPx.aay + dy; + } else { + arrowY0 = annPosPx.y + dy; + } + + // create transform matrix and related functions + var transform = + Lib.rotationXYMatrix(textangle, arrowX0, arrowY0), + applyTransform = Lib.apply2DTransform(transform), + applyTransform2 = Lib.apply2DTransform2(transform), + + // calculate and transform bounding box + xHalf = annbg.attr('width') / 2, + yHalf = annbg.attr('height') / 2, + edges = [ + [arrowX0 - xHalf, arrowY0 - yHalf, arrowX0 - xHalf, arrowY0 + yHalf], + [arrowX0 - xHalf, arrowY0 + yHalf, arrowX0 + xHalf, arrowY0 + yHalf], + [arrowX0 + xHalf, arrowY0 + yHalf, arrowX0 + xHalf, arrowY0 - yHalf], + [arrowX0 + xHalf, arrowY0 - yHalf, arrowX0 - xHalf, arrowY0 - yHalf] + ].map(applyTransform2); + + // Remove the line if it ends inside the box. Use ray + // casting for rotated boxes: see which edges intersect a + // line from the arrowhead to far away and reduce with xor + // to get the parity of the number of intersections. + if(edges.reduce(function(a, x) { + return a ^ + !!lineIntersect(arrowX, arrowY, arrowX + 1e6, arrowY + 1e6, + x[0], x[1], x[2], x[3]); + }, false)) { + // no line or arrow - so quit drawArrow now + return; + } + + edges.forEach(function(x) { + var p = lineIntersect(arrowX0, arrowY0, arrowX, arrowY, + x[0], x[1], x[2], x[3]); + if(p) { + arrowX0 = p.x; + arrowY0 = p.y; + } + }); + + var strokewidth = options.arrowwidth, + arrowColor = options.arrowcolor; + + var arrowgroup = anngroup.append('g') + .style({opacity: Color.opacity(arrowColor)}) + .classed('annotation-arrow-g', true) + .attr('data-index', String(index)); + + var arrow = arrowgroup.append('path') + .attr('d', 'M' + arrowX0 + ',' + arrowY0 + 'L' + arrowX + ',' + arrowY) + .style('stroke-width', strokewidth + 'px') + .call(Color.stroke, Color.rgb(arrowColor)); + + arrowhead(arrow, options.arrowhead, 'end', options.arrowsize); + + var arrowdrag = arrowgroup.append('path') + .classed('annotation', true) + .classed('anndrag', true) + .attr({ + 'data-index': String(index), + d: 'M3,3H-3V-3H3ZM0,0L' + (arrowX0 - arrowX) + ',' + (arrowY0 - arrowY), + transform: 'translate(' + arrowX + ',' + arrowY + ')' + }) + .style('stroke-width', (strokewidth + 6) + 'px') + .call(Color.stroke, 'rgba(0,0,0,0)') + .call(Color.fill, 'rgba(0,0,0,0)'); + + if(gd._context.editable) { + var update, + annx0, + anny0; + + dragElement.init({ + element: arrowdrag.node(), + prepFn: function() { + var pos = Lib.getTranslate(ann); + + annx0 = pos.x; + anny0 = pos.y; + update = {}; + if(xa && xa.autorange) { + update[xa._name + '.autorange'] = true; + } + if(ya && ya.autorange) { + update[ya._name + '.autorange'] = true; + } + }, + moveFn: function(dx, dy) { + arrowgroup.attr('transform', 'translate(' + dx + ',' + dy + ')'); + + var annxy0 = applyTransform(annx0, anny0), + xcenter = annxy0[0] + dx, + ycenter = annxy0[1] + dy; + ann.call(Lib.setTranslate, xcenter, ycenter); + + update[annbase + '.x'] = xa ? + (options.x + dx / xa._m) : + ((arrowX + dx - gs.l) / gs.w); + update[annbase + '.y'] = ya ? + (options.y + dy / ya._m) : + (1 - ((arrowY + dy - gs.t) / gs.h)); + + if(options.axref === options.xref) { + update[annbase + '.ax'] = xa ? + (options.ax + dx / xa._m) : + ((arrowX + dx - gs.l) / gs.w); + } + + if(options.ayref === options.yref) { + update[annbase + '.ay'] = ya ? + (options.ay + dy / ya._m) : + (1 - ((arrowY + dy - gs.t) / gs.h)); + } + + anng.attr({ + transform: 'rotate(' + textangle + ',' + + xcenter + ',' + ycenter + ')' + }); + }, + doneFn: function(dragged) { + if(dragged) { + Plotly.relayout(gd, update); + var notesBox = document.querySelector('.js-notes-box-panel'); + if(notesBox) notesBox.redraw(notesBox.selectedObj); + } + } + }); + } + }; + + if(options.showarrow) drawArrow(0, 0); + + // create transform matrix and related functions + var transform = Lib.rotationXYMatrix(textangle, + annPosPx.x, annPosPx.y), + applyTransform = Lib.apply2DTransform(transform); + + // user dragging the annotation (text, not arrow) + if(gd._context.editable) { + var x0, + y0, + update; + + dragElement.init({ + element: ann.node(), + prepFn: function() { + var pos = Lib.getTranslate(ann); + + x0 = pos.x; + y0 = pos.y; + update = {}; + }, + moveFn: function(dx, dy) { + ann.call(Lib.setTranslate, x0 + dx, y0 + dy); + var csr = 'pointer'; + if(options.showarrow) { + if(options.axref === options.xref) { + update[annbase + '.ax'] = xa.p2l(xa.l2p(options.ax) + dx); + } else { + update[annbase + '.ax'] = options.ax + dx; + } + + if(options.ayref === options.yref) { + update[annbase + '.ay'] = ya.p2l(ya.l2p(options.ay) + dy); + } else { + update[annbase + '.ay'] = options.ay + dy; + } + + drawArrow(dx, dy); + } + else { + if(xa) update[annbase + '.x'] = options.x + dx / xa._m; + else { + var widthFraction = options._xsize / gs.w, + xLeft = options.x + options._xshift / gs.w - widthFraction / 2; + + update[annbase + '.x'] = dragElement.align(xLeft + dx / gs.w, + widthFraction, 0, 1, options.xanchor); + } + + if(ya) update[annbase + '.y'] = options.y + dy / ya._m; + else { + var heightFraction = options._ysize / gs.h, + yBottom = options.y - options._yshift / gs.h - heightFraction / 2; + + update[annbase + '.y'] = dragElement.align(yBottom - dy / gs.h, + heightFraction, 0, 1, options.yanchor); + } + if(!xa || !ya) { + csr = dragElement.getCursor( + xa ? 0.5 : update[annbase + '.x'], + ya ? 0.5 : update[annbase + '.y'], + options.xanchor, options.yanchor + ); + } + } + + var xy1 = applyTransform(x0, y0), + x1 = xy1[0] + dx, + y1 = xy1[1] + dy; + + ann.call(Lib.setTranslate, x0 + dx, y0 + dy); + + anng.attr({ + transform: 'rotate(' + textangle + ',' + + x1 + ',' + y1 + ')' + }); + + setCursor(ann, csr); + }, + doneFn: function(dragged) { + setCursor(ann); + if(dragged) { + Plotly.relayout(gd, update); + var notesBox = document.querySelector('.js-notes-box-panel'); + if(notesBox) notesBox.redraw(notesBox.selectedObj); + } + } + }); + } + } + + if(gd._context.editable) { + anntext.call(svgTextUtils.makeEditable, ann) + .call(textLayout) + .on('edit', function(_text) { + options.text = _text; + this.attr({'data-unformatted': options.text}); + this.call(textLayout); + var update = {}; + update['annotations[' + index + '].text'] = options.text; + if(xa && xa.autorange) { + update[xa._name + '.autorange'] = true; + } + if(ya && ya.autorange) { + update[ya._name + '.autorange'] = true; + } + Plotly.relayout(gd, update); + }); + } + else anntext.call(textLayout); + + // rotate and position text and background + anng.attr({transform: 'rotate(' + textangle + ',' + + annPosPx.x + ',' + annPosPx.y + ')'}) + .call(Drawing.setPosition, annPosPx.x, annPosPx.y); +} + +// look for intersection of two line segments +// (1->2 and 3->4) - returns array [x,y] if they do, null if not +function lineIntersect(x1, y1, x2, y2, x3, y3, x4, y4) { + var a = x2 - x1, + b = x3 - x1, + c = x4 - x3, + d = y2 - y1, + e = y3 - y1, + f = y4 - y3, + det = a * f - c * d; + // parallel lines? intersection is undefined + // ignore the case where they are colinear + if(det === 0) return null; + var t = (b * f - c * e) / det, + u = (b * d - a * e) / det; + // segments do not intersect? + if(u < 0 || u > 1 || t < 0 || t > 1) return null; + + return {x: x1 + a * t, y: y1 + d * t}; +} diff --git a/src/components/annotations/draw_arrow_head.js b/src/components/annotations/draw_arrow_head.js new file mode 100644 index 00000000000..3a42b712dbf --- /dev/null +++ b/src/components/annotations/draw_arrow_head.js @@ -0,0 +1,117 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var d3 = require('d3'); +var isNumeric = require('fast-isnumeric'); + +var Color = require('../color'); +var Drawing = require('../drawing'); + +var ARROWPATHS = require('./arrow_paths'); + +// add arrowhead(s) to a path or line d3 element el3 +// style: 1-6, first 5 are pointers, 6 is circle, 7 is square, 8 is none +// ends is 'start', 'end' (default), 'start+end' +// mag is magnification vs. default (default 1) + +module.exports = function drawArrowHead(el3, style, ends, mag) { + if(!isNumeric(mag)) mag = 1; + var el = el3.node(), + headStyle = ARROWPATHS[style||0]; + if(!headStyle) return; + + if(typeof ends !== 'string' || !ends) ends = 'end'; + + var scale = (Drawing.getPx(el3, 'stroke-width') || 1) * mag, + stroke = el3.style('stroke') || Color.defaultLine, + opacity = el3.style('stroke-opacity') || 1, + doStart = ends.indexOf('start') >= 0, + doEnd = ends.indexOf('end') >= 0, + backOff = headStyle.backoff * scale, + start, + end, + startRot, + endRot; + + if(el.nodeName === 'line') { + start = {x: +el3.attr('x1'), y: +el3.attr('y1')}; + end = {x: +el3.attr('x2'), y: +el3.attr('y2')}; + startRot = Math.atan2(start.y - end.y, start.x - end.x); + endRot = startRot + Math.PI; + if(backOff) { + var backOffX = backOff * Math.cos(startRot), + backOffY = backOff * Math.sin(startRot); + + if(doStart) { + start.x -= backOffX; + start.y -= backOffY; + el3.attr({x1: start.x, y1: start.y}); + } + if(doEnd) { + end.x += backOffX; + end.y += backOffY; + el3.attr({x2: end.x, y2: end.y}); + } + } + } + else if(el.nodeName === 'path') { + var pathlen = el.getTotalLength(), + // using dash to hide the backOff region of the path. + // if we ever allow dash for the arrow we'll have to + // do better than this hack... maybe just manually + // combine the two + dashArray = ''; + + if(doStart) { + var start0 = el.getPointAtLength(0), + dstart = el.getPointAtLength(0.1); + startRot = Math.atan2(start0.y - dstart.y, start0.x - dstart.x); + start = el.getPointAtLength(Math.min(backOff, pathlen)); + if(backOff) dashArray = '0px,' + backOff + 'px,'; + } + + if(doEnd) { + var end0 = el.getPointAtLength(pathlen), + dend = el.getPointAtLength(pathlen - 0.1); + endRot = Math.atan2(end0.y - dend.y, end0.x - dend.x); + end = el.getPointAtLength(Math.max(0, pathlen - backOff)); + + if(backOff) { + var shortening = dashArray ? 2 * backOff : backOff; + dashArray += (pathlen - shortening) + 'px,' + pathlen + 'px'; + } + } + else if(dashArray) dashArray += pathlen + 'px'; + + if(dashArray) el3.style('stroke-dasharray', dashArray); + } + + var drawhead = function(p, rot) { + if(style > 5) rot = 0; // don't rotate square or circle + d3.select(el.parentElement).append('path') + .attr({ + 'class': el3.attr('class'), + d: headStyle.path, + transform: + 'translate(' + p.x + ',' + p.y + ')' + + 'rotate(' + (rot * 180 / Math.PI) + ')' + + 'scale(' + scale + ')' + }) + .style({ + fill: stroke, + opacity: opacity, + 'stroke-width': 0 + }); + }; + + if(doStart) drawhead(start, startRot); + if(doEnd) drawhead(end, endRot); +}; diff --git a/src/components/annotations/index.js b/src/components/annotations/index.js index 3fc49527908..0cbba50192a 100644 --- a/src/components/annotations/index.js +++ b/src/components/annotations/index.js @@ -9,999 +9,25 @@ 'use strict'; -var d3 = require('d3'); -var isNumeric = require('fast-isnumeric'); - var Plotly = require('../../plotly'); -var Lib = require('../../lib'); -var Axes = require('../../plots/cartesian/axes'); -var Color = require('../color'); -var Drawing = require('../drawing'); -var svgTextUtils = require('../../lib/svg_text_utils'); -var setCursor = require('../../lib/setcursor'); -var dragElement = require('../dragelement'); - -var annotations = module.exports = {}; - -annotations.ARROWPATHS = require('./arrow_paths'); - -annotations.layoutAttributes = require('./attributes'); - -annotations.supplyLayoutDefaults = function(layoutIn, layoutOut) { - var containerIn = layoutIn.annotations || [], - containerOut = layoutOut.annotations = []; - - for(var i = 0; i < containerIn.length; i++) { - containerOut.push(handleAnnotationDefaults(containerIn[i] || {}, layoutOut)); - } -}; - -function handleAnnotationDefaults(annIn, fullLayout) { - var annOut = {}; - - function coerce(attr, dflt) { - return Lib.coerce(annIn, annOut, annotations.layoutAttributes, attr, dflt); - } - coerce('opacity'); - coerce('align'); - coerce('bgcolor'); - var borderColor = coerce('bordercolor'), - borderOpacity = Color.opacity(borderColor); - coerce('borderpad'); - var borderWidth = coerce('borderwidth'); - var showArrow = coerce('showarrow'); - if(showArrow) { - coerce('arrowcolor', - borderOpacity ? annOut.bordercolor : Color.defaultLine); - coerce('arrowhead'); - coerce('arrowsize'); - coerce('arrowwidth', ((borderOpacity && borderWidth) || 1) * 2); - coerce('ax'); - coerce('ay'); - coerce('axref'); - coerce('ayref'); - // if you have one part of arrow length you should have both - Lib.noneOrAll(annIn, annOut, ['ax', 'ay']); - } - coerce('text', showArrow ? ' ' : 'new text'); - coerce('textangle'); - Lib.coerceFont(coerce, 'font', fullLayout.font); +exports.ARROWPATHS = require('./arrow_paths'); - // positioning - var axLetters = ['x', 'y']; - for(var i = 0; i < 2; i++) { - var axLetter = axLetters[i], - tdMock = {_fullLayout: fullLayout}; +exports.layoutAttributes = require('./attributes'); - // xref, yref - var axRef = Axes.coerceRef(annIn, annOut, tdMock, axLetter); +exports.supplyLayoutDefaults = require('./defaults'); - //todo: should be refactored in conjunction with Axes - // axref, ayref - var aaxRef = Axes.coerceARef(annIn, annOut, tdMock, axLetter); +exports.calcAutorange = require('./calc_autorange'); - // x, y - var defaultPosition = 0.5; - if(axRef !== 'paper') { - var ax = Axes.getFromId(tdMock, axRef); - defaultPosition = ax.range[0] + defaultPosition * (ax.range[1] - ax.range[0]); +exports.arrowhead = require('./draw_arrow_head'); - // convert date or category strings to numbers - if(['date', 'category'].indexOf(ax.type) !== -1 && - typeof annIn[axLetter] === 'string') { - var newval; - if(ax.type === 'date') { - newval = Lib.dateTime2ms(annIn[axLetter]); - if(newval !== false) annIn[axLetter] = newval; +var drawModule = require('./draw'); +exports.draw = drawModule.draw; +exports.drawOne = drawModule.drawOne; - if(aaxRef === axRef) { - var newvalB = Lib.dateTime2ms(annIn['a' + axLetter]); - if(newvalB !== false) annIn['a' + axLetter] = newvalB; - } - } - else if((ax._categories || []).length) { - newval = ax._categories.indexOf(annIn[axLetter]); - if(newval !== -1) annIn[axLetter] = newval; - } - } - } - coerce(axLetter, defaultPosition); - - // xanchor, yanchor - if(!showArrow) coerce(axLetter + 'anchor'); - } - - // if you have one coordinate you should have both - Lib.noneOrAll(annIn, annOut, ['x', 'y']); - - return annOut; -} - -annotations.drawAll = function(gd) { - var fullLayout = gd._fullLayout; - fullLayout._infolayer.selectAll('.annotation').remove(); - for(var i = 0; i < fullLayout.annotations.length; i++) { - annotations.draw(gd, i); - } - return Plotly.Plots.previousPromises(gd); -}; - -annotations.add = function(gd) { +exports.add = function(gd) { var nextAnn = gd._fullLayout.annotations.length; - Plotly.relayout(gd, 'annotations[' + nextAnn + ']', 'add'); -}; - -// ----------------------------------------------------- -// make or edit an annotation on the graph -// ----------------------------------------------------- - -// annotations are stored in gd.layout.annotations, an array of objects -// index can point to one item in this array, -// or non-numeric to simply add a new one -// or -1 to modify all existing -// opt can be the full options object, or one key (to be set to value) -// or undefined to simply redraw -// if opt is blank, val can be 'add' or a full options object to add a new -// annotation at that point in the array, or 'remove' to delete this one -annotations.draw = function(gd, index, opt, value) { - var layout = gd.layout, - fullLayout = gd._fullLayout, - i; - - if(!isNumeric(index) || index === -1) { - // no index provided - we're operating on ALL annotations - if(!index && Array.isArray(value)) { - // a whole annotation array is passed in - // (as in, redo of delete all) - layout.annotations = value; - annotations.supplyLayoutDefaults(layout, fullLayout); - annotations.drawAll(gd); - return; - } - else if(value === 'remove') { - // delete all - delete layout.annotations; - fullLayout.annotations = []; - annotations.drawAll(gd); - return; - } - else if(opt && value !== 'add') { - // make the same change to all annotations - for(i = 0; i < fullLayout.annotations.length; i++) { - annotations.draw(gd, i, opt, value); - } - return; - } - else { - // add a new empty annotation - index = fullLayout.annotations.length; - fullLayout.annotations.push({}); - } - } - - if(!opt && value) { - if(value === 'remove') { - fullLayout._infolayer.selectAll('.annotation[data-index="' + index + '"]') - .remove(); - fullLayout.annotations.splice(index, 1); - layout.annotations.splice(index, 1); - for(i = index; i < fullLayout.annotations.length; i++) { - fullLayout._infolayer - .selectAll('.annotation[data-index="' + (i + 1) + '"]') - .attr('data-index', String(i)); - - // redraw all annotations past the removed one, - // so they bind to the right events - annotations.draw(gd, i); - } - return; - } - else if(value === 'add' || Lib.isPlainObject(value)) { - fullLayout.annotations.splice(index, 0, {}); - - var rule = Lib.isPlainObject(value) ? - Lib.extendFlat({}, value) : - {text: 'New text'}; - - if(layout.annotations) { - layout.annotations.splice(index, 0, rule); - } else { - layout.annotations = [rule]; - } - - for(i = fullLayout.annotations.length - 1; i > index; i--) { - fullLayout._infolayer - .selectAll('.annotation[data-index="' + (i - 1) + '"]') - .attr('data-index', String(i)); - annotations.draw(gd, i); - } - } - } - - // remove the existing annotation if there is one - fullLayout._infolayer.selectAll('.annotation[data-index="' + index + '"]').remove(); - - // remember a few things about what was already there, - var optionsIn = layout.annotations[index], - oldPrivate = fullLayout.annotations[index]; - - // not sure how we're getting here... but C12 is seeing a bug - // where we fail here when they add/remove annotations - if(!optionsIn) return; - - var oldRef = {xref: optionsIn.xref, yref: optionsIn.yref}; - - // alter the input annotation as requested - var optionsEdit = {}; - if(typeof opt === 'string' && opt) optionsEdit[opt] = value; - else if(Lib.isPlainObject(opt)) optionsEdit = opt; - - var optionKeys = Object.keys(optionsEdit); - for(i = 0; i < optionKeys.length; i++) { - var k = optionKeys[i]; - Lib.nestedProperty(optionsIn, k).set(optionsEdit[k]); - } - - var gs = fullLayout._size; - - var axLetters = ['x', 'y']; - for(i = 0; i < 2; i++) { - var axLetter = axLetters[i]; - // if we don't have an explicit position already, - // don't set one just because we're changing references - // or axis type. - // the defaults will be consistent most of the time anyway, - // except in log/linear changes - if(optionsEdit[axLetter] !== undefined || - optionsIn[axLetter] === undefined) { - continue; - } - - var axOld = Axes.getFromId(gd, Axes.coerceRef(oldRef, {}, gd, axLetter)), - axNew = Axes.getFromId(gd, Axes.coerceRef(optionsIn, {}, gd, axLetter)), - position = optionsIn[axLetter], - axTypeOld = oldPrivate['_' + axLetter + 'type']; - - if(optionsEdit[axLetter + 'ref'] !== undefined) { - var autoAnchor = optionsIn[axLetter + 'anchor'] === 'auto', - plotSize = (axLetter === 'x' ? gs.w : gs.h), - halfSizeFrac = (oldPrivate['_' + axLetter + 'size'] || 0) / - (2 * plotSize); - if(axOld && axNew) { // data -> different data - // go to the same fraction of the axis length - // whether or not these axes share a domain - - // first convert to fraction of the axis - position = (position - axOld.range[0]) / - (axOld.range[1] - axOld.range[0]); - - // then convert to new data coordinates at the same fraction - position = axNew.range[0] + - position * (axNew.range[1] - axNew.range[0]); - } - else if(axOld) { // data -> paper - // first convert to fraction of the axis - position = (position - axOld.range[0]) / - (axOld.range[1] - axOld.range[0]); - - // next scale the axis to the whole plot - position = axOld.domain[0] + - position * (axOld.domain[1] - axOld.domain[0]); - - // finally see if we need to adjust auto alignment - // because auto always means middle / center alignment for data, - // but it changes for page alignment based on the closest side - if(autoAnchor) { - var posPlus = position + halfSizeFrac, - posMinus = position - halfSizeFrac; - if(position + posMinus < 2 / 3) position = posMinus; - else if(position + posPlus > 4 / 3) position = posPlus; - } - } - else if(axNew) { // paper -> data - // first see if we need to adjust auto alignment - if(autoAnchor) { - if(position < 1 / 3) position += halfSizeFrac; - else if(position > 2 / 3) position -= halfSizeFrac; - } - - // next convert to fraction of the axis - position = (position - axNew.domain[0]) / - (axNew.domain[1] - axNew.domain[0]); - - // finally convert to data coordinates - position = axNew.range[0] + - position * (axNew.range[1] - axNew.range[0]); - } - } - - if(axNew && axNew === axOld && axTypeOld) { - if(axTypeOld === 'log' && axNew.type !== 'log') { - position = Math.pow(10, position); - } - else if(axTypeOld !== 'log' && axNew.type === 'log') { - position = (position > 0) ? - Math.log(position) / Math.LN10 : undefined; - } - } - - optionsIn[axLetter] = position; - } - - var options = handleAnnotationDefaults(optionsIn, fullLayout); - fullLayout.annotations[index] = options; - - var xa = Axes.getFromId(gd, options.xref), - ya = Axes.getFromId(gd, options.yref), - annPosPx = {x: 0, y: 0}, - textangle = +options.textangle || 0; - - // create the components - // made a single group to contain all, so opacity can work right - // with border/arrow together this could handle a whole bunch of - // cleanup at this point, but works for now - var anngroup = fullLayout._infolayer.append('g') - .classed('annotation', true) - .attr('data-index', String(index)) - .style('opacity', options.opacity) - .on('click', function() { - gd._dragging = false; - gd.emit('plotly_clickannotation', { - index: index, - annotation: optionsIn, - fullAnnotation: options - }); - }); - - // another group for text+background so that they can rotate together - var anng = anngroup.append('g') - .classed('annotation-text-g', true) - .attr('data-index', String(index)); - - var ann = anng.append('g'); - - var borderwidth = options.borderwidth, - borderpad = options.borderpad, - borderfull = borderwidth + borderpad; - - var annbg = ann.append('rect') - .attr('class', 'bg') - .style('stroke-width', borderwidth + 'px') - .call(Color.stroke, options.bordercolor) - .call(Color.fill, options.bgcolor); - - var font = options.font; - - var anntext = ann.append('text') - .classed('annotation', true) - .attr('data-unformatted', options.text) - .text(options.text); - - function textLayout(s) { - s.call(Drawing.font, font) - .attr({ - 'text-anchor': { - left: 'start', - right: 'end' - }[options.align] || 'middle' - }); - - svgTextUtils.convertToTspans(s, drawGraphicalElements); - return s; - } - - function drawGraphicalElements() { - - // make sure lines are aligned the way they will be - // at the end, even if their position changes - anntext.selectAll('tspan.line').attr({y: 0, x: 0}); - - var mathjaxGroup = ann.select('.annotation-math-group'), - hasMathjax = !mathjaxGroup.empty(), - anntextBB = Drawing.bBox( - (hasMathjax ? mathjaxGroup : anntext).node()), - annwidth = anntextBB.width, - annheight = anntextBB.height, - outerwidth = Math.round(annwidth + 2 * borderfull), - outerheight = Math.round(annheight + 2 * borderfull); - - - // save size in the annotation object for use by autoscale - options._w = annwidth; - options._h = annheight; - - function shiftFraction(v, anchor) { - if(anchor === 'auto') { - if(v < 1 / 3) anchor = 'left'; - else if(v > 2 / 3) anchor = 'right'; - else anchor = 'center'; - } - return { - center: 0, - middle: 0, - left: 0.5, - bottom: -0.5, - right: -0.5, - top: 0.5 - }[anchor]; - } - - var annotationIsOffscreen = false; - ['x', 'y'].forEach(function(axLetter) { - var axRef = options[axLetter + 'ref'] || axLetter, - ax = Axes.getFromId(gd, axRef), - dimAngle = (textangle + (axLetter === 'x' ? 0 : 90)) * Math.PI / 180, - annSize = outerwidth * Math.abs(Math.cos(dimAngle)) + - outerheight * Math.abs(Math.sin(dimAngle)), - anchor = options[axLetter + 'anchor'], - alignPosition; - - // calculate pixel position - if(ax) { - // hide the annotation if it's pointing - // outside the visible plot (as long as the axis - // isn't autoranged - then we need to draw it - // anyway to get its bounding box) - if(!ax.autorange && ((options[axLetter] - ax.range[0]) * - (options[axLetter] - ax.range[1]) > 0)) { - if(options['a' + axLetter + 'ref'] === axRef) { - if((options['a' + axLetter] - ax.range[0]) * - (options['a' + axLetter] - ax.range[1]) > 0) { - annotationIsOffscreen = true; - } - } else { - annotationIsOffscreen = true; - } - - if(annotationIsOffscreen) return; - } - annPosPx[axLetter] = ax._offset + ax.l2p(options[axLetter]); - alignPosition = 0.5; - } - else { - alignPosition = options[axLetter]; - if(axLetter === 'y') alignPosition = 1 - alignPosition; - annPosPx[axLetter] = (axLetter === 'x') ? - (gs.l + gs.w * alignPosition) : - (gs.t + gs.h * alignPosition); - } - - var alignShift = 0; - if(options['a' + axLetter + 'ref'] === axRef) { - annPosPx['aa' + axLetter] = ax._offset + ax.l2p(options['a' + axLetter]); - } else { - if(options.showarrow) { - alignShift = options['a' + axLetter]; - } - else { - alignShift = annSize * shiftFraction(alignPosition, anchor); - } - annPosPx[axLetter] += alignShift; - } - - // save the current axis type for later log/linear changes - options['_' + axLetter + 'type'] = ax && ax.type; - - // save the size and shift in this dim for autorange - options['_' + axLetter + 'size'] = annSize; - options['_' + axLetter + 'shift'] = alignShift; - }); - - if(annotationIsOffscreen) { - ann.remove(); - return; - } - - var arrowX, arrowY; - // make sure the arrowhead (if there is one) - // and the annotation center are visible - if(options.showarrow) { - if(options.axref === options.xref) { - //we don't want to constrain if the tail is absolute - //or the slope (which is meaningful) will change. - arrowX = annPosPx.x; - } else { - arrowX = Lib.constrain(annPosPx.x - options.ax, 1, fullLayout.width - 1); - } - - if(options.ayref === options.yref) { - //we don't want to constrain if the tail is absolute - //or the slope (which is meaningful) will change. - arrowY = annPosPx.y; - } else { - arrowY = Lib.constrain(annPosPx.y - options.ay, 1, fullLayout.height - 1); - } - } - annPosPx.x = Lib.constrain(annPosPx.x, 1, fullLayout.width - 1); - annPosPx.y = Lib.constrain(annPosPx.y, 1, fullLayout.height - 1); - - var texty = borderfull - anntextBB.top, - textx = borderfull - anntextBB.left; - - if(hasMathjax) { - mathjaxGroup.select('svg').attr({x: borderfull - 1, y: borderfull}); - } - else { - anntext.attr({x: textx, y: texty}); - anntext.selectAll('tspan.line').attr({y: texty, x: textx}); - } - - annbg.call(Drawing.setRect, borderwidth / 2, borderwidth / 2, - outerwidth - borderwidth, outerheight - borderwidth); - - var annX = 0, annY = 0; - if(options.axref === options.xref) { - annX = Math.round(annPosPx.aax - outerwidth / 2); - } else { - annX = Math.round(annPosPx.x - outerwidth / 2); - } - - if(options.ayref === options.yref) { - annY = Math.round(annPosPx.aay - outerheight / 2); - } else { - annY = Math.round(annPosPx.y - outerheight / 2); - } - - ann.call(Lib.setTranslate, annX, annY); - - var annbase = 'annotations[' + index + ']'; - - // add the arrow - // uses options[arrowwidth,arrowcolor,arrowhead] for styling - var drawArrow = function(dx, dy) { - d3.select(gd) - .selectAll('.annotation-arrow-g[data-index="' + index + '"]') - .remove(); - // find where to start the arrow: - // at the border of the textbox, if that border is visible, - // or at the edge of the lines of text, if the border is hidden - // TODO: tspan bounding box fails in chrome - // looks like there may be a cross-browser solution, see - // http://stackoverflow.com/questions/5364980/ - // how-to-get-the-width-of-an-svg-tspan-element - var arrowX0, arrowY0; - - if(options.axref === options.xref) { - arrowX0 = annPosPx.aax + dx; - } else { - arrowX0 = annPosPx.x + dx; - } - - if(options.ayref === options.yref) { - arrowY0 = annPosPx.aay + dy; - } else { - arrowY0 = annPosPx.y + dy; - } - - // create transform matrix and related functions - var transform = - Lib.rotationXYMatrix(textangle, arrowX0, arrowY0), - applyTransform = Lib.apply2DTransform(transform), - applyTransform2 = Lib.apply2DTransform2(transform), - - // calculate and transform bounding box - xHalf = annbg.attr('width') / 2, - yHalf = annbg.attr('height') / 2, - edges = [ - [arrowX0 - xHalf, arrowY0 - yHalf, arrowX0 - xHalf, arrowY0 + yHalf], - [arrowX0 - xHalf, arrowY0 + yHalf, arrowX0 + xHalf, arrowY0 + yHalf], - [arrowX0 + xHalf, arrowY0 + yHalf, arrowX0 + xHalf, arrowY0 - yHalf], - [arrowX0 + xHalf, arrowY0 - yHalf, arrowX0 - xHalf, arrowY0 - yHalf] - ].map(applyTransform2); - - // Remove the line if it ends inside the box. Use ray - // casting for rotated boxes: see which edges intersect a - // line from the arrowhead to far away and reduce with xor - // to get the parity of the number of intersections. - if(edges.reduce(function(a, x) { - return a ^ - !!lineIntersect(arrowX, arrowY, arrowX + 1e6, arrowY + 1e6, - x[0], x[1], x[2], x[3]); - }, false)) { - // no line or arrow - so quit drawArrow now - return; - } - - edges.forEach(function(x) { - var p = lineIntersect(arrowX0, arrowY0, arrowX, arrowY, - x[0], x[1], x[2], x[3]); - if(p) { - arrowX0 = p.x; - arrowY0 = p.y; - } - }); - - var strokewidth = options.arrowwidth, - arrowColor = options.arrowcolor; - - var arrowgroup = anngroup.append('g') - .style({opacity: Color.opacity(arrowColor)}) - .classed('annotation-arrow-g', true) - .attr('data-index', String(index)); - - var arrow = arrowgroup.append('path') - .attr('d', 'M' + arrowX0 + ',' + arrowY0 + 'L' + arrowX + ',' + arrowY) - .style('stroke-width', strokewidth + 'px') - .call(Color.stroke, Color.rgb(arrowColor)); - - annotations.arrowhead(arrow, options.arrowhead, 'end', options.arrowsize); - - var arrowdrag = arrowgroup.append('path') - .classed('annotation', true) - .classed('anndrag', true) - .attr({ - 'data-index': String(index), - d: 'M3,3H-3V-3H3ZM0,0L' + (arrowX0 - arrowX) + ',' + (arrowY0 - arrowY), - transform: 'translate(' + arrowX + ',' + arrowY + ')' - }) - .style('stroke-width', (strokewidth + 6) + 'px') - .call(Color.stroke, 'rgba(0,0,0,0)') - .call(Color.fill, 'rgba(0,0,0,0)'); - - if(gd._context.editable) { - var update, - annx0, - anny0; - - dragElement.init({ - element: arrowdrag.node(), - prepFn: function() { - var pos = Lib.getTranslate(ann); - - annx0 = pos.x; - anny0 = pos.y; - update = {}; - if(xa && xa.autorange) { - update[xa._name + '.autorange'] = true; - } - if(ya && ya.autorange) { - update[ya._name + '.autorange'] = true; - } - }, - moveFn: function(dx, dy) { - arrowgroup.attr('transform', 'translate(' + dx + ',' + dy + ')'); - - var annxy0 = applyTransform(annx0, anny0), - xcenter = annxy0[0] + dx, - ycenter = annxy0[1] + dy; - ann.call(Lib.setTranslate, xcenter, ycenter); - - update[annbase + '.x'] = xa ? - (options.x + dx / xa._m) : - ((arrowX + dx - gs.l) / gs.w); - update[annbase + '.y'] = ya ? - (options.y + dy / ya._m) : - (1 - ((arrowY + dy - gs.t) / gs.h)); - - if(options.axref === options.xref) { - update[annbase + '.ax'] = xa ? - (options.ax + dx / xa._m) : - ((arrowX + dx - gs.l) / gs.w); - } - - if(options.ayref === options.yref) { - update[annbase + '.ay'] = ya ? - (options.ay + dy / ya._m) : - (1 - ((arrowY + dy - gs.t) / gs.h)); - } - - anng.attr({ - transform: 'rotate(' + textangle + ',' + - xcenter + ',' + ycenter + ')' - }); - }, - doneFn: function(dragged) { - if(dragged) { - Plotly.relayout(gd, update); - var notesBox = document.querySelector('.js-notes-box-panel'); - if(notesBox) notesBox.redraw(notesBox.selectedObj); - } - } - }); - } - }; - - if(options.showarrow) drawArrow(0, 0); - - // create transform matrix and related functions - var transform = Lib.rotationXYMatrix(textangle, - annPosPx.x, annPosPx.y), - applyTransform = Lib.apply2DTransform(transform); - - // user dragging the annotation (text, not arrow) - if(gd._context.editable) { - var x0, - y0, - update; - - dragElement.init({ - element: ann.node(), - prepFn: function() { - var pos = Lib.getTranslate(ann); - - x0 = pos.x; - y0 = pos.y; - update = {}; - }, - moveFn: function(dx, dy) { - ann.call(Lib.setTranslate, x0 + dx, y0 + dy); - var csr = 'pointer'; - if(options.showarrow) { - if(options.axref === options.xref) { - update[annbase + '.ax'] = xa.p2l(xa.l2p(options.ax) + dx); - } else { - update[annbase + '.ax'] = options.ax + dx; - } - - if(options.ayref === options.yref) { - update[annbase + '.ay'] = ya.p2l(ya.l2p(options.ay) + dy); - } else { - update[annbase + '.ay'] = options.ay + dy; - } - - drawArrow(dx, dy); - } - else { - if(xa) update[annbase + '.x'] = options.x + dx / xa._m; - else { - var widthFraction = options._xsize / gs.w, - xLeft = options.x + options._xshift / gs.w - widthFraction / 2; - - update[annbase + '.x'] = dragElement.align(xLeft + dx / gs.w, - widthFraction, 0, 1, options.xanchor); - } - - if(ya) update[annbase + '.y'] = options.y + dy / ya._m; - else { - var heightFraction = options._ysize / gs.h, - yBottom = options.y - options._yshift / gs.h - heightFraction / 2; - - update[annbase + '.y'] = dragElement.align(yBottom - dy / gs.h, - heightFraction, 0, 1, options.yanchor); - } - if(!xa || !ya) { - csr = dragElement.getCursor( - xa ? 0.5 : update[annbase + '.x'], - ya ? 0.5 : update[annbase + '.y'], - options.xanchor, options.yanchor - ); - } - } - - var xy1 = applyTransform(x0, y0), - x1 = xy1[0] + dx, - y1 = xy1[1] + dy; - - ann.call(Lib.setTranslate, x0 + dx, y0 + dy); - - anng.attr({ - transform: 'rotate(' + textangle + ',' + - x1 + ',' + y1 + ')' - }); - - setCursor(ann, csr); - }, - doneFn: function(dragged) { - setCursor(ann); - if(dragged) { - Plotly.relayout(gd, update); - var notesBox = document.querySelector('.js-notes-box-panel'); - if(notesBox) notesBox.redraw(notesBox.selectedObj); - } - } - }); - } - } - - if(gd._context.editable) { - anntext.call(svgTextUtils.makeEditable, ann) - .call(textLayout) - .on('edit', function(_text) { - options.text = _text; - this.attr({'data-unformatted': options.text}); - this.call(textLayout); - var update = {}; - update['annotations[' + index + '].text'] = options.text; - if(xa && xa.autorange) { - update[xa._name + '.autorange'] = true; - } - if(ya && ya.autorange) { - update[ya._name + '.autorange'] = true; - } - Plotly.relayout(gd, update); - }); - } - else anntext.call(textLayout); - - // rotate and position text and background - anng.attr({transform: 'rotate(' + textangle + ',' + - annPosPx.x + ',' + annPosPx.y + ')'}) - .call(Drawing.setPosition, annPosPx.x, annPosPx.y); -}; - -// add arrowhead(s) to a path or line d3 element el3 -// style: 1-6, first 5 are pointers, 6 is circle, 7 is square, 8 is none -// ends is 'start', 'end' (default), 'start+end' -// mag is magnification vs. default (default 1) -annotations.arrowhead = function(el3, style, ends, mag) { - if(!isNumeric(mag)) mag = 1; - var el = el3.node(), - headStyle = annotations.ARROWPATHS[style||0]; - if(!headStyle) return; - - if(typeof ends !== 'string' || !ends) ends = 'end'; - - var scale = (Drawing.getPx(el3, 'stroke-width') || 1) * mag, - stroke = el3.style('stroke') || Color.defaultLine, - opacity = el3.style('stroke-opacity') || 1, - doStart = ends.indexOf('start') >= 0, - doEnd = ends.indexOf('end') >= 0, - backOff = headStyle.backoff * scale, - start, - end, - startRot, - endRot; - - if(el.nodeName === 'line') { - start = {x: +el3.attr('x1'), y: +el3.attr('y1')}; - end = {x: +el3.attr('x2'), y: +el3.attr('y2')}; - startRot = Math.atan2(start.y - end.y, start.x - end.x); - endRot = startRot + Math.PI; - if(backOff) { - var backOffX = backOff * Math.cos(startRot), - backOffY = backOff * Math.sin(startRot); - - if(doStart) { - start.x -= backOffX; - start.y -= backOffY; - el3.attr({x1: start.x, y1: start.y}); - } - if(doEnd) { - end.x += backOffX; - end.y += backOffY; - el3.attr({x2: end.x, y2: end.y}); - } - } - } - else if(el.nodeName === 'path') { - var pathlen = el.getTotalLength(), - // using dash to hide the backOff region of the path. - // if we ever allow dash for the arrow we'll have to - // do better than this hack... maybe just manually - // combine the two - dashArray = ''; - - if(doStart) { - var start0 = el.getPointAtLength(0), - dstart = el.getPointAtLength(0.1); - startRot = Math.atan2(start0.y - dstart.y, start0.x - dstart.x); - start = el.getPointAtLength(Math.min(backOff, pathlen)); - if(backOff) dashArray = '0px,' + backOff + 'px,'; - } - - if(doEnd) { - var end0 = el.getPointAtLength(pathlen), - dend = el.getPointAtLength(pathlen - 0.1); - endRot = Math.atan2(end0.y - dend.y, end0.x - dend.x); - end = el.getPointAtLength(Math.max(0, pathlen - backOff)); - - if(backOff) { - var shortening = dashArray ? 2 * backOff : backOff; - dashArray += (pathlen - shortening) + 'px,' + pathlen + 'px'; - } - } - else if(dashArray) dashArray += pathlen + 'px'; - - if(dashArray) el3.style('stroke-dasharray', dashArray); - } - - var drawhead = function(p, rot) { - if(style > 5) rot = 0; // don't rotate square or circle - d3.select(el.parentElement).append('path') - .attr({ - 'class': el3.attr('class'), - d: headStyle.path, - transform: - 'translate(' + p.x + ',' + p.y + ')' + - 'rotate(' + (rot * 180 / Math.PI) + ')' + - 'scale(' + scale + ')' - }) - .style({ - fill: stroke, - opacity: opacity, - 'stroke-width': 0 - }); - }; - - if(doStart) drawhead(start, startRot); - if(doEnd) drawhead(end, endRot); -}; - -annotations.calcAutorange = function(gd) { - var fullLayout = gd._fullLayout, - annotationList = fullLayout.annotations; - - if(!annotationList.length || !gd._fullData.length) return; - - var annotationAxes = {}; - annotationList.forEach(function(ann) { - annotationAxes[ann.xref] = true; - annotationAxes[ann.yref] = true; - }); - - var autorangedAnnos = Axes.list(gd).filter(function(ax) { - return ax.autorange && annotationAxes[ax._id]; - }); - if(!autorangedAnnos.length) return; - - return Lib.syncOrAsync([ - annotations.drawAll, - annAutorange - ], gd); + Plotly.relayout(gd, 'annotations[' + nextAnn + ']', 'add'); }; - -function annAutorange(gd) { - var fullLayout = gd._fullLayout; - - // find the bounding boxes for each of these annotations' - // relative to their anchor points - // use the arrow and the text bg rectangle, - // as the whole anno may include hidden text in its bbox - fullLayout.annotations.forEach(function(ann) { - var xa = Axes.getFromId(gd, ann.xref), - ya = Axes.getFromId(gd, ann.yref); - if(!(xa || ya)) return; - - var halfWidth = (ann._xsize || 0) / 2, - xShift = ann._xshift || 0, - halfHeight = (ann._ysize || 0) / 2, - yShift = ann._yshift || 0, - leftSize = halfWidth - xShift, - rightSize = halfWidth + xShift, - topSize = halfHeight - yShift, - bottomSize = halfHeight + yShift; - if(ann.showarrow) { - var headSize = 3 * ann.arrowsize * ann.arrowwidth; - leftSize = Math.max(leftSize, headSize); - rightSize = Math.max(rightSize, headSize); - topSize = Math.max(topSize, headSize); - bottomSize = Math.max(bottomSize, headSize); - } - if(xa && xa.autorange) { - Axes.expand(xa, [xa.l2c(ann.x)], { - ppadplus: rightSize, - ppadminus: leftSize - }); - } - if(ya && ya.autorange) { - Axes.expand(ya, [ya.l2c(ann.y)], { - ppadplus: bottomSize, - ppadminus: topSize - }); - } - }); -} - -// look for intersection of two line segments -// (1->2 and 3->4) - returns array [x,y] if they do, null if not -function lineIntersect(x1, y1, x2, y2, x3, y3, x4, y4) { - var a = x2 - x1, - b = x3 - x1, - c = x4 - x3, - d = y2 - y1, - e = y3 - y1, - f = y4 - y3, - det = a * f - c * d; - // parallel lines? intersection is undefined - // ignore the case where they are colinear - if(det === 0) return null; - var t = (b * f - c * e) / det, - u = (b * d - a * e) / det; - // segments do not intersect? - if(u < 0 || u > 1 || t < 0 || t > 1) return null; - - return {x: x1 + a * t, y: y1 + d * t}; -} diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 5ce6953015f..580161d0c31 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -24,6 +24,7 @@ var Fx = require('../plots/cartesian/graph_interact'); var Color = require('../components/color'); var Drawing = require('../components/drawing'); var ErrorBars = require('../components/errorbars'); +var Annotations = require('../components/annotations'); var Images = require('../components/images'); var Legend = require('../components/legend'); var RangeSlider = require('../components/rangeslider'); @@ -231,7 +232,7 @@ Plotly.plot = function(gd, data, layout, config) { // TODO: autosize extra for text markers return Lib.syncOrAsync([ Shapes.calcAutorange, - Plotly.Annotations.calcAutorange, + Annotations.calcAutorange, doAutoRange ], gd); } @@ -284,7 +285,7 @@ Plotly.plot = function(gd, data, layout, config) { // show annotations and shapes Shapes.drawAll(gd); - Plotly.Annotations.drawAll(gd); + Annotations.draw(gd); // source links Plots.addLinks(gd); @@ -301,7 +302,7 @@ Plotly.plot = function(gd, data, layout, config) { function finalDraw() { Shapes.drawAll(gd); Images.draw(gd); - Plotly.Annotations.drawAll(gd); + Annotations.draw(gd); Legend.draw(gd); RangeSlider.draw(gd); RangeSelector.draw(gd); @@ -2305,7 +2306,7 @@ Plotly.relayout = function relayout(gd, astr, val) { } // TODO: combine all edits to a given annotation / shape into one call // as it is we get separate calls for x and y (or ax and ay) on move - objModule.draw(gd, objNum, p.parts.slice(2).join('.'), aobj[ai]); + objModule.drawOne(gd, objNum, p.parts.slice(2).join('.'), aobj[ai]); delete aobj[ai]; } else if(p.parts[0] === 'images') { diff --git a/src/plots/plots.js b/src/plots/plots.js index c7f7ac041ab..a5c34a36dd9 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -265,7 +265,7 @@ plots.redrawText = function(gd) { return new Promise(function(resolve) { setTimeout(function() { - Plotly.Annotations.drawAll(gd); + Plotly.Annotations.draw(gd); Plotly.Legend.draw(gd); (gd.calcdata || []).forEach(function(d) { From bc4069c7dffa8985922785fed284bcb727b234ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 5 Aug 2016 18:51:27 -0400 Subject: [PATCH 2/5] cleanup: break shapes into files - rename Shapes.drawAll -> Shapes.draw - make old Shapes.draw a private method in shapes/draw.js - make old Shapes.convertPath a private method in shapes/draw.js - rm Shapes.add (wasn't used anywhere) --- src/components/shapes/calc_autorange.js | 74 +++ src/components/shapes/constants.js | 62 ++ src/components/shapes/defaults.js | 25 + src/components/shapes/draw.js | 561 +++++++++++++++++ src/components/shapes/helpers.js | 79 +++ src/components/shapes/index.js | 779 +----------------------- src/components/shapes/shape_defaults.js | 69 +++ src/plot_api/plot_api.js | 4 +- 8 files changed, 878 insertions(+), 775 deletions(-) create mode 100644 src/components/shapes/calc_autorange.js create mode 100644 src/components/shapes/constants.js create mode 100644 src/components/shapes/defaults.js create mode 100644 src/components/shapes/draw.js create mode 100644 src/components/shapes/helpers.js create mode 100644 src/components/shapes/shape_defaults.js diff --git a/src/components/shapes/calc_autorange.js b/src/components/shapes/calc_autorange.js new file mode 100644 index 00000000000..0aaaf499d13 --- /dev/null +++ b/src/components/shapes/calc_autorange.js @@ -0,0 +1,74 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Axes = require('../../plots/cartesian/axes'); + +var constants = require('./constants'); +var helpers = require('./helpers'); + + +module.exports = function calcAutorange(gd) { + var fullLayout = gd._fullLayout, + shapeList = fullLayout.shapes; + + if(!shapeList.length || !gd._fullData.length) return; + + for(var i = 0; i < shapeList.length; i++) { + var shape = shapeList[i], + ppad = shape.line.width / 2; + + var ax, bounds; + + if(shape.xref !== 'paper') { + ax = Axes.getFromId(gd, shape.xref); + bounds = shapeBounds(ax, shape.x0, shape.x1, shape.path, constants.paramIsX); + if(bounds) Axes.expand(ax, bounds, {ppad: ppad}); + } + + if(shape.yref !== 'paper') { + ax = Axes.getFromId(gd, shape.yref); + bounds = shapeBounds(ax, shape.y0, shape.y1, shape.path, constants.paramIsY); + if(bounds) Axes.expand(ax, bounds, {ppad: ppad}); + } + } +}; + +function shapeBounds(ax, v0, v1, path, paramsToUse) { + var convertVal = (ax.type === 'category') ? Number : ax.d2c; + + if(v0 !== undefined) return [convertVal(v0), convertVal(v1)]; + if(!path) return; + + var min = Infinity, + max = -Infinity, + segments = path.match(constants.segmentRE), + i, + segment, + drawnParam, + params, + val; + + if(ax.type === 'date') convertVal = helpers.decodeDate(convertVal); + + for(i = 0; i < segments.length; i++) { + segment = segments[i]; + drawnParam = paramsToUse[segment.charAt(0)].drawn; + if(drawnParam === undefined) continue; + + params = segments[i].substr(1).match(constants.paramRE); + if(!params || params.length < drawnParam) continue; + + val = convertVal(params[drawnParam]); + if(val < min) min = val; + if(val > max) max = val; + } + if(max >= min) return [min, max]; +} diff --git a/src/components/shapes/constants.js b/src/components/shapes/constants.js new file mode 100644 index 00000000000..512d4750933 --- /dev/null +++ b/src/components/shapes/constants.js @@ -0,0 +1,62 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + + +module.exports = { + segmentRE: /[MLHVQCTSZ][^MLHVQCTSZ]*/g, + paramRE: /[^\s,]+/g, + + // which numbers in each path segment are x (or y) values + // drawn is which param is a drawn point, as opposed to a + // control point (which doesn't count toward autorange. + // TODO: this means curved paths could extend beyond the + // autorange bounds. This is a bit tricky to get right + // unless we revert to bounding boxes, but perhaps there's + // a calculation we could do...) + paramIsX: { + M: {0: true, drawn: 0}, + L: {0: true, drawn: 0}, + H: {0: true, drawn: 0}, + V: {}, + Q: {0: true, 2: true, drawn: 2}, + C: {0: true, 2: true, 4: true, drawn: 4}, + T: {0: true, drawn: 0}, + S: {0: true, 2: true, drawn: 2}, + // A: {0: true, 5: true}, + Z: {} + }, + + paramIsY: { + M: {1: true, drawn: 1}, + L: {1: true, drawn: 1}, + H: {}, + V: {0: true, drawn: 0}, + Q: {1: true, 3: true, drawn: 3}, + C: {1: true, 3: true, 5: true, drawn: 5}, + T: {1: true, drawn: 1}, + S: {1: true, 3: true, drawn: 5}, + // A: {1: true, 6: true}, + Z: {} + }, + + numParams: { + M: 2, + L: 2, + H: 1, + V: 1, + Q: 4, + C: 6, + T: 2, + S: 4, + // A: 7, + Z: 0 + } +}; diff --git a/src/components/shapes/defaults.js b/src/components/shapes/defaults.js new file mode 100644 index 00000000000..5479f37316f --- /dev/null +++ b/src/components/shapes/defaults.js @@ -0,0 +1,25 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var handleShapeDefaults = require('./shape_defaults'); + + +module.exports = function supplyLayoutDefaults(layoutIn, layoutOut) { + var containerIn = layoutIn.shapes || [], + containerOut = layoutOut.shapes = []; + + for(var i = 0; i < containerIn.length; i++) { + var shapeIn = containerIn[i] || {}, + shapeOut = handleShapeDefaults(shapeIn, layoutOut); + + containerOut.push(shapeOut); + } +}; diff --git a/src/components/shapes/draw.js b/src/components/shapes/draw.js new file mode 100644 index 00000000000..1bc0a165216 --- /dev/null +++ b/src/components/shapes/draw.js @@ -0,0 +1,561 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var isNumeric = require('fast-isnumeric'); + +var Plotly = require('../../plotly'); +var Lib = require('../../lib'); +var Axes = require('../../plots/cartesian/axes'); +var Color = require('../color'); +var Drawing = require('../drawing'); + +var dragElement = require('../dragelement'); +var setCursor = require('../../lib/setcursor'); + +var constants = require('./constants'); +var helpers = require('./helpers'); +var handleShapeDefaults = require('./shape_defaults'); +var supplyLayoutDefaults = require('./defaults'); + + +// Shapes are stored in gd.layout.shapes, an array of objects +// index can point to one item in this array, +// or non-numeric to simply add a new one +// or -1 to modify all existing +// opt can be the full options object, or one key (to be set to value) +// or undefined to simply redraw +// if opt is blank, val can be 'add' or a full options object to add a new +// annotation at that point in the array, or 'remove' to delete this one + +module.exports = { + draw: draw, + drawOne: drawOne +}; + +function draw(gd) { + var fullLayout = gd._fullLayout; + + // Remove previous shapes before drawing new in shapes in fullLayout.shapes + fullLayout._shapeUpperLayer.selectAll('path').remove(); + fullLayout._shapeLowerLayer.selectAll('path').remove(); + fullLayout._shapeSubplotLayer.selectAll('path').remove(); + + for(var i = 0; i < fullLayout.shapes.length; i++) { + drawOne(gd, i); + } + + // may need to resurrect this if we put text (LaTeX) in shapes + // return Plotly.Plots.previousPromises(gd); +} + +function drawOne(gd, index, opt, value) { + if(!isNumeric(index) || index === -1) { + + // no index provided - we're operating on ALL shapes + if(!index && Array.isArray(value)) { + replaceAllShapes(gd, value); + return; + } + else if(value === 'remove') { + deleteAllShapes(gd); + return; + } + else if(opt && value !== 'add') { + updateAllShapes(gd, opt, value); + return; + } + else { + // add a new empty annotation + index = gd._fullLayout.shapes.length; + gd._fullLayout.shapes.push({}); + } + } + + if(!opt && value) { + if(value === 'remove') { + deleteShape(gd, index); + return; + } + else if(value === 'add' || Lib.isPlainObject(value)) { + insertShape(gd, index, value); + } + } + + updateShape(gd, index, opt, value); +} + +function replaceAllShapes(gd, newShapes) { + gd.layout.shapes = newShapes; + supplyLayoutDefaults(gd.layout, gd._fullLayout); + draw(gd); +} + +function deleteAllShapes(gd) { + delete gd.layout.shapes; + gd._fullLayout.shapes = []; + draw(gd); +} + +function updateAllShapes(gd, opt, value) { + for(var i = 0; i < gd._fullLayout.shapes.length; i++) { + drawOne(gd, i, opt, value); + } +} + +function deleteShape(gd, index) { + getShapeLayer(gd, index) + .selectAll('[data-index="' + index + '"]') + .remove(); + + gd._fullLayout.shapes.splice(index, 1); + + gd.layout.shapes.splice(index, 1); + + for(var i = index; i < gd._fullLayout.shapes.length; i++) { + // redraw all shapes past the removed one, + // so they bind to the right events + getShapeLayer(gd, i) + .selectAll('[data-index="' + (i + 1) + '"]') + .attr('data-index', i); + drawOne(gd, i); + } +} + +function insertShape(gd, index, newShape) { + gd._fullLayout.shapes.splice(index, 0, {}); + + var rule = Lib.isPlainObject(newShape) ? + Lib.extendFlat({}, newShape) : + {text: 'New text'}; + + if(gd.layout.shapes) { + gd.layout.shapes.splice(index, 0, rule); + } else { + gd.layout.shapes = [rule]; + } + + // there is no need to call shapes.draw(gd, index), + // because updateShape() is called from within shapes.draw() + + for(var i = gd._fullLayout.shapes.length - 1; i > index; i--) { + getShapeLayer(gd, i) + .selectAll('[data-index="' + (i - 1) + '"]') + .attr('data-index', i); + drawOne(gd, i); + } +} + +function updateShape(gd, index, opt, value) { + var i, n; + + // remove the existing shape if there is one + getShapeLayer(gd, index) + .selectAll('[data-index="' + index + '"]') + .remove(); + + // remember a few things about what was already there, + var optionsIn = gd.layout.shapes[index]; + + // (from annos...) not sure how we're getting here... but C12 is seeing a bug + // where we fail here when they add/remove annotations + // TODO: clean this up and remove it. + if(!optionsIn) return; + + var oldRef = {xref: optionsIn.xref, yref: optionsIn.yref}; + + // alter the input shape as requested + var optionsEdit = {}; + if(typeof opt === 'string' && opt) optionsEdit[opt] = value; + else if(Lib.isPlainObject(opt)) optionsEdit = opt; + + var optionKeys = Object.keys(optionsEdit); + for(i = 0; i < optionKeys.length; i++) { + var k = optionKeys[i]; + Lib.nestedProperty(optionsIn, k).set(optionsEdit[k]); + } + + var posAttrs = ['x0', 'x1', 'y0', 'y1']; + for(i = 0; i < 4; i++) { + var posAttr = posAttrs[i]; + // if we don't have an explicit position already, + // don't set one just because we're changing references + // or axis type. + // the defaults will be consistent most of the time anyway, + // except in log/linear changes + if(optionsEdit[posAttr] !== undefined || + optionsIn[posAttr] === undefined) { + continue; + } + + var axLetter = posAttr.charAt(0), + axOld = Axes.getFromId(gd, + Axes.coerceRef(oldRef, {}, gd, axLetter)), + axNew = Axes.getFromId(gd, + Axes.coerceRef(optionsIn, {}, gd, axLetter)), + position = optionsIn[posAttr], + linearizedPosition; + + if(optionsEdit[axLetter + 'ref'] !== undefined) { + // first convert to fraction of the axis + if(axOld) { + linearizedPosition = helpers.dataToLinear(axOld)(position); + position = (linearizedPosition - axOld.range[0]) / + (axOld.range[1] - axOld.range[0]); + } else { + position = (position - axNew.domain[0]) / + (axNew.domain[1] - axNew.domain[0]); + } + + if(axNew) { + // then convert to new data coordinates at the same fraction + linearizedPosition = axNew.range[0] + position * + (axNew.range[1] - axNew.range[0]); + position = helpers.linearToData(axNew)(linearizedPosition); + } else { + // or scale to the whole plot + position = axOld.domain[0] + + position * (axOld.domain[1] - axOld.domain[0]); + } + } + + optionsIn[posAttr] = position; + } + + var options = handleShapeDefaults(optionsIn, gd._fullLayout); + gd._fullLayout.shapes[index] = options; + + var clipAxes; + if(options.layer !== 'below') { + clipAxes = (options.xref + options.yref).replace(/paper/g, ''); + drawShape(gd._fullLayout._shapeUpperLayer); + } + else if(options.xref === 'paper' && options.yref === 'paper') { + clipAxes = ''; + drawShape(gd._fullLayout._shapeLowerLayer); + } + else { + var plots = gd._fullLayout._plots || {}, + subplots = Object.keys(plots), + plotinfo; + + for(i = 0, n = subplots.length; i < n; i++) { + plotinfo = plots[subplots[i]]; + clipAxes = subplots[i]; + + if(isShapeInSubplot(gd, options, plotinfo)) { + drawShape(plotinfo.shapelayer); + } + } + } + + function drawShape(shapeLayer) { + var attrs = { + 'data-index': index, + 'fill-rule': 'evenodd', + d: getPathString(gd, options) + }, + lineColor = options.line.width ? + options.line.color : 'rgba(0,0,0,0)'; + + var path = shapeLayer.append('path') + .attr(attrs) + .style('opacity', options.opacity) + .call(Color.stroke, lineColor) + .call(Color.fill, options.fillcolor) + .call(Drawing.dashLine, options.line.dash, options.line.width); + + if(clipAxes) { + path.call(Drawing.setClipUrl, + 'clip' + gd._fullLayout._uid + clipAxes); + } + + if(gd._context.editable) setupDragElement(gd, path, options, index); + } +} + +function setupDragElement(gd, shapePath, shapeOptions, index) { + var MINWIDTH = 10, + MINHEIGHT = 10; + + var update; + var x0, y0, x1, y1, astrX0, astrY0, astrX1, astrY1; + var n0, s0, w0, e0, astrN, astrS, astrW, astrE, optN, optS, optW, optE; + var pathIn, astrPath; + + var xa, ya, x2p, y2p, p2x, p2y; + + var dragOptions = { + setCursor: updateDragMode, + element: shapePath.node(), + prepFn: startDrag, + doneFn: endDrag + }, + dragBBox = dragOptions.element.getBoundingClientRect(), + dragMode; + + dragElement.init(dragOptions); + + function updateDragMode(evt) { + // 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 startDrag(evt) { + // setup conversion functions + 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); + + // setup update strings and initial values + var astr = 'shapes[' + index + ']'; + if(shapeOptions.type === 'path') { + pathIn = shapeOptions.path; + astrPath = astr + '.path'; + } + else { + x0 = x2p(shapeOptions.x0); + y0 = y2p(shapeOptions.y0); + x1 = x2p(shapeOptions.x1); + y1 = y2p(shapeOptions.y1); + + astrX0 = astr + '.x0'; + astrY0 = astr + '.y0'; + astrX1 = astr + '.x1'; + astrY1 = astr + '.y1'; + } + + if(x0 < x1) { + w0 = x0; astrW = astr + '.x0'; optW = 'x0'; + e0 = x1; astrE = astr + '.x1'; optE = 'x1'; + } + else { + w0 = x1; astrW = astr + '.x1'; optW = 'x1'; + e0 = x0; astrE = astr + '.x0'; optE = 'x0'; + } + if(y0 < y1) { + n0 = y0; astrN = astr + '.y0'; optN = 'y0'; + s0 = y1; astrS = astr + '.y1'; optS = 'y1'; + } + else { + n0 = y1; astrN = astr + '.y1'; optN = 'y1'; + s0 = y0; astrS = astr + '.y0'; optS = 'y0'; + } + + update = {}; + + // setup dragMode and the corresponding handler + updateDragMode(evt); + dragOptions.moveFn = (dragMode === 'move') ? moveShape : resizeShape; + } + + function endDrag(dragged) { + setCursor(shapePath); + if(dragged) { + Plotly.relayout(gd, update); + } + } + + function moveShape(dx, dy) { + if(shapeOptions.type === 'path') { + var moveX = function moveX(x) { return p2x(x2p(x) + dx); }; + if(xa && xa.type === 'date') moveX = helpers.encodeDate(moveX); + + var moveY = function moveY(y) { return p2y(y2p(y) + dy); }; + if(ya && ya.type === 'date') moveY = helpers.encodeDate(moveY); + + shapeOptions.path = movePath(pathIn, moveX, moveY); + update[astrPath] = shapeOptions.path; + } + else { + update[astrX0] = shapeOptions.x0 = p2x(x0 + dx); + update[astrY0] = shapeOptions.y0 = p2y(y0 + dy); + update[astrX1] = shapeOptions.x1 = p2x(x1 + dx); + update[astrY1] = shapeOptions.y1 = p2y(y1 + dy); + } + + shapePath.attr('d', getPathString(gd, shapeOptions)); + } + + function resizeShape(dx, dy) { + if(shapeOptions.type === 'path') { + // TODO: implement path resize + var moveX = function moveX(x) { return p2x(x2p(x) + dx); }; + if(xa && xa.type === 'date') moveX = helpers.encodeDate(moveX); + + var moveY = function moveY(y) { return p2y(y2p(y) + dy); }; + if(ya && ya.type === 'date') moveY = helpers.encodeDate(moveY); + + shapeOptions.path = movePath(pathIn, moveX, moveY); + update[astrPath] = shapeOptions.path; + } + else { + var newN = (~dragMode.indexOf('n')) ? n0 + dy : n0, + newS = (~dragMode.indexOf('s')) ? s0 + dy : s0, + newW = (~dragMode.indexOf('w')) ? w0 + dx : w0, + newE = (~dragMode.indexOf('e')) ? e0 + dx : e0; + + if(newS - newN > MINHEIGHT) { + update[astrN] = shapeOptions[optN] = p2y(newN); + update[astrS] = shapeOptions[optS] = p2y(newS); + } + + if(newE - newW > MINWIDTH) { + update[astrW] = shapeOptions[optW] = p2x(newW); + update[astrE] = shapeOptions[optE] = p2x(newE); + } + } + + shapePath.attr('d', getPathString(gd, shapeOptions)); + } +} + +function getShapeLayer(gd, index) { + var shape = gd._fullLayout.shapes[index], + shapeLayer = gd._fullLayout._shapeUpperLayer; + + if(!shape) { + Lib.log('getShapeLayer: undefined shape: index', index); + } + else if(shape.layer === 'below') { + shapeLayer = (shape.xref === 'paper' && shape.yref === 'paper') ? + gd._fullLayout._shapeLowerLayer : + gd._fullLayout._shapeSubplotLayer; + } + + return shapeLayer; +} + +function isShapeInSubplot(gd, shape, plotinfo) { + var xa = Plotly.Axes.getFromId(gd, plotinfo.id, 'x')._id, + ya = Plotly.Axes.getFromId(gd, plotinfo.id, 'y')._id, + isBelow = shape.layer === 'below', + inSuplotAxis = (xa === shape.xref || ya === shape.yref), + isNotAnOverlaidSubplot = !!plotinfo.shapelayer; + return isBelow && inSuplotAxis && isNotAnOverlaidSubplot; +} + +function getPathString(gd, options) { + var type = options.type, + xa = Axes.getFromId(gd, options.xref), + ya = Axes.getFromId(gd, options.yref), + gs = gd._fullLayout._size, + x2l, + x2p, + y2l, + y2p; + + if(xa) { + x2l = helpers.dataToLinear(xa); + x2p = function(v) { return xa._offset + xa.l2p(x2l(v, true)); }; + } + else { + x2p = function(v) { return gs.l + gs.w * v; }; + } + + if(ya) { + y2l = helpers.dataToLinear(ya); + y2p = function(v) { return ya._offset + ya.l2p(y2l(v, true)); }; + } + else { + y2p = function(v) { return gs.t + gs.h * (1 - v); }; + } + + if(type === 'path') { + if(xa && xa.type === 'date') x2p = helpers.decodeDate(x2p); + if(ya && ya.type === 'date') y2p = helpers.decodeDate(y2p); + return convertPath(options.path, x2p, y2p); + } + + var x0 = x2p(options.x0), + x1 = x2p(options.x1), + y0 = y2p(options.y0), + y1 = y2p(options.y1); + + if(type === 'line') return 'M' + x0 + ',' + y0 + 'L' + x1 + ',' + y1; + if(type === 'rect') return 'M' + x0 + ',' + y0 + 'H' + x1 + 'V' + y1 + 'H' + x0 + 'Z'; + // circle + var cx = (x0 + x1) / 2, + cy = (y0 + y1) / 2, + rx = Math.abs(cx - x0), + ry = Math.abs(cy - y0), + rArc = 'A' + rx + ',' + ry, + rightPt = (cx + rx) + ',' + cy, + topPt = cx + ',' + (cy - ry); + return 'M' + rightPt + rArc + ' 0 1,1 ' + topPt + + rArc + ' 0 0,1 ' + rightPt + 'Z'; +} + + +function convertPath(pathIn, x2p, y2p) { + // convert an SVG path string from data units to pixels + return pathIn.replace(constants.segmentRE, function(segment) { + var paramNumber = 0, + segmentType = segment.charAt(0), + xParams = constants.paramIsX[segmentType], + yParams = constants.paramIsY[segmentType], + nParams = constants.numParams[segmentType]; + + var paramString = segment.substr(1).replace(constants.paramRE, function(param) { + if(xParams[paramNumber]) param = x2p(param); + else if(yParams[paramNumber]) param = y2p(param); + paramNumber++; + + if(paramNumber > nParams) param = 'X'; + return param; + }); + + if(paramNumber > nParams) { + paramString = paramString.replace(/[\s,]*X.*/, ''); + Lib.log('Ignoring extra params in segment ' + segment); + } + + return segmentType + paramString; + }); +} + +function movePath(pathIn, moveX, moveY) { + return pathIn.replace(constants.segmentRE, function(segment) { + var paramNumber = 0, + segmentType = segment.charAt(0), + xParams = constants.paramIsX[segmentType], + yParams = constants.paramIsY[segmentType], + nParams = constants.numParams[segmentType]; + + var paramString = segment.substr(1).replace(constants.paramRE, function(param) { + if(paramNumber >= nParams) return param; + + if(xParams[paramNumber]) param = moveX(param); + else if(yParams[paramNumber]) param = moveY(param); + + paramNumber++; + + return param; + }); + + return segmentType + paramString; + }); +} diff --git a/src/components/shapes/helpers.js b/src/components/shapes/helpers.js new file mode 100644 index 00000000000..d4a5fc5fba9 --- /dev/null +++ b/src/components/shapes/helpers.js @@ -0,0 +1,79 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +// special position conversion functions... category axis positions can't be +// specified by their data values, because they don't make a continuous mapping. +// so these have to be specified in terms of the category serial numbers, +// but can take fractional values. Other axis types we specify position based on +// the actual data values. +// TODO: this should really be part of axes, but for now it's only used here. +// eventually annotations and axis ranges will use this too. +// what should we do, invent a new letter for "data except if it's category"? + +exports.dataToLinear = function(ax) { + return ax.type === 'category' ? ax.c2l : ax.d2l; +}; + +exports.linearToData = function(ax) { + return ax.type === 'category' ? ax.l2c : ax.l2d; +}; + +exports.decodeDate = function(convertToPx) { + return function(v) { + if(v.replace) v = v.replace('_', ' '); + return convertToPx(v); + }; +}; + +exports.encodeDate = function(convertToDate) { + return function(v) { return convertToDate(v).replace(' ', '_'); }; +}; + +exports.getDataToPixel = function(gd, axis, isVertical) { + var gs = gd._fullLayout._size, + dataToPixel; + + if(axis) { + var d2l = exports.dataToLinear(axis); + + dataToPixel = function(v) { + return axis._offset + axis.l2p(d2l(v, true)); + }; + + if(axis.type === 'date') dataToPixel = exports.decodeDate(dataToPixel); + } + else if(isVertical) { + dataToPixel = function(v) { return gs.t + gs.h * (1 - v); }; + } + else { + dataToPixel = function(v) { return gs.l + gs.w * v; }; + } + + return dataToPixel; +}; + +exports.getPixelToData = function(gd, axis, isVertical) { + var gs = gd._fullLayout._size, + pixelToData; + + if(axis) { + var l2d = exports.linearToData(axis); + pixelToData = function(p) { return l2d(axis.p2l(p - axis._offset)); }; + } + else if(isVertical) { + pixelToData = function(p) { return 1 - (p - gs.t) / gs.h; }; + } + else { + pixelToData = function(p) { return (p - gs.l) / gs.w; }; + } + + return pixelToData; +}; diff --git a/src/components/shapes/index.js b/src/components/shapes/index.js index ef8c9853842..6afd6de5f95 100644 --- a/src/components/shapes/index.js +++ b/src/components/shapes/index.js @@ -9,780 +9,13 @@ 'use strict'; -var isNumeric = require('fast-isnumeric'); -var Plotly = require('../../plotly'); -var Lib = require('../../lib'); -var Axes = require('../../plots/cartesian/axes'); -var Color = require('../color'); -var Drawing = require('../drawing'); +exports.layoutAttributes = require('./attributes'); -var dragElement = require('../dragelement'); -var setCursor = require('../../lib/setcursor'); +exports.supplyLayoutDefaults = require('./defaults'); -var shapes = module.exports = {}; +exports.calcAutorange = require('./calc_autorange'); -shapes.layoutAttributes = require('./attributes'); - -shapes.supplyLayoutDefaults = function(layoutIn, layoutOut) { - var containerIn = layoutIn.shapes || [], - containerOut = layoutOut.shapes = []; - - for(var i = 0; i < containerIn.length; i++) { - containerOut.push(handleShapeDefaults(containerIn[i] || {}, layoutOut)); - } -}; - -function handleShapeDefaults(shapeIn, fullLayout) { - var shapeOut = {}; - - function coerce(attr, dflt) { - return Lib.coerce(shapeIn, shapeOut, shapes.layoutAttributes, attr, dflt); - } - - coerce('layer'); - coerce('opacity'); - coerce('fillcolor'); - coerce('line.color'); - coerce('line.width'); - coerce('line.dash'); - var dfltType = shapeIn.path ? 'path' : 'rect', - shapeType = coerce('type', dfltType); - - // positioning - var axLetters = ['x', 'y']; - for(var i = 0; i < 2; i++) { - var axLetter = axLetters[i], - tdMock = {_fullLayout: fullLayout}; - - // xref, yref - var axRef = Axes.coerceRef(shapeIn, shapeOut, tdMock, axLetter); - - if(shapeType !== 'path') { - var dflt0 = 0.25, - dflt1 = 0.75; - if(axRef !== 'paper') { - var ax = Axes.getFromId(tdMock, axRef), - convertFn = linearToData(ax); - dflt0 = convertFn(ax.range[0] + dflt0 * (ax.range[1] - ax.range[0])); - dflt1 = convertFn(ax.range[0] + dflt1 * (ax.range[1] - ax.range[0])); - } - // x0, x1 (and y0, y1) - coerce(axLetter + '0', dflt0); - coerce(axLetter + '1', dflt1); - } - } - - if(shapeType === 'path') { - coerce('path'); - } else { - Lib.noneOrAll(shapeIn, shapeOut, ['x0', 'x1', 'y0', 'y1']); - } - - return shapeOut; -} - -// special position conversion functions... category axis positions can't be -// specified by their data values, because they don't make a continuous mapping. -// so these have to be specified in terms of the category serial numbers, -// but can take fractional values. Other axis types we specify position based on -// the actual data values. -// TODO: this should really be part of axes, but for now it's only used here. -// eventually annotations and axis ranges will use this too. -// what should we do, invent a new letter for "data except if it's category"? -function dataToLinear(ax) { return ax.type === 'category' ? ax.c2l : ax.d2l; } - -function linearToData(ax) { return ax.type === 'category' ? ax.l2c : ax.l2d; } - -shapes.drawAll = function(gd) { - var fullLayout = gd._fullLayout; - - // Remove previous shapes before drawing new in shapes in fullLayout.shapes - fullLayout._shapeUpperLayer.selectAll('path').remove(); - fullLayout._shapeLowerLayer.selectAll('path').remove(); - fullLayout._shapeSubplotLayer.selectAll('path').remove(); - - for(var i = 0; i < fullLayout.shapes.length; i++) { - shapes.draw(gd, i); - } - // may need to resurrect this if we put text (LaTeX) in shapes - // return Plotly.Plots.previousPromises(gd); -}; - -shapes.add = function(gd) { - var nextShape = gd._fullLayout.shapes.length; - Plotly.relayout(gd, 'shapes[' + nextShape + ']', 'add'); -}; - -// ----------------------------------------------------- -// make or edit an annotation on the graph -// ----------------------------------------------------- - -// shapes are stored in gd.layout.shapes, an array of objects -// index can point to one item in this array, -// or non-numeric to simply add a new one -// or -1 to modify all existing -// opt can be the full options object, or one key (to be set to value) -// or undefined to simply redraw -// if opt is blank, val can be 'add' or a full options object to add a new -// annotation at that point in the array, or 'remove' to delete this one -shapes.draw = function(gd, index, opt, value) { - if(!isNumeric(index) || index === -1) { - // no index provided - we're operating on ALL shapes - if(!index && Array.isArray(value)) { - replaceAllShapes(gd, value); - return; - } - else if(value === 'remove') { - deleteAllShapes(gd); - return; - } - else if(opt && value !== 'add') { - updateAllShapes(gd, opt, value); - return; - } - else { - // add a new empty annotation - index = gd._fullLayout.shapes.length; - gd._fullLayout.shapes.push({}); - } - } - - if(!opt && value) { - if(value === 'remove') { - deleteShape(gd, index); - return; - } - else if(value === 'add' || Lib.isPlainObject(value)) { - insertShape(gd, index, value); - } - } - - updateShape(gd, index, opt, value); -}; - -function replaceAllShapes(gd, newShapes) { - gd.layout.shapes = newShapes; - shapes.supplyLayoutDefaults(gd.layout, gd._fullLayout); - shapes.drawAll(gd); -} - -function deleteAllShapes(gd) { - delete gd.layout.shapes; - gd._fullLayout.shapes = []; - shapes.drawAll(gd); -} - -function updateAllShapes(gd, opt, value) { - for(var i = 0; i < gd._fullLayout.shapes.length; i++) { - shapes.draw(gd, i, opt, value); - } -} - -function deleteShape(gd, index) { - getShapeLayer(gd, index) - .selectAll('[data-index="' + index + '"]') - .remove(); - - gd._fullLayout.shapes.splice(index, 1); - - gd.layout.shapes.splice(index, 1); - - for(var i = index; i < gd._fullLayout.shapes.length; i++) { - // redraw all shapes past the removed one, - // so they bind to the right events - getShapeLayer(gd, i) - .selectAll('[data-index="' + (i + 1) + '"]') - .attr('data-index', i); - shapes.draw(gd, i); - } -} - -function insertShape(gd, index, newShape) { - gd._fullLayout.shapes.splice(index, 0, {}); - - var rule = Lib.isPlainObject(newShape) ? - Lib.extendFlat({}, newShape) : - {text: 'New text'}; - - if(gd.layout.shapes) { - gd.layout.shapes.splice(index, 0, rule); - } else { - gd.layout.shapes = [rule]; - } - - // there is no need to call shapes.draw(gd, index), - // because updateShape() is called from within shapes.draw() - - for(var i = gd._fullLayout.shapes.length - 1; i > index; i--) { - getShapeLayer(gd, i) - .selectAll('[data-index="' + (i - 1) + '"]') - .attr('data-index', i); - shapes.draw(gd, i); - } -} - -function updateShape(gd, index, opt, value) { - var i, n; - - // remove the existing shape if there is one - getShapeLayer(gd, index) - .selectAll('[data-index="' + index + '"]') - .remove(); - - // remember a few things about what was already there, - var optionsIn = gd.layout.shapes[index]; - - // (from annos...) not sure how we're getting here... but C12 is seeing a bug - // where we fail here when they add/remove annotations - // TODO: clean this up and remove it. - if(!optionsIn) return; - - var oldRef = {xref: optionsIn.xref, yref: optionsIn.yref}; - - // alter the input shape as requested - var optionsEdit = {}; - if(typeof opt === 'string' && opt) optionsEdit[opt] = value; - else if(Lib.isPlainObject(opt)) optionsEdit = opt; - - var optionKeys = Object.keys(optionsEdit); - for(i = 0; i < optionKeys.length; i++) { - var k = optionKeys[i]; - Lib.nestedProperty(optionsIn, k).set(optionsEdit[k]); - } - - var posAttrs = ['x0', 'x1', 'y0', 'y1']; - for(i = 0; i < 4; i++) { - var posAttr = posAttrs[i]; - // if we don't have an explicit position already, - // don't set one just because we're changing references - // or axis type. - // the defaults will be consistent most of the time anyway, - // except in log/linear changes - if(optionsEdit[posAttr] !== undefined || - optionsIn[posAttr] === undefined) { - continue; - } - - var axLetter = posAttr.charAt(0), - axOld = Axes.getFromId(gd, - Axes.coerceRef(oldRef, {}, gd, axLetter)), - axNew = Axes.getFromId(gd, - Axes.coerceRef(optionsIn, {}, gd, axLetter)), - position = optionsIn[posAttr], - linearizedPosition; - - if(optionsEdit[axLetter + 'ref'] !== undefined) { - // first convert to fraction of the axis - if(axOld) { - linearizedPosition = dataToLinear(axOld)(position); - position = (linearizedPosition - axOld.range[0]) / - (axOld.range[1] - axOld.range[0]); - } else { - position = (position - axNew.domain[0]) / - (axNew.domain[1] - axNew.domain[0]); - } - - if(axNew) { - // then convert to new data coordinates at the same fraction - linearizedPosition = axNew.range[0] + position * - (axNew.range[1] - axNew.range[0]); - position = linearToData(axNew)(linearizedPosition); - } else { - // or scale to the whole plot - position = axOld.domain[0] + - position * (axOld.domain[1] - axOld.domain[0]); - } - } - - optionsIn[posAttr] = position; - } - - var options = handleShapeDefaults(optionsIn, gd._fullLayout); - gd._fullLayout.shapes[index] = options; - - var clipAxes; - if(options.layer !== 'below') { - clipAxes = (options.xref + options.yref).replace(/paper/g, ''); - drawShape(gd._fullLayout._shapeUpperLayer); - } - else if(options.xref === 'paper' && options.yref === 'paper') { - clipAxes = ''; - drawShape(gd._fullLayout._shapeLowerLayer); - } - else { - var plots = gd._fullLayout._plots || {}, - subplots = Object.keys(plots), - plotinfo; - - for(i = 0, n = subplots.length; i < n; i++) { - plotinfo = plots[subplots[i]]; - clipAxes = subplots[i]; - - if(isShapeInSubplot(gd, options, plotinfo)) { - drawShape(plotinfo.shapelayer); - } - } - } - - function drawShape(shapeLayer) { - var attrs = { - 'data-index': index, - 'fill-rule': 'evenodd', - d: getPathString(gd, options) - }, - lineColor = options.line.width ? - options.line.color : 'rgba(0,0,0,0)'; - - var path = shapeLayer.append('path') - .attr(attrs) - .style('opacity', options.opacity) - .call(Color.stroke, lineColor) - .call(Color.fill, options.fillcolor) - .call(Drawing.dashLine, options.line.dash, options.line.width); - - if(clipAxes) { - path.call(Drawing.setClipUrl, - 'clip' + gd._fullLayout._uid + clipAxes); - } - - if(gd._context.editable) setupDragElement(gd, path, options, index); - } -} - -function setupDragElement(gd, shapePath, shapeOptions, index) { - var MINWIDTH = 10, - MINHEIGHT = 10; - - var update; - var x0, y0, x1, y1, astrX0, astrY0, astrX1, astrY1; - var n0, s0, w0, e0, astrN, astrS, astrW, astrE, optN, optS, optW, optE; - var pathIn, astrPath; - - var xa, ya, x2p, y2p, p2x, p2y; - - var dragOptions = { - setCursor: updateDragMode, - element: shapePath.node(), - prepFn: startDrag, - doneFn: endDrag - }, - dragBBox = dragOptions.element.getBoundingClientRect(), - dragMode; - - dragElement.init(dragOptions); - - function updateDragMode(evt) { - // 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 startDrag(evt) { - // setup conversion functions - xa = Axes.getFromId(gd, shapeOptions.xref); - ya = Axes.getFromId(gd, shapeOptions.yref); - - x2p = getDataToPixel(gd, xa); - y2p = getDataToPixel(gd, ya, true); - p2x = getPixelToData(gd, xa); - p2y = getPixelToData(gd, ya, true); - - // setup update strings and initial values - var astr = 'shapes[' + index + ']'; - if(shapeOptions.type === 'path') { - pathIn = shapeOptions.path; - astrPath = astr + '.path'; - } - else { - x0 = x2p(shapeOptions.x0); - y0 = y2p(shapeOptions.y0); - x1 = x2p(shapeOptions.x1); - y1 = y2p(shapeOptions.y1); - - astrX0 = astr + '.x0'; - astrY0 = astr + '.y0'; - astrX1 = astr + '.x1'; - astrY1 = astr + '.y1'; - } - - if(x0 < x1) { - w0 = x0; astrW = astr + '.x0'; optW = 'x0'; - e0 = x1; astrE = astr + '.x1'; optE = 'x1'; - } - else { - w0 = x1; astrW = astr + '.x1'; optW = 'x1'; - e0 = x0; astrE = astr + '.x0'; optE = 'x0'; - } - if(y0 < y1) { - n0 = y0; astrN = astr + '.y0'; optN = 'y0'; - s0 = y1; astrS = astr + '.y1'; optS = 'y1'; - } - else { - n0 = y1; astrN = astr + '.y1'; optN = 'y1'; - s0 = y0; astrS = astr + '.y0'; optS = 'y0'; - } - - update = {}; - - // setup dragMode and the corresponding handler - updateDragMode(evt); - dragOptions.moveFn = (dragMode === 'move') ? moveShape : resizeShape; - } - - function endDrag(dragged) { - setCursor(shapePath); - if(dragged) { - Plotly.relayout(gd, update); - } - } - - function moveShape(dx, dy) { - if(shapeOptions.type === 'path') { - var moveX = function moveX(x) { return p2x(x2p(x) + dx); }; - if(xa && xa.type === 'date') moveX = encodeDate(moveX); - - var moveY = function moveY(y) { return p2y(y2p(y) + dy); }; - if(ya && ya.type === 'date') moveY = encodeDate(moveY); - - shapeOptions.path = movePath(pathIn, moveX, moveY); - update[astrPath] = shapeOptions.path; - } - else { - update[astrX0] = shapeOptions.x0 = p2x(x0 + dx); - update[astrY0] = shapeOptions.y0 = p2y(y0 + dy); - update[astrX1] = shapeOptions.x1 = p2x(x1 + dx); - update[astrY1] = shapeOptions.y1 = p2y(y1 + dy); - } - - shapePath.attr('d', getPathString(gd, shapeOptions)); - } - - function resizeShape(dx, dy) { - if(shapeOptions.type === 'path') { - // TODO: implement path resize - var moveX = function moveX(x) { return p2x(x2p(x) + dx); }; - if(xa && xa.type === 'date') moveX = encodeDate(moveX); - - var moveY = function moveY(y) { return p2y(y2p(y) + dy); }; - if(ya && ya.type === 'date') moveY = encodeDate(moveY); - - shapeOptions.path = movePath(pathIn, moveX, moveY); - update[astrPath] = shapeOptions.path; - } - else { - var newN = (~dragMode.indexOf('n')) ? n0 + dy : n0, - newS = (~dragMode.indexOf('s')) ? s0 + dy : s0, - newW = (~dragMode.indexOf('w')) ? w0 + dx : w0, - newE = (~dragMode.indexOf('e')) ? e0 + dx : e0; - - if(newS - newN > MINHEIGHT) { - update[astrN] = shapeOptions[optN] = p2y(newN); - update[astrS] = shapeOptions[optS] = p2y(newS); - } - - if(newE - newW > MINWIDTH) { - update[astrW] = shapeOptions[optW] = p2x(newW); - update[astrE] = shapeOptions[optE] = p2x(newE); - } - } - - shapePath.attr('d', getPathString(gd, shapeOptions)); - } -} - -function getShapeLayer(gd, index) { - var shape = gd._fullLayout.shapes[index], - shapeLayer = gd._fullLayout._shapeUpperLayer; - - if(!shape) { - Lib.log('getShapeLayer: undefined shape: index', index); - } - else if(shape.layer === 'below') { - shapeLayer = (shape.xref === 'paper' && shape.yref === 'paper') ? - gd._fullLayout._shapeLowerLayer : - gd._fullLayout._shapeSubplotLayer; - } - - return shapeLayer; -} - -function isShapeInSubplot(gd, shape, plotinfo) { - var xa = Plotly.Axes.getFromId(gd, plotinfo.id, 'x')._id, - ya = Plotly.Axes.getFromId(gd, plotinfo.id, 'y')._id, - isBelow = shape.layer === 'below', - inSuplotAxis = (xa === shape.xref || ya === shape.yref), - isNotAnOverlaidSubplot = !!plotinfo.shapelayer; - return isBelow && inSuplotAxis && isNotAnOverlaidSubplot; -} - -function decodeDate(convertToPx) { - return function(v) { - if(v.replace) v = v.replace('_', ' '); - return convertToPx(v); - }; -} - -function encodeDate(convertToDate) { - return function(v) { return convertToDate(v).replace(' ', '_'); }; -} - -function getDataToPixel(gd, axis, isVertical) { - var gs = gd._fullLayout._size, - dataToPixel; - - if(axis) { - var d2l = dataToLinear(axis); - - dataToPixel = function(v) { - return axis._offset + axis.l2p(d2l(v, true)); - }; - - if(axis.type === 'date') dataToPixel = decodeDate(dataToPixel); - } - else if(isVertical) { - dataToPixel = function(v) { return gs.t + gs.h * (1 - v); }; - } - else { - dataToPixel = function(v) { return gs.l + gs.w * v; }; - } - - return dataToPixel; -} - -function getPixelToData(gd, axis, isVertical) { - var gs = gd._fullLayout._size, - pixelToData; - - if(axis) { - var l2d = linearToData(axis); - pixelToData = function(p) { return l2d(axis.p2l(p - axis._offset)); }; - } - else if(isVertical) { - pixelToData = function(p) { return 1 - (p - gs.t) / gs.h; }; - } - else { - pixelToData = function(p) { return (p - gs.l) / gs.w; }; - } - - return pixelToData; -} - -function getPathString(gd, options) { - var type = options.type, - xa = Axes.getFromId(gd, options.xref), - ya = Axes.getFromId(gd, options.yref), - gs = gd._fullLayout._size, - x2l, - x2p, - y2l, - y2p; - - if(xa) { - x2l = dataToLinear(xa); - x2p = function(v) { return xa._offset + xa.l2p(x2l(v, true)); }; - } - else { - x2p = function(v) { return gs.l + gs.w * v; }; - } - - if(ya) { - y2l = dataToLinear(ya); - y2p = function(v) { return ya._offset + ya.l2p(y2l(v, true)); }; - } - else { - y2p = function(v) { return gs.t + gs.h * (1 - v); }; - } - - if(type === 'path') { - if(xa && xa.type === 'date') x2p = decodeDate(x2p); - if(ya && ya.type === 'date') y2p = decodeDate(y2p); - return shapes.convertPath(options.path, x2p, y2p); - } - - var x0 = x2p(options.x0), - x1 = x2p(options.x1), - y0 = y2p(options.y0), - y1 = y2p(options.y1); - - if(type === 'line') return 'M' + x0 + ',' + y0 + 'L' + x1 + ',' + y1; - if(type === 'rect') return 'M' + x0 + ',' + y0 + 'H' + x1 + 'V' + y1 + 'H' + x0 + 'Z'; - // circle - var cx = (x0 + x1) / 2, - cy = (y0 + y1) / 2, - rx = Math.abs(cx - x0), - ry = Math.abs(cy - y0), - rArc = 'A' + rx + ',' + ry, - rightPt = (cx + rx) + ',' + cy, - topPt = cx + ',' + (cy - ry); - return 'M' + rightPt + rArc + ' 0 1,1 ' + topPt + - rArc + ' 0 0,1 ' + rightPt + 'Z'; -} - -var segmentRE = /[MLHVQCTSZ][^MLHVQCTSZ]*/g, - paramRE = /[^\s,]+/g, - - // which numbers in each path segment are x (or y) values - // drawn is which param is a drawn point, as opposed to a - // control point (which doesn't count toward autorange. - // TODO: this means curved paths could extend beyond the - // autorange bounds. This is a bit tricky to get right - // unless we revert to bounding boxes, but perhaps there's - // a calculation we could do...) - paramIsX = { - M: {0: true, drawn: 0}, - L: {0: true, drawn: 0}, - H: {0: true, drawn: 0}, - V: {}, - Q: {0: true, 2: true, drawn: 2}, - C: {0: true, 2: true, 4: true, drawn: 4}, - T: {0: true, drawn: 0}, - S: {0: true, 2: true, drawn: 2}, - // A: {0: true, 5: true}, - Z: {} - }, - - paramIsY = { - M: {1: true, drawn: 1}, - L: {1: true, drawn: 1}, - H: {}, - V: {0: true, drawn: 0}, - Q: {1: true, 3: true, drawn: 3}, - C: {1: true, 3: true, 5: true, drawn: 5}, - T: {1: true, drawn: 1}, - S: {1: true, 3: true, drawn: 5}, - // A: {1: true, 6: true}, - Z: {} - }, - numParams = { - M: 2, - L: 2, - H: 1, - V: 1, - Q: 4, - C: 6, - T: 2, - S: 4, - // A: 7, - Z: 0 - }; - -shapes.convertPath = function(pathIn, x2p, y2p) { - // convert an SVG path string from data units to pixels - return pathIn.replace(segmentRE, function(segment) { - var paramNumber = 0, - segmentType = segment.charAt(0), - xParams = paramIsX[segmentType], - yParams = paramIsY[segmentType], - nParams = numParams[segmentType]; - - var paramString = segment.substr(1).replace(paramRE, function(param) { - if(xParams[paramNumber]) param = x2p(param); - else if(yParams[paramNumber]) param = y2p(param); - paramNumber++; - - if(paramNumber > nParams) param = 'X'; - return param; - }); - - if(paramNumber > nParams) { - paramString = paramString.replace(/[\s,]*X.*/, ''); - Lib.log('Ignoring extra params in segment ' + segment); - } - - return segmentType + paramString; - }); -}; - -function movePath(pathIn, moveX, moveY) { - return pathIn.replace(segmentRE, function(segment) { - var paramNumber = 0, - segmentType = segment.charAt(0), - xParams = paramIsX[segmentType], - yParams = paramIsY[segmentType], - nParams = numParams[segmentType]; - - var paramString = segment.substr(1).replace(paramRE, function(param) { - if(paramNumber >= nParams) return param; - - if(xParams[paramNumber]) param = moveX(param); - else if(yParams[paramNumber]) param = moveY(param); - - paramNumber++; - - return param; - }); - - return segmentType + paramString; - }); -} - -shapes.calcAutorange = function(gd) { - var fullLayout = gd._fullLayout, - shapeList = fullLayout.shapes, - i, - shape, - ppad, - ax, - bounds; - - if(!shapeList.length || !gd._fullData.length) return; - - for(i = 0; i < shapeList.length; i++) { - shape = shapeList[i]; - ppad = shape.line.width / 2; - if(shape.xref !== 'paper') { - ax = Axes.getFromId(gd, shape.xref); - bounds = shapeBounds(ax, shape.x0, shape.x1, shape.path, paramIsX); - if(bounds) Axes.expand(ax, bounds, {ppad: ppad}); - } - if(shape.yref !== 'paper') { - ax = Axes.getFromId(gd, shape.yref); - bounds = shapeBounds(ax, shape.y0, shape.y1, shape.path, paramIsY); - if(bounds) Axes.expand(ax, bounds, {ppad: ppad}); - } - } -}; - -function shapeBounds(ax, v0, v1, path, paramsToUse) { - var convertVal = (ax.type === 'category') ? Number : ax.d2c; - - if(v0 !== undefined) return [convertVal(v0), convertVal(v1)]; - if(!path) return; - - var min = Infinity, - max = -Infinity, - segments = path.match(segmentRE), - i, - segment, - drawnParam, - params, - val; - - if(ax.type === 'date') convertVal = decodeDate(convertVal); - - for(i = 0; i < segments.length; i++) { - segment = segments[i]; - drawnParam = paramsToUse[segment.charAt(0)].drawn; - if(drawnParam === undefined) continue; - - params = segments[i].substr(1).match(paramRE); - if(!params || params.length < drawnParam) continue; - - val = convertVal(params[drawnParam]); - if(val < min) min = val; - if(val > max) max = val; - } - if(max >= min) return [min, max]; -} +var drawModule = require('./draw'); +exports.draw = drawModule.draw; +exports.drawOne = drawModule.drawOne; diff --git a/src/components/shapes/shape_defaults.js b/src/components/shapes/shape_defaults.js new file mode 100644 index 00000000000..88e4c98ba93 --- /dev/null +++ b/src/components/shapes/shape_defaults.js @@ -0,0 +1,69 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Lib = require('../../lib'); +var Axes = require('../../plots/cartesian/axes'); + +var attributes = require('./attributes'); +var helpers = require('./helpers'); + +module.exports = function handleShapeDefaults(shapeIn, fullLayout) { + var shapeOut = {}; + + function coerce(attr, dflt) { + return Lib.coerce(shapeIn, shapeOut, attributes, attr, dflt); + } + + coerce('layer'); + coerce('opacity'); + coerce('fillcolor'); + coerce('line.color'); + coerce('line.width'); + coerce('line.dash'); + + var dfltType = shapeIn.path ? 'path' : 'rect', + shapeType = coerce('type', dfltType); + + // positioning + var axLetters = ['x', 'y']; + for(var i = 0; i < 2; i++) { + var axLetter = axLetters[i], + tdMock = {_fullLayout: fullLayout}; + + // xref, yref + var axRef = Axes.coerceRef(shapeIn, shapeOut, tdMock, axLetter); + + if(shapeType !== 'path') { + var dflt0 = 0.25, + dflt1 = 0.75; + + if(axRef !== 'paper') { + var ax = Axes.getFromId(tdMock, axRef), + convertFn = helpers.linearToData(ax); + + dflt0 = convertFn(ax.range[0] + dflt0 * (ax.range[1] - ax.range[0])); + dflt1 = convertFn(ax.range[0] + dflt1 * (ax.range[1] - ax.range[0])); + } + + // x0, x1 (and y0, y1) + coerce(axLetter + '0', dflt0); + coerce(axLetter + '1', dflt1); + } + } + + if(shapeType === 'path') { + coerce('path'); + } else { + Lib.noneOrAll(shapeIn, shapeOut, ['x0', 'x1', 'y0', 'y1']); + } + + return shapeOut; +}; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 580161d0c31..d724d3790ab 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -284,7 +284,7 @@ Plotly.plot = function(gd, data, layout, config) { Plots.style(gd); // show annotations and shapes - Shapes.drawAll(gd); + Shapes.draw(gd); Annotations.draw(gd); // source links @@ -300,7 +300,7 @@ Plotly.plot = function(gd, data, layout, config) { // correctly sized and the whole plot re-margined. gd._replotting must // be set to false before these will work properly. function finalDraw() { - Shapes.drawAll(gd); + Shapes.draw(gd); Images.draw(gd); Annotations.draw(gd); Legend.draw(gd); From 2b632e1009b5a8642fac45b2ab6e9950dc5b70a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 8 Aug 2016 14:36:57 -0400 Subject: [PATCH 3/5] dry up shape test suite --- test/jasmine/tests/shapes_test.js | 112 ++++-------------------------- 1 file changed, 15 insertions(+), 97 deletions(-) diff --git a/test/jasmine/tests/shapes_test.js b/test/jasmine/tests/shapes_test.js index b02398852cd..b2058b03bc4 100644 --- a/test/jasmine/tests/shapes_test.js +++ b/test/jasmine/tests/shapes_test.js @@ -1,11 +1,12 @@ -var d3 = require('d3'); +var helpers = require('@src/components/shapes/helpers'); +var constants = require('@src/components/shapes/constants'); var Plotly = require('@lib/index'); -var Lib = require('@src/lib'); - var PlotlyInternal = require('@src/plotly'); +var Lib = require('@src/lib'); var Axes = PlotlyInternal.Axes; +var d3 = require('d3'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); @@ -476,8 +477,8 @@ describe('Test shapes', function() { function testShapeDrag(dx, dy, layoutShape, node) { var xa = Axes.getFromId(gd, layoutShape.xref), ya = Axes.getFromId(gd, layoutShape.yref), - x2p = getDataToPixel(gd, xa), - y2p = getDataToPixel(gd, ya, true); + x2p = helpers.getDataToPixel(gd, xa), + y2p = helpers.getDataToPixel(gd, ya, true); var initialCoordinates = getShapeCoordinates(layoutShape, x2p, y2p); @@ -503,8 +504,8 @@ describe('Test shapes', function() { function testPathDrag(dx, dy, layoutShape, node) { var xa = Axes.getFromId(gd, layoutShape.xref), ya = Axes.getFromId(gd, layoutShape.yref), - x2p = getDataToPixel(gd, xa), - y2p = getDataToPixel(gd, ya, true); + x2p = helpers.getDataToPixel(gd, xa), + y2p = helpers.getDataToPixel(gd, ya, true); var initialPath = layoutShape.path, initialCoordinates = getPathCoordinates(initialPath, x2p, y2p); @@ -536,8 +537,8 @@ describe('Test shapes', function() { function testShapeResize(direction, dx, dy, layoutShape, node) { var xa = Axes.getFromId(gd, layoutShape.xref), ya = Axes.getFromId(gd, layoutShape.yref), - x2p = getDataToPixel(gd, xa), - y2p = getDataToPixel(gd, ya, true); + x2p = helpers.getDataToPixel(gd, xa), + y2p = helpers.getDataToPixel(gd, ya, true); var initialCoordinates = getShapeCoordinates(layoutShape, x2p, y2p); @@ -578,65 +579,16 @@ describe('Test shapes', function() { }); } - // Adapted from src/components/shapes/index.js - var segmentRE = /[MLHVQCTSZ][^MLHVQCTSZ]*/g, - paramRE = /[^\s,]+/g, - - // which numbers in each path segment are x (or y) values - // drawn is which param is a drawn point, as opposed to a - // control point (which doesn't count toward autorange. - // TODO: this means curved paths could extend beyond the - // autorange bounds. This is a bit tricky to get right - // unless we revert to bounding boxes, but perhaps there's - // a calculation we could do...) - paramIsX = { - M: {0: true, drawn: 0}, - L: {0: true, drawn: 0}, - H: {0: true, drawn: 0}, - V: {}, - Q: {0: true, 2: true, drawn: 2}, - C: {0: true, 2: true, 4: true, drawn: 4}, - T: {0: true, drawn: 0}, - S: {0: true, 2: true, drawn: 2}, - // A: {0: true, 5: true}, - Z: {} - }, - - paramIsY = { - M: {1: true, drawn: 1}, - L: {1: true, drawn: 1}, - H: {}, - V: {0: true, drawn: 0}, - Q: {1: true, 3: true, drawn: 3}, - C: {1: true, 3: true, 5: true, drawn: 5}, - T: {1: true, drawn: 1}, - S: {1: true, 3: true, drawn: 5}, - // A: {1: true, 6: true}, - Z: {} - }, - numParams = { - M: 2, - L: 2, - H: 1, - V: 1, - Q: 4, - C: 6, - T: 2, - S: 4, - // A: 7, - Z: 0 - }; - function getPathCoordinates(pathString, x2p, y2p) { var coordinates = []; - pathString.match(segmentRE).forEach(function(segment) { + pathString.match(constants.segmentRE).forEach(function(segment) { var paramNumber = 0, segmentType = segment.charAt(0), - xParams = paramIsX[segmentType], - yParams = paramIsY[segmentType], - nParams = numParams[segmentType], - params = segment.substr(1).match(paramRE); + xParams = constants.paramIsX[segmentType], + yParams = constants.paramIsY[segmentType], + nParams = constants.numParams[segmentType], + params = segment.substr(1).match(constants.paramRE); if(params) { params.forEach(function(param) { @@ -658,40 +610,6 @@ describe('Test shapes', function() { } }); - -// getDataToPixel and decodeDate -// adapted from src/components/shapes.index.js -function getDataToPixel(gd, axis, isVertical) { - var gs = gd._fullLayout._size, - dataToPixel; - - if(axis) { - var d2l = axis.type === 'category' ? axis.c2l : axis.d2l; - - dataToPixel = function(v) { - return axis._offset + axis.l2p(d2l(v, true)); - }; - - if(axis.type === 'date') dataToPixel = decodeDate(dataToPixel); - } - else if(isVertical) { - dataToPixel = function(v) { return gs.t + gs.h * (1 - v); }; - } - else { - dataToPixel = function(v) { return gs.l + gs.w * v; }; - } - - return dataToPixel; -} - -function decodeDate(convertToPx) { - return function(v) { - if(v.replace) v = v.replace('_', ' '); - return convertToPx(v); - }; -} - - var DBLCLICKDELAY = require('@src/plots/cartesian/constants').DBLCLICKDELAY; function mouseDown(node, x, y) { From 050390114bce7cef7f5077f23347440b60f9e6b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 8 Aug 2016 14:37:12 -0400 Subject: [PATCH 4/5] test: add relayout tests for annotations --- test/jasmine/tests/annotations_test.js | 53 +++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/test/jasmine/tests/annotations_test.js b/test/jasmine/tests/annotations_test.js index 3b6d02a75e6..2da994bf5f7 100644 --- a/test/jasmine/tests/annotations_test.js +++ b/test/jasmine/tests/annotations_test.js @@ -1,8 +1,14 @@ -require('@src/plotly'); -var Plots = require('@src/plots/plots'); var Annotations = require('@src/components/annotations'); + +var Plotly = require('@lib/index'); +var Plots = require('@src/plots/plots'); +var Lib = require('@src/lib'); var Dates = require('@src/lib/dates'); +var d3 = require('d3'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); + describe('Test annotations', function() { 'use strict'; @@ -31,3 +37,46 @@ describe('Test annotations', function() { }); }); }); + +describe('annotations relayout', function() { + 'use strict'; + + var mock = require('@mocks/annotations.json'); + var len = mock.layout.annotations.length; + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + + var mockData = Lib.extendDeep([], mock.data), + mockLayout = Lib.extendDeep({}, mock.layout); + + Plotly.plot(gd, mockData, mockLayout).then(done); + }); + + afterEach(destroyGraphDiv); + + function countAnnotations() { + return d3.selectAll('g.annotation').size(); + } + + it('should be able to add /remove annotations', function(done) { + expect(countAnnotations()).toEqual(len); + + var ann = { text: '' }; + + Plotly.relayout(gd, 'annotations[' + len + ']', ann).then(function() { + expect(countAnnotations()).toEqual(len + 1); + + return Plotly.relayout(gd, 'annotations[' + 0 + ']', 'remove'); + }).then(function() { + expect(countAnnotations()).toEqual(len); + + return Plotly.relayout(gd, { annotations: [] }); + }).then(function() { + expect(countAnnotations()).toEqual(0); + + done(); + }); + }); +}); From a5dbf445e99362b04dad193e131a052eec0a3eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Tue, 9 Aug 2016 17:34:13 -0400 Subject: [PATCH 5/5] sub Plotly[module]. --> [module]. --- src/components/shapes/draw.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/shapes/draw.js b/src/components/shapes/draw.js index 1bc0a165216..7b5e67d99b7 100644 --- a/src/components/shapes/draw.js +++ b/src/components/shapes/draw.js @@ -53,7 +53,7 @@ function draw(gd) { } // may need to resurrect this if we put text (LaTeX) in shapes - // return Plotly.Plots.previousPromises(gd); + // return Plots.previousPromises(gd); } function drawOne(gd, index, opt, value) { @@ -450,8 +450,8 @@ function getShapeLayer(gd, index) { } function isShapeInSubplot(gd, shape, plotinfo) { - var xa = Plotly.Axes.getFromId(gd, plotinfo.id, 'x')._id, - ya = Plotly.Axes.getFromId(gd, plotinfo.id, 'y')._id, + var xa = Axes.getFromId(gd, plotinfo.id, 'x')._id, + ya = Axes.getFromId(gd, plotinfo.id, 'y')._id, isBelow = shape.layer === 'below', inSuplotAxis = (xa === shape.xref || ya === shape.yref), isNotAnOverlaidSubplot = !!plotinfo.shapelayer;