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