'use strict'; var d3 = require('@plotly/d3'); var Registry = require('../../registry'); var Plots = require('../../plots/plots'); var Lib = require('../../lib'); var strTranslate = Lib.strTranslate; var Axes = require('../../plots/cartesian/axes'); var Color = require('../color'); var Drawing = require('../drawing'); var Fx = require('../fx'); var svgTextUtils = require('../../lib/svg_text_utils'); var setCursor = require('../../lib/setcursor'); var dragElement = require('../dragelement'); var arrayEditor = require('../../plot_api/plot_template').arrayEditor; var drawArrowHead = 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, drawRaw: drawRaw }; /* * draw: draw all annotations without any new modifications */ function draw(gd) { var fullLayout = gd._fullLayout; fullLayout._infolayer.selectAll('.annotation').remove(); for(var i = 0; i < fullLayout.annotations.length; i++) { if(fullLayout.annotations[i].visible) { drawOne(gd, i); } } return Plots.previousPromises(gd); } /* * drawOne: draw a single cartesian or paper-ref annotation, potentially with modifications * * index (int): the annotation to draw */ function drawOne(gd, index) { var fullLayout = gd._fullLayout; var options = fullLayout.annotations[index] || {}; var xa = Axes.getFromId(gd, options.xref); var ya = Axes.getFromId(gd, options.yref); if(xa) xa.setScale(); if(ya) ya.setScale(); drawRaw(gd, options, index, false, xa, ya); } // Convert pixels to the coordinates relevant for the axis referred to. For // example, for paper it would convert to a value normalized by the dimension of // the plot. // axDomainRef: if true and axa defined, draws relative to axis domain, // otherwise draws relative to data (if axa defined) or paper (if not). function shiftPosition(axa, dAx, axLetter, gs, options) { var optAx = options[axLetter]; var axRef = options[axLetter + 'ref']; var vertical = axLetter.indexOf('y') !== -1; var axDomainRef = Axes.getRefType(axRef) === 'domain'; var gsDim = vertical ? gs.h : gs.w; if(axa) { if(axDomainRef) { // here optAx normalized to length of axis (e.g., normally in range // 0 to 1). But dAx is in pixels. So we normalize dAx to length of // axis before doing the math. return optAx + (vertical ? -dAx : dAx) / axa._length; } else { return axa.p2r(axa.r2p(optAx) + dAx); } } else { return optAx + (vertical ? -dAx : dAx) / gsDim; } } /** * drawRaw: draw a single annotation, potentially with modifications * * @param {DOM element} gd * @param {object} options : this annotation's fullLayout options * @param {integer} index : index in 'annotations' container of the annotation to draw * @param {string} subplotId : id of the annotation's subplot * - use false for 2d (i.e. cartesian or paper-ref) annotations * @param {object | undefined} xa : full x-axis object to compute subplot pos-to-px * @param {object | undefined} ya : ... y-axis */ function drawRaw(gd, options, index, subplotId, xa, ya) { var fullLayout = gd._fullLayout; var gs = gd._fullLayout._size; var edits = gd._context.edits; var className, containerStr; if(subplotId) { className = 'annotation-' + subplotId; containerStr = subplotId + '.annotations'; } else { className = 'annotation'; containerStr = 'annotations'; } var editHelpers = arrayEditor(gd.layout, containerStr, options); var modifyBase = editHelpers.modifyBase; var modifyItem = editHelpers.modifyItem; var getUpdateObj = editHelpers.getUpdateObj; // remove the existing annotation if there is one fullLayout._infolayer .selectAll('.' + className + '[data-index="' + index + '"]') .remove(); var annClipID = 'clip' + fullLayout._uid + '_ann' + index; // this annotation is gone - quit now after deleting it // TODO: use d3 idioms instead of deleting and redrawing every time if(!options._input || options.visible === false) { d3.selectAll('#' + annClipID).remove(); return; } // calculated pixel positions // x & y each will get text, head, and tail as appropriate var annPosPx = {x: {}, y: {}}; var 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(className, true) .attr('data-index', String(index)) .style('opacity', options.opacity); // another group for text+background so that they can rotate together var annTextGroup = annGroup.append('g') .classed('annotation-text-g', true); var editTextPosition = edits[options.showarrow ? 'annotationTail' : 'annotationPosition']; var textEvents = options.captureevents || edits.annotationText || editTextPosition; function makeEventData(initialEvent) { var eventData = { index: index, annotation: options._input, fullAnnotation: options, event: initialEvent }; if(subplotId) { eventData.subplotId = subplotId; } return eventData; } var annTextGroupInner = annTextGroup.append('g') .style('pointer-events', textEvents ? 'all' : null) .call(setCursor, 'pointer') .on('click', function() { gd._dragging = false; gd.emit('plotly_clickannotation', makeEventData(d3.event)); }); if(options.hovertext) { annTextGroupInner .on('mouseover', function() { var hoverOptions = options.hoverlabel; var hoverFont = hoverOptions.font; var bBox = this.getBoundingClientRect(); var bBoxRef = gd.getBoundingClientRect(); Fx.loneHover({ x0: bBox.left - bBoxRef.left, x1: bBox.right - bBoxRef.left, y: (bBox.top + bBox.bottom) / 2 - bBoxRef.top, text: options.hovertext, color: hoverOptions.bgcolor, borderColor: hoverOptions.bordercolor, fontFamily: hoverFont.family, fontSize: hoverFont.size, fontColor: hoverFont.color, fontWeight: hoverFont.weight, fontStyle: hoverFont.style, fontVariant: hoverFont.variant, fontShadow: hoverFont.fontShadow, fontLineposition: hoverFont.fontLineposition, fontTextcase: hoverFont.fontTextcase, }, { container: fullLayout._hoverlayer.node(), outerContainer: fullLayout._paper.node(), gd: gd }); }) .on('mouseout', function() { Fx.loneUnhover(fullLayout._hoverlayer.node()); }); } var borderwidth = options.borderwidth; var borderpad = options.borderpad; var borderfull = borderwidth + borderpad; var annTextBG = annTextGroupInner.append('rect') .attr('class', 'bg') .style('stroke-width', borderwidth + 'px') .call(Color.stroke, options.bordercolor) .call(Color.fill, options.bgcolor); var isSizeConstrained = options.width || options.height; var annTextClip = fullLayout._topclips .selectAll('#' + annClipID) .data(isSizeConstrained ? [0] : []); annTextClip.enter().append('clipPath') .classed('annclip', true) .attr('id', annClipID) .append('rect'); annTextClip.exit().remove(); var font = options.font; var text = fullLayout._meta ? Lib.templateString(options.text, fullLayout._meta) : options.text; var annText = annTextGroupInner.append('text') .classed('annotation-text', true) .text(text); function textLayout(s) { s.call(Drawing.font, font) .attr({ 'text-anchor': { left: 'start', right: 'end' }[options.align] || 'middle' }); svgTextUtils.convertToTspans(s, gd, drawGraphicalElements); return s; } function drawGraphicalElements() { // if the text has *only* a link, make the whole box into a link var anchor3 = annText.selectAll('a'); if(anchor3.size() === 1 && anchor3.text() === annText.text()) { var wholeLink = annTextGroupInner.insert('a', ':first-child').attr({ 'xlink:xlink:href': anchor3.attr('xlink:href'), 'xlink:xlink:show': anchor3.attr('xlink:show') }) .style({cursor: 'pointer'}); wholeLink.node().appendChild(annTextBG.node()); } var mathjaxGroup = annTextGroupInner.select('.annotation-text-math-group'); var hasMathjax = !mathjaxGroup.empty(); var anntextBB = Drawing.bBox( (hasMathjax ? mathjaxGroup : annText).node()); var textWidth = anntextBB.width; var textHeight = anntextBB.height; var annWidth = options.width || textWidth; var annHeight = options.height || textHeight; var outerWidth = Math.round(annWidth + 2 * borderfull); var outerHeight = Math.round(annHeight + 2 * borderfull); 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; var letters = ['x', 'y']; for(var i = 0; i < letters.length; i++) { var axLetter = letters[i]; var axRef = options[axLetter + 'ref'] || axLetter; var tailRef = options['a' + axLetter + 'ref']; var ax = {x: xa, y: ya}[axLetter]; var dimAngle = (textangle + (axLetter === 'x' ? 0 : -90)) * Math.PI / 180; // note that these two can be either positive or negative var annSizeFromWidth = outerWidth * Math.cos(dimAngle); var annSizeFromHeight = outerHeight * Math.sin(dimAngle); // but this one is the positive total size var annSize = Math.abs(annSizeFromWidth) + Math.abs(annSizeFromHeight); var anchor = options[axLetter + 'anchor']; var overallShift = options[axLetter + 'shift'] * (axLetter === 'x' ? 1 : -1); var posPx = annPosPx[axLetter]; var basePx; var textPadShift; var alignPosition; var autoAlignFraction; var textShift; var axRefType = Axes.getRefType(axRef); /* * calculate the *primary* pixel position * which is the arrowhead if there is one, * otherwise the text anchor point */ if(ax && (axRefType !== 'domain')) { // check if annotation is off screen, to bypass DOM manipulations var posFraction = ax.r2fraction(options[axLetter]); if(posFraction < 0 || posFraction > 1) { if(tailRef === axRef) { posFraction = ax.r2fraction(options['a' + axLetter]); if(posFraction < 0 || posFraction > 1) { annotationIsOffscreen = true; } } else { annotationIsOffscreen = true; } } basePx = ax._offset + ax.r2p(options[axLetter]); autoAlignFraction = 0.5; } else { var axRefTypeEqDomain = axRefType === 'domain'; if(axLetter === 'x') { alignPosition = options[axLetter]; basePx = axRefTypeEqDomain ? ax._offset + ax._length * alignPosition : basePx = gs.l + gs.w * alignPosition; } else { alignPosition = 1 - options[axLetter]; basePx = axRefTypeEqDomain ? ax._offset + ax._length * alignPosition : basePx = gs.t + gs.h * alignPosition; } autoAlignFraction = options.showarrow ? 0.5 : alignPosition; } // now translate this into pixel positions of head, tail, and text // as well as paddings for autorange if(options.showarrow) { posPx.head = basePx; var arrowLength = options['a' + axLetter]; // with an arrow, the text rotates around the anchor point textShift = annSizeFromWidth * shiftFraction(0.5, options.xanchor) - annSizeFromHeight * shiftFraction(0.5, options.yanchor); if(tailRef === axRef) { // In the case tailRefType is 'domain' or 'paper', the arrow's // position is set absolutely, which is consistent with how // it behaves when its position is set in data ('range') // coordinates. var tailRefType = Axes.getRefType(tailRef); if(tailRefType === 'domain') { if(axLetter === 'y') { arrowLength = 1 - arrowLength; } posPx.tail = ax._offset + ax._length * arrowLength; } else if(tailRefType === 'paper') { if(axLetter === 'y') { arrowLength = 1 - arrowLength; posPx.tail = gs.t + gs.h * arrowLength; } else { posPx.tail = gs.l + gs.w * arrowLength; } } else { // assumed tailRef is range or paper referenced posPx.tail = ax._offset + ax.r2p(arrowLength); } // tail is range- or domain-referenced: autorange pads the // text in px from the tail textPadShift = textShift; } else { posPx.tail = basePx + arrowLength; // tail is specified in px from head, so autorange also pads vs head textPadShift = textShift + arrowLength; } posPx.text = posPx.tail + textShift; // constrain pixel/paper referenced so the draggers are at least // partially visible var maxPx = fullLayout[(axLetter === 'x') ? 'width' : 'height']; if(axRef === 'paper') { posPx.head = Lib.constrain(posPx.head, 1, maxPx - 1); } if(tailRef === 'pixel') { var shiftPlus = -Math.max(posPx.tail - 3, posPx.text); var shiftMinus = Math.min(posPx.tail + 3, posPx.text) - maxPx; if(shiftPlus > 0) { posPx.tail += shiftPlus; posPx.text += shiftPlus; } else if(shiftMinus > 0) { posPx.tail -= shiftMinus; posPx.text -= shiftMinus; } } posPx.tail += overallShift; posPx.head += overallShift; } else { // with no arrow, the text rotates and *then* we put the anchor // relative to the new bounding box textShift = annSize * shiftFraction(autoAlignFraction, anchor); textPadShift = textShift; posPx.text = basePx + textShift; } posPx.text += overallShift; textShift += overallShift; textPadShift += overallShift; // padplus/minus are used by autorange options['_' + axLetter + 'padplus'] = (annSize / 2) + textPadShift; options['_' + axLetter + 'padminus'] = (annSize / 2) - textPadShift; // size/shift are used during dragging options['_' + axLetter + 'size'] = annSize; options['_' + axLetter + 'shift'] = textShift; } if(annotationIsOffscreen) { annTextGroupInner.remove(); return; } var xShift = 0; var yShift = 0; if(options.align !== 'left') { xShift = (annWidth - textWidth) * (options.align === 'center' ? 0.5 : 1); } if(options.valign !== 'top') { yShift = (annHeight - textHeight) * (options.valign === 'middle' ? 0.5 : 1); } if(hasMathjax) { mathjaxGroup.select('svg').attr({ x: borderfull + xShift - 1, y: borderfull + yShift }) .call(Drawing.setClipUrl, isSizeConstrained ? annClipID : null, gd); } else { var texty = borderfull + yShift - anntextBB.top; var textx = borderfull + xShift - anntextBB.left; annText.call(svgTextUtils.positionText, textx, texty) .call(Drawing.setClipUrl, isSizeConstrained ? annClipID : null, gd); } annTextClip.select('rect').call(Drawing.setRect, borderfull, borderfull, annWidth, annHeight); annTextBG.call(Drawing.setRect, borderwidth / 2, borderwidth / 2, outerWidth - borderwidth, outerHeight - borderwidth); annTextGroupInner.call(Drawing.setTranslate, Math.round(annPosPx.x.text - outerWidth / 2), Math.round(annPosPx.y.text - outerHeight / 2)); /* * rotate text and background * we already calculated the text center position *as rotated* * because we needed that for autoranging anyway, so now whether * we have an arrow or not, we rotate about the text center. */ annTextGroup.attr({transform: 'rotate(' + textangle + ',' + annPosPx.x.text + ',' + annPosPx.y.text + ')'}); /* * add the arrow * uses options[arrowwidth,arrowcolor,arrowhead] for styling * dx and dy are normally zero, but when you are dragging the textbox * while the head stays put, dx and dy are the pixel offsets */ var drawArrow = function(dx, dy) { annGroup .selectAll('.annotation-arrow-g') .remove(); var headX = annPosPx.x.head; var headY = annPosPx.y.head; var tailX = annPosPx.x.tail + dx; var tailY = annPosPx.y.tail + dy; var textX = annPosPx.x.text + dx; var textY = annPosPx.y.text + dy; // find the edge of the text box, where we'll start the arrow: // create transform matrix to rotate the text box corners var transform = Lib.rotationXYMatrix(textangle, textX, textY); var applyTransform = Lib.apply2DTransform(transform); var applyTransform2 = Lib.apply2DTransform2(transform); // calculate and transform bounding box var width = +annTextBG.attr('width'); var height = +annTextBG.attr('height'); var xLeft = textX - 0.5 * width; var xRight = xLeft + width; var yTop = textY - 0.5 * height; var yBottom = yTop + height; var edges = [ [xLeft, yTop, xLeft, yBottom], [xLeft, yBottom, xRight, yBottom], [xRight, yBottom, xRight, yTop], [xRight, yTop, xLeft, yTop] ].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 ^ !!Lib.segmentsIntersect(headX, headY, headX + 1e6, headY + 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 = Lib.segmentsIntersect(tailX, tailY, headX, headY, x[0], x[1], x[2], x[3]); if(p) { tailX = p.x; tailY = p.y; } }); var strokewidth = options.arrowwidth; var arrowColor = options.arrowcolor; var arrowSide = options.arrowside; var arrowGroup = annGroup.append('g') .style({opacity: Color.opacity(arrowColor)}) .classed('annotation-arrow-g', true); var arrow = arrowGroup.append('path') .attr('d', 'M' + tailX + ',' + tailY + 'L' + headX + ',' + headY) .style('stroke-width', strokewidth + 'px') .call(Color.stroke, Color.rgb(arrowColor)); drawArrowHead(arrow, arrowSide, options); // the arrow dragger is a small square right at the head, then a line to the tail, // all expanded by a stroke width of 6px plus the arrow line width if(edits.annotationPosition && arrow.node().parentNode && !subplotId) { var arrowDragHeadX = headX; var arrowDragHeadY = headY; if(options.standoff) { var arrowLength = Math.sqrt(Math.pow(headX - tailX, 2) + Math.pow(headY - tailY, 2)); arrowDragHeadX += options.standoff * (tailX - headX) / arrowLength; arrowDragHeadY += options.standoff * (tailY - headY) / arrowLength; } var arrowDrag = arrowGroup.append('path') .classed('annotation-arrow', true) .classed('anndrag', true) .classed('cursor-move', true) .attr({ d: 'M3,3H-3V-3H3ZM0,0L' + (tailX - arrowDragHeadX) + ',' + (tailY - arrowDragHeadY), transform: strTranslate(arrowDragHeadX, arrowDragHeadY) }) .style('stroke-width', (strokewidth + 6) + 'px') .call(Color.stroke, 'rgba(0,0,0,0)') .call(Color.fill, 'rgba(0,0,0,0)'); var annx0, anny0; // dragger for the arrow & head: translates the whole thing // (head/tail/text) all together dragElement.init({ element: arrowDrag.node(), gd: gd, prepFn: function() { var pos = Drawing.getTranslate(annTextGroupInner); annx0 = pos.x; anny0 = pos.y; if(xa && xa.autorange) { modifyBase(xa._name + '.autorange', true); } if(ya && ya.autorange) { modifyBase(ya._name + '.autorange', true); } }, moveFn: function(dx, dy) { var annxy0 = applyTransform(annx0, anny0); var xcenter = annxy0[0] + dx; var ycenter = annxy0[1] + dy; annTextGroupInner.call(Drawing.setTranslate, xcenter, ycenter); modifyItem('x', shiftPosition(xa, dx, 'x', gs, options)); modifyItem('y', shiftPosition(ya, dy, 'y', gs, options)); // for these 2 calls to shiftPosition, it is assumed xa, ya are // defined, so gsDim will not be used, but we put it in // anyways for consistency if(options.axref === options.xref) { modifyItem('ax', shiftPosition(xa, dx, 'ax', gs, options)); } if(options.ayref === options.yref) { modifyItem('ay', shiftPosition(ya, dy, 'ay', gs, options)); } arrowGroup.attr('transform', strTranslate(dx, dy)); annTextGroup.attr({ transform: 'rotate(' + textangle + ',' + xcenter + ',' + ycenter + ')' }); }, doneFn: function() { Registry.call('_guiRelayout', gd, getUpdateObj()); var notesBox = document.querySelector('.js-notes-box-panel'); if(notesBox) notesBox.redraw(notesBox.selectedObj); } }); } }; if(options.showarrow) drawArrow(0, 0); // user dragging the annotation (text, not arrow) if(editTextPosition) { var baseTextTransform; // dragger for the textbox: if there's an arrow, just drag the // textbox and tail, leave the head untouched dragElement.init({ element: annTextGroupInner.node(), gd: gd, prepFn: function() { baseTextTransform = annTextGroup.attr('transform'); }, moveFn: function(dx, dy) { var csr = 'pointer'; if(options.showarrow) { // for these 2 calls to shiftPosition, it is assumed xa, ya are // defined, so gsDim will not be used, but we put it in // anyways for consistency if(options.axref === options.xref) { modifyItem('ax', shiftPosition(xa, dx, 'ax', gs, options)); } else { modifyItem('ax', options.ax + dx); } if(options.ayref === options.yref) { modifyItem('ay', shiftPosition(ya, dy, 'ay', gs.w, options)); } else { modifyItem('ay', options.ay + dy); } drawArrow(dx, dy); } else if(!subplotId) { var xUpdate, yUpdate; if(xa) { // shiftPosition will not execute code where xa was // undefined, so we use to calculate xUpdate too xUpdate = shiftPosition(xa, dx, 'x', gs, options); } else { var widthFraction = options._xsize / gs.w; var xLeft = options.x + (options._xshift - options.xshift) / gs.w - widthFraction / 2; xUpdate = dragElement.align(xLeft + dx / gs.w, widthFraction, 0, 1, options.xanchor); } if(ya) { // shiftPosition will not execute code where ya was // undefined, so we use to calculate yUpdate too yUpdate = shiftPosition(ya, dy, 'y', gs, options); } else { var heightFraction = options._ysize / gs.h; var yBottom = options.y - (options._yshift + options.yshift) / gs.h - heightFraction / 2; yUpdate = dragElement.align(yBottom - dy / gs.h, heightFraction, 0, 1, options.yanchor); } modifyItem('x', xUpdate); modifyItem('y', yUpdate); if(!xa || !ya) { csr = dragElement.getCursor( xa ? 0.5 : xUpdate, ya ? 0.5 : yUpdate, options.xanchor, options.yanchor ); } } else return; annTextGroup.attr({ transform: strTranslate(dx, dy) + baseTextTransform }); setCursor(annTextGroupInner, csr); }, clickFn: function(_, initialEvent) { if(options.captureevents) { gd.emit('plotly_clickannotation', makeEventData(initialEvent)); } }, doneFn: function() { setCursor(annTextGroupInner); Registry.call('_guiRelayout', gd, getUpdateObj()); var notesBox = document.querySelector('.js-notes-box-panel'); if(notesBox) notesBox.redraw(notesBox.selectedObj); } }); } } if(edits.annotationText) { annText.call(svgTextUtils.makeEditable, {delegate: annTextGroupInner, gd: gd}) .call(textLayout) .on('edit', function(_text) { options.text = _text; this.call(textLayout); modifyItem('text', _text); if(xa && xa.autorange) { modifyBase(xa._name + '.autorange', true); } if(ya && ya.autorange) { modifyBase(ya._name + '.autorange', true); } Registry.call('_guiRelayout', gd, getUpdateObj()); }); } else annText.call(textLayout); }