| 1 | /** |
| 2 | * @license |
| 3 | * Copyright 2012 Dan Vanderkam (danvdk@gmail.com) |
| 4 | * MIT-licensed (http://opensource.org/licenses/MIT) |
| 5 | */ |
| 6 | /*global Dygraph:false */ |
| 7 | |
| 8 | /* |
| 9 | Current bits of jankiness: |
| 10 | - Uses two private APIs: |
| 11 | 1. Dygraph.optionsViewForAxis_ |
| 12 | 2. dygraph.plotter_.area |
| 13 | - Registers for a "predraw" event, which should be renamed. |
| 14 | - I call calculateEmWidthInDiv more often than needed. |
| 15 | */ |
| 16 | |
| 17 | /*global Dygraph:false */ |
| 18 | "use strict"; |
| 19 | |
| 20 | import * as utils from '../dygraph-utils'; |
| 21 | |
| 22 | |
| 23 | /** |
| 24 | * Creates the legend, which appears when the user hovers over the chart. |
| 25 | * The legend can be either a user-specified or generated div. |
| 26 | * |
| 27 | * @constructor |
| 28 | */ |
| 29 | var Legend = function() { |
| 30 | this.legend_div_ = null; |
| 31 | this.is_generated_div_ = false; // do we own this div, or was it user-specified? |
| 32 | }; |
| 33 | |
| 34 | Legend.prototype.toString = function() { |
| 35 | return "Legend Plugin"; |
| 36 | }; |
| 37 | |
| 38 | // (defined below) |
| 39 | var generateLegendDashHTML; |
| 40 | |
| 41 | /** |
| 42 | * This is called during the dygraph constructor, after options have been set |
| 43 | * but before the data is available. |
| 44 | * |
| 45 | * Proper tasks to do here include: |
| 46 | * - Reading your own options |
| 47 | * - DOM manipulation |
| 48 | * - Registering event listeners |
| 49 | * |
| 50 | * @param {Dygraph} g Graph instance. |
| 51 | * @return {object.<string, function(ev)>} Mapping of event names to callbacks. |
| 52 | */ |
| 53 | Legend.prototype.activate = function(g) { |
| 54 | var div; |
| 55 | var divWidth = g.getOption('labelsDivWidth'); |
| 56 | |
| 57 | var userLabelsDiv = g.getOption('labelsDiv'); |
| 58 | if (userLabelsDiv && null !== userLabelsDiv) { |
| 59 | if (typeof(userLabelsDiv) == "string" || userLabelsDiv instanceof String) { |
| 60 | div = document.getElementById(userLabelsDiv); |
| 61 | } else { |
| 62 | div = userLabelsDiv; |
| 63 | } |
| 64 | } else { |
| 65 | // Default legend styles. These can be overridden in CSS by adding |
| 66 | // "!important" after your rule, e.g. "left: 30px !important;" |
| 67 | var messagestyle = { |
| 68 | "position": "absolute", |
| 69 | "fontSize": "14px", |
| 70 | "zIndex": 10, |
| 71 | "width": divWidth + "px", |
| 72 | "top": "0px", |
| 73 | "left": (g.size().width - divWidth - 2) + "px", |
| 74 | "background": "white", |
| 75 | "lineHeight": "normal", |
| 76 | "textAlign": "left", |
| 77 | "overflow": "hidden"}; |
| 78 | |
| 79 | // TODO(danvk): get rid of labelsDivStyles? CSS is better. |
| 80 | utils.update(messagestyle, g.getOption('labelsDivStyles')); |
| 81 | div = document.createElement("div"); |
| 82 | div.className = "dygraph-legend"; |
| 83 | for (var name in messagestyle) { |
| 84 | if (!messagestyle.hasOwnProperty(name)) continue; |
| 85 | |
| 86 | try { |
| 87 | div.style[name] = messagestyle[name]; |
| 88 | } catch (e) { |
| 89 | console.warn("You are using unsupported css properties for your " + |
| 90 | "browser in labelsDivStyles"); |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | // TODO(danvk): come up with a cleaner way to expose this. |
| 95 | g.graphDiv.appendChild(div); |
| 96 | this.is_generated_div_ = true; |
| 97 | } |
| 98 | |
| 99 | this.legend_div_ = div; |
| 100 | this.one_em_width_ = 10; // just a guess, will be updated. |
| 101 | |
| 102 | return { |
| 103 | select: this.select, |
| 104 | deselect: this.deselect, |
| 105 | // TODO(danvk): rethink the name "predraw" before we commit to it in any API. |
| 106 | predraw: this.predraw, |
| 107 | didDrawChart: this.didDrawChart |
| 108 | }; |
| 109 | }; |
| 110 | |
| 111 | // Needed for dashed lines. |
| 112 | var calculateEmWidthInDiv = function(div) { |
| 113 | var sizeSpan = document.createElement('span'); |
| 114 | sizeSpan.setAttribute('style', 'margin: 0; padding: 0 0 0 1em; border: 0;'); |
| 115 | div.appendChild(sizeSpan); |
| 116 | var oneEmWidth=sizeSpan.offsetWidth; |
| 117 | div.removeChild(sizeSpan); |
| 118 | return oneEmWidth; |
| 119 | }; |
| 120 | |
| 121 | var escapeHTML = function(str) { |
| 122 | return str.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">"); |
| 123 | }; |
| 124 | |
| 125 | Legend.prototype.select = function(e) { |
| 126 | var xValue = e.selectedX; |
| 127 | var points = e.selectedPoints; |
| 128 | var row = e.selectedRow; |
| 129 | |
| 130 | var legendMode = e.dygraph.getOption('legend'); |
| 131 | if (legendMode === 'never') { |
| 132 | this.legend_div_.style.display = 'none'; |
| 133 | return; |
| 134 | } |
| 135 | |
| 136 | if (legendMode === 'follow') { |
| 137 | // create floating legend div |
| 138 | var area = e.dygraph.plotter_.area; |
| 139 | var labelsDivWidth = e.dygraph.getOption('labelsDivWidth'); |
| 140 | var yAxisLabelWidth = e.dygraph.getOptionForAxis('axisLabelWidth', 'y'); |
| 141 | // determine floating [left, top] coordinates of the legend div |
| 142 | // within the plotter_ area |
| 143 | // offset 50 px to the right and down from the first selection point |
| 144 | // 50 px is guess based on mouse cursor size |
| 145 | var leftLegend = points[0].x * area.w + 50; |
| 146 | var topLegend = points[0].y * area.h - 50; |
| 147 | |
| 148 | // if legend floats to end of the chart area, it flips to the other |
| 149 | // side of the selection point |
| 150 | if ((leftLegend + labelsDivWidth + 1) > area.w) { |
| 151 | leftLegend = leftLegend - 2 * 50 - labelsDivWidth - (yAxisLabelWidth - area.x); |
| 152 | } |
| 153 | |
| 154 | e.dygraph.graphDiv.appendChild(this.legend_div_); |
| 155 | this.legend_div_.style.left = yAxisLabelWidth + leftLegend + "px"; |
| 156 | this.legend_div_.style.top = topLegend + "px"; |
| 157 | } |
| 158 | |
| 159 | var html = Legend.generateLegendHTML(e.dygraph, xValue, points, this.one_em_width_, row); |
| 160 | this.legend_div_.innerHTML = html; |
| 161 | this.legend_div_.style.display = ''; |
| 162 | }; |
| 163 | |
| 164 | Legend.prototype.deselect = function(e) { |
| 165 | var legendMode = e.dygraph.getOption('legend'); |
| 166 | if (legendMode !== 'always') { |
| 167 | this.legend_div_.style.display = "none"; |
| 168 | } |
| 169 | |
| 170 | // Have to do this every time, since styles might have changed. |
| 171 | var oneEmWidth = calculateEmWidthInDiv(this.legend_div_); |
| 172 | this.one_em_width_ = oneEmWidth; |
| 173 | |
| 174 | var html = Legend.generateLegendHTML(e.dygraph, undefined, undefined, oneEmWidth, null); |
| 175 | this.legend_div_.innerHTML = html; |
| 176 | }; |
| 177 | |
| 178 | Legend.prototype.didDrawChart = function(e) { |
| 179 | this.deselect(e); |
| 180 | }; |
| 181 | |
| 182 | // Right edge should be flush with the right edge of the charting area (which |
| 183 | // may not be the same as the right edge of the div, if we have two y-axes. |
| 184 | // TODO(danvk): is any of this really necessary? Could just set "right" in "activate". |
| 185 | /** |
| 186 | * Position the labels div so that: |
| 187 | * - its right edge is flush with the right edge of the charting area |
| 188 | * - its top edge is flush with the top edge of the charting area |
| 189 | * @private |
| 190 | */ |
| 191 | Legend.prototype.predraw = function(e) { |
| 192 | // Don't touch a user-specified labelsDiv. |
| 193 | if (!this.is_generated_div_) return; |
| 194 | |
| 195 | // TODO(danvk): only use real APIs for this. |
| 196 | e.dygraph.graphDiv.appendChild(this.legend_div_); |
| 197 | var area = e.dygraph.getArea(); |
| 198 | var labelsDivWidth = e.dygraph.getOption("labelsDivWidth"); |
| 199 | this.legend_div_.style.left = area.x + area.w - labelsDivWidth - 1 + "px"; |
| 200 | this.legend_div_.style.top = area.y + "px"; |
| 201 | this.legend_div_.style.width = labelsDivWidth + "px"; |
| 202 | }; |
| 203 | |
| 204 | /** |
| 205 | * Called when dygraph.destroy() is called. |
| 206 | * You should null out any references and detach any DOM elements. |
| 207 | */ |
| 208 | Legend.prototype.destroy = function() { |
| 209 | this.legend_div_ = null; |
| 210 | }; |
| 211 | |
| 212 | /** |
| 213 | * Generates HTML for the legend which is displayed when hovering over the |
| 214 | * chart. If no selected points are specified, a default legend is returned |
| 215 | * (this may just be the empty string). |
| 216 | * @param {number} x The x-value of the selected points. |
| 217 | * @param {Object} sel_points List of selected points for the given |
| 218 | * x-value. Should have properties like 'name', 'yval' and 'canvasy'. |
| 219 | * @param {number} oneEmWidth The pixel width for 1em in the legend. Only |
| 220 | * relevant when displaying a legend with no selection (i.e. {legend: |
| 221 | * 'always'}) and with dashed lines. |
| 222 | * @param {number} row The selected row index. |
| 223 | * @private |
| 224 | */ |
| 225 | Legend.generateLegendHTML = function(g, x, sel_points, oneEmWidth, row) { |
| 226 | // Data about the selection to pass to legendFormatter |
| 227 | var data = { |
| 228 | dygraph: g, |
| 229 | x: x, |
| 230 | series: [] |
| 231 | }; |
| 232 | |
| 233 | var labelToSeries = {}; |
| 234 | var labels = g.getLabels(); |
| 235 | if (labels) { |
| 236 | for (var i = 1; i < labels.length; i++) { |
| 237 | var series = g.getPropertiesForSeries(labels[i]); |
| 238 | var strokePattern = g.getOption('strokePattern', labels[i]); |
| 239 | var seriesData = { |
| 240 | dashHTML: generateLegendDashHTML(strokePattern, series.color, oneEmWidth), |
| 241 | label: labels[i], |
| 242 | labelHTML: escapeHTML(labels[i]), |
| 243 | isVisible: series.visible, |
| 244 | color: series.color |
| 245 | }; |
| 246 | |
| 247 | data.series.push(seriesData); |
| 248 | labelToSeries[labels[i]] = seriesData; |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | if (typeof(x) !== 'undefined') { |
| 253 | var xOptView = g.optionsViewForAxis_('x'); |
| 254 | var xvf = xOptView('valueFormatter'); |
| 255 | data.xHTML = xvf.call(g, x, xOptView, labels[0], g, row, 0); |
| 256 | |
| 257 | var yOptViews = []; |
| 258 | var num_axes = g.numAxes(); |
| 259 | for (var i = 0; i < num_axes; i++) { |
| 260 | // TODO(danvk): remove this use of a private API |
| 261 | yOptViews[i] = g.optionsViewForAxis_('y' + (i ? 1 + i : '')); |
| 262 | } |
| 263 | |
| 264 | var showZeros = g.getOption('labelsShowZeroValues'); |
| 265 | var highlightSeries = g.getHighlightSeries(); |
| 266 | for (i = 0; i < sel_points.length; i++) { |
| 267 | var pt = sel_points[i]; |
| 268 | var seriesData = labelToSeries[pt.name]; |
| 269 | seriesData.y = pt.yval; |
| 270 | |
| 271 | if ((pt.yval === 0 && !showZeros) || !utils.isOK(pt.canvasy)) { |
| 272 | seriesData.isVisible = false; |
| 273 | continue; |
| 274 | } |
| 275 | |
| 276 | var series = g.getPropertiesForSeries(pt.name); |
| 277 | var yOptView = yOptViews[series.axis - 1]; |
| 278 | var fmtFunc = yOptView('valueFormatter'); |
| 279 | var yHTML = fmtFunc.call(g, pt.yval, yOptView, pt.name, g, row, labels.indexOf(pt.name)); |
| 280 | |
| 281 | utils.update(seriesData, {yHTML}); |
| 282 | |
| 283 | if (pt.name == highlightSeries) { |
| 284 | seriesData.isHighlighted = true; |
| 285 | } |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | var formatter = (g.getOption('legendFormatter') || Legend.defaultFormatter); |
| 290 | return formatter.call(g, data); |
| 291 | } |
| 292 | |
| 293 | Legend.defaultFormatter = function(data) { |
| 294 | var g = data.dygraph; |
| 295 | |
| 296 | // TODO(danvk): deprecate this option in place of {legend: 'never'} |
| 297 | // XXX should this logic be in the formatter? |
| 298 | if (g.getOption('showLabelsOnHighlight') !== true) return ''; |
| 299 | |
| 300 | var sepLines = g.getOption('labelsSeparateLines'); |
| 301 | var html; |
| 302 | |
| 303 | if (typeof(data.x) === 'undefined') { |
| 304 | // TODO: this check is duplicated in generateLegendHTML. Put it in one place. |
| 305 | if (g.getOption('legend') != 'always') { |
| 306 | return ''; |
| 307 | } |
| 308 | |
| 309 | html = ''; |
| 310 | for (var i = 0; i < data.series.length; i++) { |
| 311 | var series = data.series[i]; |
| 312 | if (!series.isVisible) continue; |
| 313 | |
| 314 | if (html !== '') html += (sepLines ? '<br/>' : ' '); |
| 315 | html += `<span style='font-weight: bold; color: ${series.color};'>${series.dashHTML} ${series.labelHTML}</span>`; |
| 316 | } |
| 317 | return html; |
| 318 | } |
| 319 | |
| 320 | html = data.xHTML + ':'; |
| 321 | for (var i = 0; i < data.series.length; i++) { |
| 322 | var series = data.series[i]; |
| 323 | if (!series.isVisible) continue; |
| 324 | if (sepLines) html += '<br>'; |
| 325 | var cls = series.isHighlighted ? ' class="highlight"' : ''; |
| 326 | html += `<span${cls}> <b><span style='color: ${series.color};'>${series.labelHTML}</span></b>: ${series.yHTML}</span>`; |
| 327 | } |
| 328 | return html; |
| 329 | }; |
| 330 | |
| 331 | |
| 332 | /** |
| 333 | * Generates html for the "dash" displayed on the legend when using "legend: always". |
| 334 | * In particular, this works for dashed lines with any stroke pattern. It will |
| 335 | * try to scale the pattern to fit in 1em width. Or if small enough repeat the |
| 336 | * pattern for 1em width. |
| 337 | * |
| 338 | * @param strokePattern The pattern |
| 339 | * @param color The color of the series. |
| 340 | * @param oneEmWidth The width in pixels of 1em in the legend. |
| 341 | * @private |
| 342 | */ |
| 343 | // TODO(danvk): cache the results of this |
| 344 | generateLegendDashHTML = function(strokePattern, color, oneEmWidth) { |
| 345 | // Easy, common case: a solid line |
| 346 | if (!strokePattern || strokePattern.length <= 1) { |
| 347 | return "<div style=\"display: inline-block; position: relative; " + |
| 348 | "bottom: .5ex; padding-left: 1em; height: 1px; " + |
| 349 | "border-bottom: 2px solid " + color + ";\"></div>"; |
| 350 | } |
| 351 | |
| 352 | var i, j, paddingLeft, marginRight; |
| 353 | var strokePixelLength = 0, segmentLoop = 0; |
| 354 | var normalizedPattern = []; |
| 355 | var loop; |
| 356 | |
| 357 | // Compute the length of the pixels including the first segment twice, |
| 358 | // since we repeat it. |
| 359 | for (i = 0; i <= strokePattern.length; i++) { |
| 360 | strokePixelLength += strokePattern[i%strokePattern.length]; |
| 361 | } |
| 362 | |
| 363 | // See if we can loop the pattern by itself at least twice. |
| 364 | loop = Math.floor(oneEmWidth/(strokePixelLength-strokePattern[0])); |
| 365 | if (loop > 1) { |
| 366 | // This pattern fits at least two times, no scaling just convert to em; |
| 367 | for (i = 0; i < strokePattern.length; i++) { |
| 368 | normalizedPattern[i] = strokePattern[i]/oneEmWidth; |
| 369 | } |
| 370 | // Since we are repeating the pattern, we don't worry about repeating the |
| 371 | // first segment in one draw. |
| 372 | segmentLoop = normalizedPattern.length; |
| 373 | } else { |
| 374 | // If the pattern doesn't fit in the legend we scale it to fit. |
| 375 | loop = 1; |
| 376 | for (i = 0; i < strokePattern.length; i++) { |
| 377 | normalizedPattern[i] = strokePattern[i]/strokePixelLength; |
| 378 | } |
| 379 | // For the scaled patterns we do redraw the first segment. |
| 380 | segmentLoop = normalizedPattern.length+1; |
| 381 | } |
| 382 | |
| 383 | // Now make the pattern. |
| 384 | var dash = ""; |
| 385 | for (j = 0; j < loop; j++) { |
| 386 | for (i = 0; i < segmentLoop; i+=2) { |
| 387 | // The padding is the drawn segment. |
| 388 | paddingLeft = normalizedPattern[i%normalizedPattern.length]; |
| 389 | if (i < strokePattern.length) { |
| 390 | // The margin is the space segment. |
| 391 | marginRight = normalizedPattern[(i+1)%normalizedPattern.length]; |
| 392 | } else { |
| 393 | // The repeated first segment has no right margin. |
| 394 | marginRight = 0; |
| 395 | } |
| 396 | dash += "<div style=\"display: inline-block; position: relative; " + |
| 397 | "bottom: .5ex; margin-right: " + marginRight + "em; padding-left: " + |
| 398 | paddingLeft + "em; height: 1px; border-bottom: 2px solid " + color + |
| 399 | ";\"></div>"; |
| 400 | } |
| 401 | } |
| 402 | return dash; |
| 403 | }; |
| 404 | |
| 405 | export default Legend; |