8b918412089c1b3dd0b94cdc3c75495c74076a61
[dygraphs.git] / src / plugins / legend.js
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 /**
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
46 *
47 * @param {Dygraph} g Graph instance.
48 * @return {object.<string, function(ev)>} Mapping of event names to callbacks.
49 */
50 Legend.prototype.activate = function(g) {
51 var div;
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 {
61 div = document.createElement("div");
62 div.className = "dygraph-legend";
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;
69 this.one_em_width_ = 10; // just a guess, will be updated.
70
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,
76 didDrawChart: this.didDrawChart
77 };
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
90 var escapeHTML = function(str) {
91 return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
92 };
93
94 Legend.prototype.select = function(e) {
95 var xValue = e.selectedX;
96 var points = e.selectedPoints;
97 var row = e.selectedRow;
98
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') {
106 // create floating legend div
107 var area = e.dygraph.plotter_.area;
108 var labelsDivWidth = this.legend_div.offsetWidth;
109 var yAxisLabelWidth = e.dygraph.getOptionForAxis('axisLabelWidth', 'y');
110 // determine floating [left, top] coordinates of the legend div
111 // within the plotter_ area
112 // offset 50 px to the right and down from the first selection point
113 // 50 px is guess based on mouse cursor size
114 var leftLegend = points[0].x * area.w + 50;
115 var topLegend = points[0].y * area.h - 50;
116
117 // if legend floats to end of the chart area, it flips to the other
118 // side of the selection point
119 if ((leftLegend + labelsDivWidth + 1) > area.w) {
120 leftLegend = leftLegend - 2 * 50 - labelsDivWidth - (yAxisLabelWidth - area.x);
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
128 var html = Legend.generateLegendHTML(e.dygraph, xValue, points, this.one_em_width_, row);
129 this.legend_div_.innerHTML = html;
130 this.legend_div_.style.display = '';
131 };
132
133 Legend.prototype.deselect = function(e) {
134 var legendMode = e.dygraph.getOption('legend');
135 if (legendMode !== 'always') {
136 this.legend_div_.style.display = "none";
137 }
138
139 // Have to do this every time, since styles might have changed.
140 var oneEmWidth = calculateEmWidthInDiv(this.legend_div_);
141 this.one_em_width_ = oneEmWidth;
142
143 var html = Legend.generateLegendHTML(e.dygraph, undefined, undefined, oneEmWidth, null);
144 this.legend_div_.innerHTML = html;
145 };
146
147 Legend.prototype.didDrawChart = function(e) {
148 this.deselect(e);
149 };
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 */
160 Legend.prototype.predraw = function(e) {
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.
165 e.dygraph.graphDiv.appendChild(this.legend_div_);
166 var area = e.dygraph.getArea();
167 var labelsDivWidth = this.legend_div_.offsetWidth;
168 this.legend_div_.style.left = area.x + area.w - labelsDivWidth - 1 + "px";
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 */
176 Legend.prototype.destroy = function() {
177 this.legend_div_ = null;
178 };
179
180 /**
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).
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.
191 * @private
192 */
193 Legend.generateLegendHTML = function(g, x, sel_points, oneEmWidth, row) {
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
239 if ((pt.yval === 0 && !showZeros) || isNaN(pt.canvasy)) {
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
264 // TODO(danvk): deprecate this option in place of {legend: 'never'}
265 // XXX should this logic be in the formatter?
266 if (g.getOption('showLabelsOnHighlight') !== true) return '';
267
268 var sepLines = g.getOption('labelsSeparateLines');
269 var html;
270
271 if (typeof(data.x) === 'undefined') {
272 // TODO: this check is duplicated in generateLegendHTML. Put it in one place.
273 if (g.getOption('legend') != 'always') {
274 return '';
275 }
276
277 html = '';
278 for (var i = 0; i < data.series.length; i++) {
279 var series = data.series[i];
280 if (!series.isVisible) continue;
281
282 if (html !== '') html += (sepLines ? '<br/>' : ' ');
283 html += `<span style='font-weight: bold; color: ${series.color};'>${series.dashHTML} ${series.labelHTML}</span>`;
284 }
285 return html;
286 }
287
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>:&#160;${series.yHTML}</span>`;
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 */
311 // TODO(danvk): cache the results of this
312 function generateLegendDashHTML(strokePattern, color, oneEmWidth) {
313 // Easy, common case: a solid line
314 if (!strokePattern || strokePattern.length <= 1) {
315 return `<div class="dygraph-legend-line" style="border-bottom-color: ${color};"></div>`;
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 }
362 dash += `<div class="dygraph-legend-dash" style="margin-right: ${marginRight}em; padding-left: ${paddingLeft}em;"></div>`;
363 }
364 }
365 return dash;
366 };
367
368 export default Legend;