|
| 1 | +'use strict'; |
| 2 | + |
| 3 | +var Plotly = require('../../plotly'); |
| 4 | +var d3 = require('d3'); |
| 5 | +var isNumeric = require('fast-isnumeric'); |
| 6 | + |
| 7 | +var plots = Plotly.Plots; |
| 8 | + |
| 9 | +var Titles = module.exports = {}; |
| 10 | + |
| 11 | +// titles - (re)draw titles on the axes and plot |
| 12 | +// title can be 'xtitle', 'ytitle', 'gtitle', |
| 13 | +// or empty to draw all |
| 14 | +Titles.draw = function(gd, title) { |
| 15 | + if(!title) { |
| 16 | + Plotly.Axes.listIds(gd).forEach(function(axId) { |
| 17 | + Titles.draw(gd, axId + 'title'); |
| 18 | + }); |
| 19 | + Titles.draw(gd, 'gtitle'); |
| 20 | + return; |
| 21 | + } |
| 22 | + |
| 23 | + var fullLayout = gd._fullLayout, |
| 24 | + gs = fullLayout._size, |
| 25 | + axletter = title.charAt(0), |
| 26 | + colorbar = title.substr(1,2)==='cb'; |
| 27 | + |
| 28 | + var cbnum, cont, options; |
| 29 | + |
| 30 | + if(colorbar) { |
| 31 | + var uid = title.substr(3).replace('title',''); |
| 32 | + gd._fullData.some(function(trace, i) { |
| 33 | + if(trace.uid===uid) { |
| 34 | + cbnum = i; |
| 35 | + cont = gd.calcdata[i][0].t.cb.axis; |
| 36 | + return true; |
| 37 | + } |
| 38 | + }); |
| 39 | + } |
| 40 | + else cont = fullLayout[Plotly.Axes.id2name(title.replace('title',''))] || fullLayout; |
| 41 | + |
| 42 | + var prop = cont===fullLayout ? 'title' : cont._name+'.title', |
| 43 | + name = colorbar ? 'colorscale' : |
| 44 | + ((cont._id||axletter).toUpperCase()+' axis'), |
| 45 | + font = cont.titlefont.family, |
| 46 | + fontSize = cont.titlefont.size, |
| 47 | + fontColor = cont.titlefont.color, |
| 48 | + x, |
| 49 | + y, |
| 50 | + transform='', |
| 51 | + attr = {}, |
| 52 | + xa, |
| 53 | + ya, |
| 54 | + avoid = { |
| 55 | + selection:d3.select(gd).selectAll('g.'+cont._id+'tick'), |
| 56 | + side:cont.side |
| 57 | + }, |
| 58 | + // multiples of fontsize to offset label from axis |
| 59 | + offsetBase = colorbar ? 0 : 1.5, |
| 60 | + avoidTransform; |
| 61 | + |
| 62 | + // find the transform applied to the parents of the avoid selection |
| 63 | + // which doesn't get picked up by Plotly.Drawing.bBox |
| 64 | + if(colorbar) { |
| 65 | + avoid.offsetLeft = gs.l; |
| 66 | + avoid.offsetTop = gs.t; |
| 67 | + } |
| 68 | + else if(avoid.selection.size()) { |
| 69 | + avoidTransform = d3.select(avoid.selection.node().parentNode) |
| 70 | + .attr('transform') |
| 71 | + .match(/translate\(([-\.\d]+),([-\.\d]+)\)/); |
| 72 | + if(avoidTransform) { |
| 73 | + avoid.offsetLeft = +avoidTransform[1]; |
| 74 | + avoid.offsetTop = +avoidTransform[2]; |
| 75 | + } |
| 76 | + } |
| 77 | + |
| 78 | + if(colorbar && cont.titleside) { |
| 79 | + // argh, we only make it here if the title is on top or bottom, |
| 80 | + // not right |
| 81 | + x = gs.l+cont.titlex*gs.w; |
| 82 | + y = gs.t+(1-cont.titley)*gs.h + ((cont.titleside==='top') ? |
| 83 | + 3+fontSize*0.75 : - 3-fontSize*0.25); |
| 84 | + options = {x: x, y: y, 'text-anchor':'start'}; |
| 85 | + avoid = {}; |
| 86 | + |
| 87 | + // convertToTspans rotates any 'y...' by 90 degrees... |
| 88 | + // TODO: need a better solution than this hack |
| 89 | + title = 'h'+title; |
| 90 | + } |
| 91 | + else if(axletter==='x'){ |
| 92 | + xa = cont; |
| 93 | + ya = (xa.anchor==='free') ? |
| 94 | + {_offset:gs.t+(1-(xa.position||0))*gs.h, _length:0} : |
| 95 | + Plotly.Axes.getFromId(gd, xa.anchor); |
| 96 | + x = xa._offset+xa._length/2; |
| 97 | + y = ya._offset + ((xa.side==='top') ? |
| 98 | + -10 - fontSize*(offsetBase + (xa.showticklabels ? 1 : 0)) : |
| 99 | + ya._length + 10 + |
| 100 | + fontSize*(offsetBase + (xa.showticklabels ? 1.5 : 0.5))); |
| 101 | + options = {x: x, y: y, 'text-anchor': 'middle'}; |
| 102 | + if(!avoid.side) { avoid.side = 'bottom'; } |
| 103 | + } |
| 104 | + else if(axletter==='y'){ |
| 105 | + ya = cont; |
| 106 | + xa = (ya.anchor==='free') ? |
| 107 | + {_offset:gs.l+(ya.position||0)*gs.w, _length:0} : |
| 108 | + Plotly.Axes.getFromId(gd, ya.anchor); |
| 109 | + y = ya._offset+ya._length/2; |
| 110 | + x = xa._offset + ((ya.side==='right') ? |
| 111 | + xa._length + 10 + |
| 112 | + fontSize*(offsetBase + (ya.showticklabels ? 1 : 0.5)) : |
| 113 | + -10 - fontSize*(offsetBase + (ya.showticklabels ? 0.5 : 0))); |
| 114 | + attr = {center: 0}; |
| 115 | + options = {x: x, y: y, 'text-anchor': 'middle'}; |
| 116 | + transform = {rotate: '-90', offset: 0}; |
| 117 | + if(!avoid.side) { avoid.side = 'left'; } |
| 118 | + } |
| 119 | + else{ |
| 120 | + // plot title |
| 121 | + name = 'Plot'; |
| 122 | + fontSize = fullLayout.titlefont.size; |
| 123 | + x = fullLayout.width/2; |
| 124 | + y = fullLayout._size.t/2; |
| 125 | + options = {x: x, y: y, 'text-anchor': 'middle'}; |
| 126 | + avoid = {}; |
| 127 | + } |
| 128 | + |
| 129 | + var opacity = 1, |
| 130 | + isplaceholder = false, |
| 131 | + txt = cont.title.trim(); |
| 132 | + if(txt === '') { opacity = 0; } |
| 133 | + if(txt.match(/Click to enter .+ title/)) { |
| 134 | + opacity = 0.2; |
| 135 | + isplaceholder = true; |
| 136 | + } |
| 137 | + |
| 138 | + var group; |
| 139 | + if(colorbar) { |
| 140 | + group = d3.select(gd) |
| 141 | + .selectAll('.'+cont._id.substr(1)+' .cbtitle'); |
| 142 | + // this class-to-rotate thing with convertToTspans is |
| 143 | + // getting hackier and hackier... delete groups with the |
| 144 | + // wrong class |
| 145 | + var otherClass = title.charAt(0)==='h' ? |
| 146 | + title.substr(1) : ('h'+title); |
| 147 | + group.selectAll('.'+otherClass+',.'+otherClass+'-math-group') |
| 148 | + .remove(); |
| 149 | + } |
| 150 | + else { |
| 151 | + group = fullLayout._infolayer.selectAll('.g-'+title) |
| 152 | + .data([0]); |
| 153 | + group.enter().append('g') |
| 154 | + .classed('g-'+title, true); |
| 155 | + } |
| 156 | + |
| 157 | + var el = group.selectAll('text') |
| 158 | + .data([0]); |
| 159 | + el.enter().append('text'); |
| 160 | + el.text(txt) |
| 161 | + // this is hacky, but convertToTspans uses the class |
| 162 | + // to determine whether to rotate mathJax... |
| 163 | + // so we need to clear out any old class and put the |
| 164 | + // correct one (only relevant for colorbars, at least |
| 165 | + // for now) - ie don't use .classed |
| 166 | + .attr('class', title); |
| 167 | + |
| 168 | + function titleLayout(titleEl){ |
| 169 | + Plotly.Lib.syncOrAsync([drawTitle,scootTitle], titleEl); |
| 170 | + } |
| 171 | + |
| 172 | + function drawTitle(titleEl) { |
| 173 | + titleEl.attr('transform', transform ? |
| 174 | + 'rotate(' + [transform.rotate, options.x, options.y] + |
| 175 | + ') translate(0, '+transform.offset+')' : |
| 176 | + null); |
| 177 | + titleEl.style({ |
| 178 | + 'font-family': font, |
| 179 | + 'font-size': d3.round(fontSize,2)+'px', |
| 180 | + fill: Plotly.Color.rgb(fontColor), |
| 181 | + opacity: opacity*Plotly.Color.opacity(fontColor), |
| 182 | + 'font-weight': plots.fontWeight |
| 183 | + }) |
| 184 | + .attr(options) |
| 185 | + .call(Plotly.util.convertToTspans) |
| 186 | + .attr(options); |
| 187 | + titleEl.selectAll('tspan.line') |
| 188 | + .attr(options); |
| 189 | + return plots.previousPromises(gd); |
| 190 | + } |
| 191 | + |
| 192 | + function scootTitle(titleElIn) { |
| 193 | + var titleGroup = d3.select(titleElIn.node().parentNode); |
| 194 | + |
| 195 | + if(avoid && avoid.selection && avoid.side && txt){ |
| 196 | + titleGroup.attr('transform',null); |
| 197 | + |
| 198 | + // move toward avoid.side (= left, right, top, bottom) if needed |
| 199 | + // can include pad (pixels, default 2) |
| 200 | + var shift = 0, |
| 201 | + backside = { |
| 202 | + left: 'right', |
| 203 | + right: 'left', |
| 204 | + top: 'bottom', |
| 205 | + bottom: 'top' |
| 206 | + }[avoid.side], |
| 207 | + shiftSign = (['left','top'].indexOf(avoid.side)!==-1) ? |
| 208 | + -1 : 1, |
| 209 | + pad = isNumeric(avoid.pad) ? avoid.pad : 2, |
| 210 | + titlebb = Plotly.Drawing.bBox(titleGroup.node()), |
| 211 | + paperbb = { |
| 212 | + left: 0, |
| 213 | + top: 0, |
| 214 | + right: fullLayout.width, |
| 215 | + bottom: fullLayout.height |
| 216 | + }, |
| 217 | + maxshift = colorbar ? fullLayout.width: |
| 218 | + (paperbb[avoid.side]-titlebb[avoid.side]) * |
| 219 | + ((avoid.side==='left' || avoid.side==='top') ? -1 : 1); |
| 220 | + // Prevent the title going off the paper |
| 221 | + if(maxshift<0) shift = maxshift; |
| 222 | + else { |
| 223 | + // so we don't have to offset each avoided element, |
| 224 | + // give the title the opposite offset |
| 225 | + titlebb.left -= avoid.offsetLeft; |
| 226 | + titlebb.right -= avoid.offsetLeft; |
| 227 | + titlebb.top -= avoid.offsetTop; |
| 228 | + titlebb.bottom -= avoid.offsetTop; |
| 229 | + |
| 230 | + // iterate over a set of elements (avoid.selection) |
| 231 | + // to avoid collisions with |
| 232 | + avoid.selection.each(function(){ |
| 233 | + var avoidbb = Plotly.Drawing.bBox(this); |
| 234 | + |
| 235 | + if(Plotly.Lib.bBoxIntersect(titlebb,avoidbb,pad)) { |
| 236 | + shift = Math.max(shift, shiftSign * ( |
| 237 | + avoidbb[avoid.side] - titlebb[backside]) + pad); |
| 238 | + } |
| 239 | + }); |
| 240 | + shift = Math.min(maxshift, shift); |
| 241 | + } |
| 242 | + if(shift>0 || maxshift<0) { |
| 243 | + var shiftTemplate = { |
| 244 | + left: [-shift, 0], |
| 245 | + right: [shift, 0], |
| 246 | + top: [0, -shift], |
| 247 | + bottom: [0, shift] |
| 248 | + }[avoid.side]; |
| 249 | + titleGroup.attr('transform', |
| 250 | + 'translate(' + shiftTemplate + ')'); |
| 251 | + } |
| 252 | + } |
| 253 | + } |
| 254 | + |
| 255 | + el.attr({'data-unformatted': txt}) |
| 256 | + .call(titleLayout); |
| 257 | + |
| 258 | + var placeholderText = 'Click to enter '+name.replace(/\d+/,'')+' title'; |
| 259 | + |
| 260 | + function setPlaceholder(){ |
| 261 | + opacity = 0; |
| 262 | + isplaceholder = true; |
| 263 | + txt = placeholderText; |
| 264 | + fullLayout._infolayer.select('.'+title) |
| 265 | + .attr({'data-unformatted': txt}) |
| 266 | + .text(txt) |
| 267 | + .on('mouseover.opacity',function(){ |
| 268 | + d3.select(this).transition() |
| 269 | + .duration(100).style('opacity',1); |
| 270 | + }) |
| 271 | + .on('mouseout.opacity',function(){ |
| 272 | + d3.select(this).transition() |
| 273 | + .duration(1000).style('opacity',0); |
| 274 | + }); |
| 275 | + } |
| 276 | + |
| 277 | + if(gd._context.editable){ |
| 278 | + if(!txt) setPlaceholder(); |
| 279 | + |
| 280 | + el.call(Plotly.util.makeEditable) |
| 281 | + .on('edit', function(text){ |
| 282 | + if(colorbar) { |
| 283 | + var trace = gd._fullData[cbnum]; |
| 284 | + if(plots.traceIs(trace, 'markerColorscale')) { |
| 285 | + Plotly.restyle(gd, 'marker.colorbar.title', text, cbnum); |
| 286 | + } else Plotly.restyle(gd, 'colorbar.title', text, cbnum); |
| 287 | + } |
| 288 | + else Plotly.relayout(gd,prop,text); |
| 289 | + }) |
| 290 | + .on('cancel', function(){ |
| 291 | + this.text(this.attr('data-unformatted')) |
| 292 | + .call(titleLayout); |
| 293 | + }) |
| 294 | + .on('input', function(d){ |
| 295 | + this.text(d || ' ').attr(options) |
| 296 | + .selectAll('tspan.line') |
| 297 | + .attr(options); |
| 298 | + }); |
| 299 | + } |
| 300 | + else if(!txt || txt.match(/Click to enter .+ title/)) { |
| 301 | + el.remove(); |
| 302 | + } |
| 303 | + el.classed('js-placeholder',isplaceholder); |
| 304 | +}; |
0 commit comments