From e2c21500ff552f97a80ed02e027df86aecac3c75 Mon Sep 17 00:00:00 2001 From: Dan Vanderkam Date: Fri, 6 Apr 2012 17:53:05 -0400 Subject: [PATCH] Introduce a plugin system and move the legend to it. This is still pretty rudimentary, but I wanted to get something into the mainline to start getting some real experience with it. This does a few things: 1. Introduces a way to register plugins. 2. Adds some standard methods (e.g. "activate", "destroy"). 3. Allows plugins to register "event listeners" and adds a few hooks. 4. Adds a few new public APIs that were valuable. This is still a work in progress -- plugins/legend.js still uses two private APIs -- but I'm generally really upbeat about this refactor. It was great to see how much even this simple exercise forced the legend code to get simpler and use public APIs. Squashed commit of the following: commit bb250bdb928076c763b83d7ad5a60d15b9187510 Author: Dan Vanderkam Date: Fri Apr 6 17:50:28 2012 -0400 clean a few things up, add plugins/README commit 54328995c79a0091e56d4eda655edbd40e3379e6 Author: Dan Vanderkam Date: Fri Apr 6 17:22:03 2012 -0400 self code review commit d49379470fbe26388bdf46d45aeb5e5cdbc4004d Author: Dan Vanderkam Date: Fri Apr 6 17:01:46 2012 -0400 clean up comments commit 50b731d0559c490771112afc2f0c6c5b50bbeb26 Author: Dan Vanderkam Date: Fri Apr 6 16:52:10 2012 -0400 use public API for highlightSet_ commit 848c0ca24f2302db206e6cf8d2f512432c251813 Author: Dan Vanderkam Date: Fri Apr 6 16:44:54 2012 -0400 factor out generateLegendDashHTML, too commit 4f54b5061cea5845d7d2ca38f09ec1bb3578015b Author: Dan Vanderkam Date: Fri Apr 6 16:37:13 2012 -0400 migrate generateLegendHTML out of dygraph.js; only using one private API commit 060650eeb7007cc32906f1edc62ccfc1ef5b1b54 Author: Dan Vanderkam Date: Fri Apr 6 15:52:24 2012 -0400 misc cleanup commit ce592df0224fe9953848a074582093a113358c9f Merge: f05983c 29cb484 Author: Dan Vanderkam Date: Fri Apr 6 15:35:58 2012 -0400 Merge branch 'master' into plugin-legend commit f05983cdf6f0f2ae4dae4753ad9df53ef89addf2 Merge: 19146fb d254d25 Author: Dan Vanderkam Date: Fri Mar 30 17:58:48 2012 -0400 merge upstream changes commit 19146fb11c9eb7cccc1d971dad67b8175570a3e6 Author: Dan Vanderkam Date: Tue Mar 27 00:34:45 2012 -0400 cleanup commit 72e8cbcc829eca1054d7318cc6b5316cb5061a09 Author: Dan Vanderkam Date: Sun Mar 25 18:34:17 2012 -0400 remove setLegendHTML commit f949d4cedfc3107aa4269d496e0c8106ba86a209 Author: Dan Vanderkam Date: Sun Mar 25 17:23:26 2012 -0400 support labelsDiv; move more logic into legend.js commit 3119af67d70891968982fc2dcc042c3f466ed99e Author: Dan Vanderkam Date: Sun Mar 4 13:18:39 2012 -0500 legend plugin that works! --- dygraph-dev.js | 3 + dygraph-options-reference.js | 4 +- dygraph.js | 406 ++++++++++++++++--------------------------- generate-combined.sh | 3 + plugins/README | 92 ++++++++++ plugins/base.js | 2 + plugins/install.js | 3 + plugins/legend.js | 312 +++++++++++++++++++++++++++++++++ 8 files changed, 568 insertions(+), 257 deletions(-) create mode 100644 plugins/README create mode 100644 plugins/base.js create mode 100644 plugins/install.js create mode 100644 plugins/legend.js diff --git a/dygraph-dev.js b/dygraph-dev.js index f17ee2b..e1d8830 100644 --- a/dygraph-dev.js +++ b/dygraph-dev.js @@ -27,6 +27,9 @@ "dygraph-interaction-model.js", "dygraph-range-selector.js", "dygraph-tickers.js", + "plugins/base.js", + "plugins/legend.js", + "plugins/install.js", "dygraph-options-reference.js" // Shouldn't be included in generate-combined.sh ]; diff --git a/dygraph-options-reference.js b/dygraph-options-reference.js index 0e6b98e..6a72616 100644 --- a/dygraph-options-reference.js +++ b/dygraph-options-reference.js @@ -35,7 +35,7 @@ Dygraph.OPTIONS_REFERENCE = // "default": "null", "labels": ["Legend"], "type": "{}", - "description": "Additional styles to apply to the currently-highlighted points div. For example, { 'font-weight': 'bold' } will make the labels bold." + "description": "Additional styles to apply to the currently-highlighted points div. For example, { 'fontWeight': 'bold' } will make the labels bold. In general, it is better to use CSS to style the .dygraph-legend class than to use this property." }, "drawPoints": { "default": "false", @@ -364,7 +364,7 @@ Dygraph.OPTIONS_REFERENCE = // "labels": ["Data Line display"], "type": "array", "example": "[10, 2, 5, 2]", - "description": "A custom pattern array where the even index is a draw and odd is a space in pixels. If null then it draws a solid line. The array should have a even length as any odd lengthed array could be expressed as a smaller even length array." + "description": "A custom pattern array where the even index is a draw and odd is a space in pixels. If null then it draws a solid line. The array should have a even length as any odd lengthed array could be expressed as a smaller even length array. This is used to create dashed lines." }, "strokeBorderWidth": { "default": "null", diff --git a/dygraph.js b/dygraph.js index da2a11a..9a05677 100644 --- a/dygraph.js +++ b/dygraph.js @@ -288,6 +288,11 @@ Dygraph.DEFAULT_ATTRS = { Dygraph.HORIZONTAL = 1; Dygraph.VERTICAL = 2; +// Installed plugins, in order of precedence (most-general to most-specific). +// Plugins are installed after they are defined, in plugins/install.js. +Dygraph.PLUGINS = [ +]; + // Used for initializing annotation CSS rules only once. Dygraph.addedAnnotationCSS = false; @@ -405,10 +410,89 @@ Dygraph.prototype.__init__ = function(div, file, attrs) { // Create the containing DIV and other interactive elements this.createInterface_(); + // Activate plugins. + this.plugins_ = []; + for (var i = 0; i < Dygraph.PLUGINS.length; i++) { + var plugin = Dygraph.PLUGINS[i]; + var pluginInstance = new plugin(); + var pluginDict = { + plugin: pluginInstance, + events: {}, + options: {}, + pluginOptions: {} + }; + + var registerer = (function(pluginDict) { + return { + addEventListener: function(eventName, callback) { + // TODO(danvk): validate eventName. + pluginDict.events[eventName] = callback; + } + }; + })(pluginDict); + pluginInstance.activate(this, registerer); + // TODO(danvk): prevent activate() from holding a reference to registerer. + + this.plugins_.push(pluginDict); + } + + // At this point, plugins can no longer register event handlers. + // Construct a map from event -> ordered list of [callback, plugin]. + this.eventListeners_ = {}; + for (var i = 0; i < this.plugins_.length; i++) { + var plugin_dict = this.plugins_[i]; + for (var eventName in plugin_dict.events) { + if (!plugin_dict.events.hasOwnProperty(eventName)) continue; + var callback = plugin_dict.events[eventName]; + + var pair = [plugin_dict.plugin, callback]; + if (!(eventName in this.eventListeners_)) { + this.eventListeners_[eventName] = [pair]; + } else { + this.eventListeners_[eventName].push(pair); + } + } + } + this.start_(); }; /** + * Triggers a cascade of events to the various plugins which are interested in them. + * Returns true if the "default behavior" should be performed, i.e. if none of + * the event listeners called event.preventDefault(). + * @private + */ +Dygraph.prototype.cascadeEvents_ = function(name, extra_props) { + if (!name in this.eventListeners_) return true; + + // QUESTION: can we use objects & prototypes to speed this up? + var e = { + dygraph: this, + cancelable: false, + defaultPrevented: false, + preventDefault: function() { + if (!e.cancelable) throw "Cannot call preventDefault on non-cancelable event."; + e.defaultPrevented = true; + }, + propagationStopped: false, + stopPropagation: function() { + propagationStopped = true; + } + }; + Dygraph.update(e, extra_props); + + var callback_plugin_pairs = this.eventListeners_[name]; + for (var i = callback_plugin_pairs.length - 1; i >= 0; i--) { + var plugin = callback_plugin_pairs[i][0]; + var callback = callback_plugin_pairs[i][1]; + callback.call(plugin, e); + if (e.propagationStopped) break; + } + return e.defaultPrevented; +}; + +/** * Returns the zoomed status of the chart for one or both axes. * * Axis is an optional parameter. Can be set to 'x' or 'y'. @@ -483,6 +567,24 @@ Dygraph.prototype.attr_ = function(name, seriesName) { }; /** + * Returns the current value for an option, as set in the constructor or via + * updateOptions. You may pass in an (optional) series name to get per-series + * values for the option. + * + * All values returned by this method should be considered immutable. If you + * modify them, there is no guarantee that the changes will be honored or that + * dygraphs will remain in a consistent state. If you want to modify an option, + * use updateOptions() instead. + * + * @param { String } name The name of the option (e.g. 'strokeWidth') + * @param { String } [opt_seriesName] Series name to get per-series values. + * @return { ... } The value of the option. + */ +Dygraph.prototype.getOption = function(name, opt_seriesName) { + return this.attr_(name, opt_seriesName); +}; + +/** * @private * @param String} axis The name of the axis (i.e. 'x', 'y' or 'y2') * @return { ... } A function mapping string -> option value @@ -858,7 +960,6 @@ Dygraph.prototype.createInterface_ = function() { }; Dygraph.addEvent(this.mouseEventElement_, 'mouseout', this.mouseOutHandler); - this.createStatusMessage_(); this.createDragInterface_(); this.resizeHandler = function(e) { @@ -985,6 +1086,7 @@ Dygraph.prototype.setColors_ = function() { /** * Return the list of colors. This is either the list of colors passed in the * attributes or the autogenerated list of rgb(r,g,b) strings. + * This does not return colors for invisible series. * @return {Array} The list of colors. */ Dygraph.prototype.getColors = function() { @@ -992,61 +1094,32 @@ Dygraph.prototype.getColors = function() { }; /** - * Create the div that contains information on the selected point(s) - * This goes in the top right of the canvas, unless an external div has already - * been specified. - * @private + * Returns a few attributes of a series, i.e. its color, its visibility, which + * axis it's assigned to, and its column in the original data. + * Returns null if the series does not exist. + * Otherwise, returns an object with column, visibility, color and axis properties. + * The "axis" property will be set to 1 for y1 and 2 for y2. + * The "column" property can be fed back into getValue(row, column) to get + * values for this series. */ -Dygraph.prototype.createStatusMessage_ = function() { - var userLabelsDiv = this.user_attrs_.labelsDiv; - if (userLabelsDiv && null !== userLabelsDiv && - (typeof(userLabelsDiv) == "string" || userLabelsDiv instanceof String)) { - this.user_attrs_.labelsDiv = document.getElementById(userLabelsDiv); - } - if (!this.attr_("labelsDiv")) { - var divWidth = this.attr_('labelsDivWidth'); - var messagestyle = { - "position": "absolute", - "fontSize": "14px", - "zIndex": 10, - "width": divWidth + "px", - "top": "0px", - "left": (this.width_ - divWidth - 2) + "px", - "background": "white", - "lineHeight": "normal", - "textAlign": "left", - "overflow": "hidden"}; - Dygraph.update(messagestyle, this.attr_('labelsDivStyles')); - var div = document.createElement("div"); - div.className = "dygraph-legend"; - for (var name in messagestyle) { - if (messagestyle.hasOwnProperty(name)) { - try { - div.style[name] = messagestyle[name]; - } catch (e) { - this.warn("You are using unsupported css properties for your browser in labelsDivStyles"); - } - } +Dygraph.prototype.getPropertiesForSeries = function(series_name) { + var idx = -1; + var labels = this.getLabels(); + for (var i = 1; i < labels.length; i++) { + if (labels[i] == series_name) { + idx = i; + break; } - this.graphDiv.appendChild(div); - this.attrs_.labelsDiv = div; } -}; - -/** - * Position the labels div so that: - * - its right edge is flush with the right edge of the charting area - * - its top edge is flush with the top edge of the charting area - * @private - */ -Dygraph.prototype.positionLabelsDiv_ = function() { - // Don't touch a user-specified labelsDiv. - if (this.user_attrs_.hasOwnProperty("labelsDiv")) return; + if (idx == -1) return null; - var area = this.plotter_.area; - var div = this.attr_("labelsDiv"); - div.style.left = area.x + area.w - this.attr_("labelsDivWidth") - 1 + "px"; - div.style.top = area.y + "px"; + return { + name: series_name, + column: idx, + visible: this.visibility()[idx - 1], + color: this.plotter_.colors[series_name], + axis: 1 + this.seriesToAxisMap_[series_name] + }; }; /** @@ -1670,6 +1743,7 @@ Dygraph.prototype.mouseMove_ = function(event) { /** * Fetch left offset from first defined boundaryIds record (see bug #236). + * @private */ Dygraph.prototype.getLeftBoundary_ = function() { for (var i = 0; i < this.boundaryIds_.length; i++) { @@ -1700,171 +1774,6 @@ Dygraph.prototype.idxToRow_ = function(idx) { return -1; }; -/** - * @private - * Generates legend html dash for any stroke pattern. It will try to scale the - * pattern to fit in 1em width. Or if small enough repeat the partern for 1em - * width. - * @param strokePattern The pattern - * @param color The color of the series. - * @param oneEmWidth The width in pixels of 1em in the legend. - */ -Dygraph.prototype.generateLegendDashHTML_ = function(strokePattern, color, oneEmWidth) { - var dash = ""; - var i, j, paddingLeft, marginRight; - var strokePixelLength = 0, segmentLoop = 0; - var normalizedPattern = []; - var loop; - // IE 7,8 fail at these divs, so they get boring legend, have not tested 9. - var isIE = (/MSIE/.test(navigator.userAgent) && !window.opera); - if(isIE) { - return "—"; - } - if (!strokePattern || strokePattern.length <= 1) { - // Solid line - dash = "
"; - } else { - // Compute the length of the pixels including the first segment twice, - // since we repeat it. - for (i = 0; i <= strokePattern.length; i++) { - strokePixelLength += strokePattern[i%strokePattern.length]; - } - - // See if we can loop the pattern by itself at least twice. - loop = Math.floor(oneEmWidth/(strokePixelLength-strokePattern[0])); - if (loop > 1) { - // This pattern fits at least two times, no scaling just convert to em; - for (i = 0; i < strokePattern.length; i++) { - normalizedPattern[i] = strokePattern[i]/oneEmWidth; - } - // Since we are repeating the pattern, we don't worry about repeating the - // first segment in one draw. - segmentLoop = normalizedPattern.length; - } else { - // If the pattern doesn't fit in the legend we scale it to fit. - loop = 1; - for (i = 0; i < strokePattern.length; i++) { - normalizedPattern[i] = strokePattern[i]/strokePixelLength; - } - // For the scaled patterns we do redraw the first segment. - segmentLoop = normalizedPattern.length+1; - } - // Now make the pattern. - for (j = 0; j < loop; j++) { - for (i = 0; i < segmentLoop; i+=2) { - // The padding is the drawn segment. - paddingLeft = normalizedPattern[i%normalizedPattern.length]; - if (i < strokePattern.length) { - // The margin is the space segment. - marginRight = normalizedPattern[(i+1)%normalizedPattern.length]; - } else { - // The repeated first segment has no right margin. - marginRight = 0; - } - dash += "
"; - } - } - } - return dash; -}; - -/** - * @private - * Generates HTML for the legend which is displayed when hovering over the - * chart. If no selected points are specified, a default legend is returned - * (this may just be the empty string). - * @param { Number } [x] The x-value of the selected points. - * @param { [Object] } [sel_points] List of selected points for the given - * x-value. Should have properties like 'name', 'yval' and 'canvasy'. - * @param { Number } [oneEmWidth] The pixel width for 1em in the legend. - */ -Dygraph.prototype.generateLegendHTML_ = function(x, sel_points, oneEmWidth) { - // If no points are selected, we display a default legend. Traditionally, - // this has been blank. But a better default would be a conventional legend, - // which provides essential information for a non-interactive chart. - var html, sepLines, i, c, dash, strokePattern; - if (typeof(x) === 'undefined') { - if (this.attr_('legend') != 'always') return ''; - - sepLines = this.attr_('labelsSeparateLines'); - var labels = this.attr_('labels'); - html = ''; - for (i = 1; i < labels.length; i++) { - if (!this.visibility()[i - 1]) continue; - c = this.plotter_.colors[labels[i]]; - if (html !== '') html += (sepLines ? '
' : ' '); - strokePattern = this.attr_("strokePattern", labels[i]); - dash = this.generateLegendDashHTML_(strokePattern, c, oneEmWidth); - html += "" + dash + - " " + labels[i] + ""; - } - return html; - } - - var xOptView = this.optionsViewForAxis_('x'); - var xvf = xOptView('valueFormatter'); - html = xvf(x, xOptView, this.attr_('labels')[0], this) + ":"; - - var yOptViews = []; - var num_axes = this.numAxes(); - for (i = 0; i < num_axes; i++) { - yOptViews[i] = this.optionsViewForAxis_('y' + (i ? 1 + i : '')); - } - var showZeros = this.attr_("labelsShowZeroValues"); - sepLines = this.attr_("labelsSeparateLines"); - for (i = 0; i < this.selPoints_.length; i++) { - var pt = this.selPoints_[i]; - if (pt.yval === 0 && !showZeros) continue; - if (!Dygraph.isOK(pt.canvasy)) continue; - if (sepLines) html += "
"; - - var yOptView = yOptViews[this.seriesToAxisMap_[pt.name]]; - var fmtFunc = yOptView('valueFormatter'); - c = this.plotter_.colors[pt.name]; - var yval = fmtFunc(pt.yval, yOptView, pt.name, this); - - var cls = (pt.name == this.highlightSet_) ? " class='highlight'" : ""; - // TODO(danvk): use a template string here and make it an attribute. - html += "" + " " + pt.name + - ":" + yval + ""; - } - return html; -}; - -/** - * @private - * Displays information about the selected points in the legend. If there is no - * selection, the legend will be cleared. - * @param { Number } [x] The x-value of the selected points. - * @param { [Object] } [sel_points] List of selected points for the given - * x-value. Should have properties like 'name', 'yval' and 'canvasy'. - */ -Dygraph.prototype.setLegendHTML_ = function(x, sel_points) { - var labelsDiv = this.attr_("labelsDiv"); - if (!labelsDiv) return; - - var sizeSpan = document.createElement('span'); - // Calculates the width of 1em in pixels for the legend. - sizeSpan.setAttribute('style', 'margin: 0; padding: 0 0 0 1em; border: 0;'); - labelsDiv.appendChild(sizeSpan); - var oneEmWidth=sizeSpan.offsetWidth; - - var html = this.generateLegendHTML_(x, sel_points, oneEmWidth); - if (labelsDiv !== null) { - labelsDiv.innerHTML = html; - } else { - if (typeof(this.shown_legend_error_) == 'undefined') { - this.error('labelsDiv is set to something nonexistent; legend will not be shown.'); - this.shown_legend_error_ = true; - } - } -}; - Dygraph.prototype.animateSelection_ = function(direction) { var totalSteps = 10; var millis = 30; @@ -1902,6 +1811,12 @@ Dygraph.prototype.animateSelection_ = function(direction) { * @private */ Dygraph.prototype.updateSelection_ = function(opt_animFraction) { + var defaultPrevented = this.cascadeEvents_('select', { + selectedX: this.lastx_, + selectedPoints: this.selPoints_ + }); + // TODO(danvk): use defaultPrevented here? + // Clear the previously drawn vertical, if there is one var i; var ctx = this.canvas_ctx_; @@ -1944,11 +1859,6 @@ Dygraph.prototype.updateSelection_ = function(opt_animFraction) { } if (this.selPoints_.length > 0) { - // Set the status message to indicate the selected point(s) - if (this.attr_('showLabelsOnHighlight')) { - this.setLegendHTML_(this.lastx_, this.selPoints_); - } - // Draw colored circles over the center of each selected point var canvasx = this.selPoints_[0].canvasx; ctx.save(); @@ -2051,6 +1961,8 @@ Dygraph.prototype.mouseOut_ = function(event) { * the mouse over the chart). */ Dygraph.prototype.clearSelection = function() { + this.cascadeEvents_('deselect', {}); + // Get rid of the overlay data if (this.fadeLevel) { this.animateSelection_(-1); @@ -2058,7 +1970,6 @@ Dygraph.prototype.clearSelection = function() { } this.canvas_ctx_.clearRect(0, 0, this.width_, this.height_); this.fadeLevel = 0; - this.setLegendHTML_(); this.selPoints_ = []; this.lastx_ = -1; this.lastRow_ = -1; @@ -2083,6 +1994,10 @@ Dygraph.prototype.getSelection = function() { return -1; }; +/** + * Returns the name of the currently-highlighted series. + * Only available when the highlightSeriesOpts option is in use. + */ Dygraph.prototype.getHighlightSeries = function() { return this.highlightSet_; }; @@ -2190,10 +2105,7 @@ Dygraph.prototype.predraw_ = function() { // this will be until the options are available, so it's positioned here. this.createRollInterface_(); - // Same thing applies for the labelsDiv. It's right edge should be flush with - // the right edge of the charting area (which may not be the same as the right - // edge of the div, if we have two y-axes. - this.positionLabelsDiv_(); + this.cascadeEvents_('predraw'); if (this.rangeSelector_) { this.rangeSelector_.renderStaticLayer(); @@ -2352,20 +2264,11 @@ Dygraph.prototype.gatherDatasets_ = function(rolledSeries, dateWindow) { * has changed. If the underlying data or options have changed, predraw_ will * be called before drawGraph_ is called. * - * clearSelection, when undefined or true, causes this.clearSelection to be - * called at the end of the draw operation. This should rarely be defined, - * and never true (that is it should be undefined most of the time, and - * rarely false.) - * * @private */ -Dygraph.prototype.drawGraph_ = function(clearSelection) { +Dygraph.prototype.drawGraph_ = function() { var start = new Date(); - if (typeof(clearSelection) === 'undefined') { - clearSelection = true; - } - // This is used to set the second parameter to drawCallback, below. var is_initial_draw = this.is_initial_draw_; this.is_initial_draw_ = false; @@ -2403,7 +2306,7 @@ Dygraph.prototype.drawGraph_ = function(clearSelection) { this.layout_.setDateWindow(this.dateWindow_); this.zoomed_x_ = tmp_zoomed_x; this.layout_.evaluateWithError(); - this.renderGraph_(is_initial_draw, false); + this.renderGraph_(is_initial_draw); if (this.attr_("timingName")) { var end = new Date(); @@ -2413,32 +2316,25 @@ Dygraph.prototype.drawGraph_ = function(clearSelection) { } }; -Dygraph.prototype.renderGraph_ = function(is_initial_draw, clearSelection) { +/** + * This does the work of drawing the chart. It assumes that the layout and axis + * scales have already been set (e.g. by predraw_). + * + * @private + */ +Dygraph.prototype.renderGraph_ = function(is_initial_draw) { this.plotter_.clear(); this.plotter_.render(); this.canvas_.getContext('2d').clearRect(0, 0, this.canvas_.width, this.canvas_.height); // Generate a static legend before any particular point is selected. - this.setLegendHTML_(); - - if (!is_initial_draw) { - if (clearSelection) { - if (typeof(this.selPoints_) !== 'undefined' && this.selPoints_.length) { - // We should select the point nearest the page x/y here, but it's easier - // to just clear the selection. This prevents erroneous hover dots from - // being displayed. - this.clearSelection(); - } else { - this.clearSelection(); - } - } - } if (this.rangeSelector_) { this.rangeSelector_.renderInteractiveLayer(); } + this.cascadeEvents_('drawChart'); if (this.attr_("drawCallback") !== null) { this.attr_("drawCallback")(this, is_initial_draw); } @@ -3386,7 +3282,7 @@ Dygraph.prototype.updateOptions = function(input_attrs, block_redraw) { if (requiresNewPoints) { this.predraw_(); } else { - this.renderGraph_(false, false); + this.renderGraph_(false); } } } @@ -3557,7 +3453,7 @@ Dygraph.prototype.annotations = function() { * Get the list of label names for this graph. The first column is the * x-axis, so the data series names start at index 1. */ -Dygraph.prototype.getLabels = function(name) { +Dygraph.prototype.getLabels = function() { return this.attr_("labels").slice(); }; diff --git a/generate-combined.sh b/generate-combined.sh index ea9f92b..d38d220 100755 --- a/generate-combined.sh +++ b/generate-combined.sh @@ -16,6 +16,9 @@ dygraph-range-selector.js \ dygraph-tickers.js \ rgbcolor/rgbcolor.js \ strftime/strftime-min.js \ +plugins/base.js \ +plugins/legend.js \ +plugins/install.js \ | perl -ne 'print unless m,REMOVE_FOR_COMBINED,..m,/REMOVE_FOR_COMBINED,' \ > /tmp/dygraph.js diff --git a/plugins/README b/plugins/README new file mode 100644 index 0000000..60ddd04 --- /dev/null +++ b/plugins/README @@ -0,0 +1,92 @@ +dygraphs plugins +---------------- + +A single dygraph is actually a collection of dygraphs plugins, each responsible +for some portion of the chart: the plot area, the axes, the legend, the labels, +etc. + +This forces the dygraphs code to be more modular and encourages better APIs. + +The "legend" plugin (plugins/legend.js) can be used as a template for new +plugins. + +Here is a simplified version of it, with comments to explain the plugin +lifecycle and APIs: + +---------------- + +// (standard JavaScript library wrapper; prevents polluting global namespace) +Dygraph.Plugins.Legend = (function() { + +// Plugin constructor. This is invoked once for every chart which uses the +// plugin. You can't actually access the Dygraph object at this point, so the +// initialization you do here shouldn't be chart-specific. (For that, use +// the activate() method). +var legend = function() { + this.div_ = null; +}; + +// Plugins are expected to implement this method for debugging purposes. +legend.toString = function() { + return "Legend"; +}; + +// This is called once the dygraph is ready. The chart data may not be +// available yet, but the options specified in the constructor are. +// +// Proper tasks to do here include: +// - Reading your own options +// - DOM manipulation +// - Registering event listeners +// +// "dygraph" is the Dygraph object for which this instance is being activated. +// "registerer" allows you to register event listeners. +legend.prototype.activate = function(dygraph, registerer) { + // Create the legend div and attach it to the chart DOM. + this.div_ = document.createElement("div"); + dygraph.graphDiv.appendChild(this.div_); + + // Add event listeners. These will be called whenever points are selected + // (i.e. you hover over them) or deselected (i.e. you mouse away from the + // chart). This is your only chance to register event listeners! Once this + // method returns, the gig is up. + registerer.addEventListener('select', legend.select); + registerer.addEventListener('deselect', legend.deselect); +}; + +// The functions called by event listeners all take a single parameter, an +// event object. This contains properties relevant to the particular event, but +// you can always assume that it has: +// +// 1. A "dygraph" parameter which is a reference to the chart on which the +// event took place. +// 2. A "stopPropagation" method, which you can call to prevent the event from +// being seen by any other plugins after you. This effectively cancels the +// event. +// 3. A "preventDefault" method, which prevents dygraphs from performing the +// default action associated with this event. +// +legend.select = function(e) { + // These are two of the properties specific to the "select" event object: + var xValue = e.selectedX; + var points = e.selectedPoints; + + var html = xValue + ':'; + for (var i = 0; i < points.length; i++) { + var point = points[i]; + html += ' ' + point.name + ':' + point.yval; + } + + // In an event listener, "this" refers to your plugin object. + this.div_.innerHTML = html; +}; + +// This clears out the legend when the user mouses away from the chart. +legend.deselect = function(e) { + this.div_.innerHTML = ''; +}; + +return legend; +})(); + +---------------- diff --git a/plugins/base.js b/plugins/base.js new file mode 100644 index 0000000..5ae917d --- /dev/null +++ b/plugins/base.js @@ -0,0 +1,2 @@ +// Namespace for plugins. +Dygraph.Plugins = {}; diff --git a/plugins/install.js b/plugins/install.js new file mode 100644 index 0000000..14aaefa --- /dev/null +++ b/plugins/install.js @@ -0,0 +1,3 @@ +Dygraph.PLUGINS.push( + Dygraph.Plugins.Legend +); diff --git a/plugins/legend.js b/plugins/legend.js new file mode 100644 index 0000000..bfc0595 --- /dev/null +++ b/plugins/legend.js @@ -0,0 +1,312 @@ +Dygraph.Plugins.Legend = (function() { + +/* + +Current bits of jankiness: +- Uses two private APIs: + 1. Dygraph.optionsViewForAxis_ + 2. dygraph.plotter_.area +- Registers for a "predraw" event, which should be renamed. +- I call calculateEmWidthInDiv more often than needed. +- Why can't I call "this.deselect(e)" instead of "legend.deselect.call(this, e)"? + +*/ + +"use strict"; + + +/** + * Creates the legend, which appears when the user hovers over the chart. + * The legend can be either a user-specified or generated div. + * + * @constructor + */ +var legend = function() { + this.legend_div_ = null; + this.is_generated_div_ = false; // do we own this div, or was it user-specified? +}; + +legend.prototype.toString = function() { + return "Legend Plugin"; +}; + +/** + * This is called during the dygraph constructor, after options have been set + * but before the data is available. + * + * Proper tasks to do here include: + * - Reading your own options + * - DOM manipulation + * - Registering event listeners + */ +legend.prototype.activate = function(g, r) { + var div; + var divWidth = g.getOption('labelsDivWidth'); + + var userLabelsDiv = g.getOption('labelsDiv'); + if (userLabelsDiv && null !== userLabelsDiv) { + if (typeof(userLabelsDiv) == "string" || userLabelsDiv instanceof String) { + div = document.getElementById(userLabelsDiv); + } else { + div = userLabelsDiv; + } + } else { + // Default legend styles. These can be overridden in CSS by adding + // "!important" after your rule, e.g. "left: 30px !important;" + var messagestyle = { + "position": "absolute", + "fontSize": "14px", + "zIndex": 10, + "width": divWidth + "px", + "top": "0px", + "right": "2px", + "background": "white", + "textAlign": "left", + "overflow": "hidden"}; + + // TODO(danvk): get rid of labelsDivStyles? CSS is better. + Dygraph.update(messagestyle, g.getOption('labelsDivStyles')); + div = document.createElement("div"); + div.className = "dygraph-legend"; + for (var name in messagestyle) { + if (!messagestyle.hasOwnProperty(name)) continue; + + try { + div.style[name] = messagestyle[name]; + } catch (e) { + this.warn("You are using unsupported css properties for your " + + "browser in labelsDivStyles"); + } + } + + // TODO(danvk): come up with a cleaner way to expose this. + g.graphDiv.appendChild(div); + this.is_generated_div_ = true; + } + + this.legend_div_ = div; + + r.addEventListener('select', legend.select); + r.addEventListener('deselect', legend.deselect); + + // TODO(danvk): rethink the name "predraw" before we commit to it in any API. + r.addEventListener('predraw', legend.predraw); + r.addEventListener('drawChart', legend.drawChart); +}; + +// Needed for dashed lines. +var calculateEmWidthInDiv = function(div) { + var sizeSpan = document.createElement('span'); + sizeSpan.setAttribute('style', 'margin: 0; padding: 0 0 0 1em; border: 0;'); + div.appendChild(sizeSpan); + var oneEmWidth=sizeSpan.offsetWidth; + div.removeChild(sizeSpan); + return oneEmWidth; +}; + +legend.select = function(e) { + var xValue = e.selectedX; + var points = e.selectedPoints; + + // Have to do this every time, since styles might have changed. + // TODO(danvk): this is not necessary; dashes never used in this case. + var oneEmWidth = calculateEmWidthInDiv(this.legend_div_); + + var html = generateLegendHTML(e.dygraph, xValue, points, oneEmWidth); + this.legend_div_.innerHTML = html; +}; + +legend.deselect = function(e) { + var oneEmWidth = calculateEmWidthInDiv(this.legend_div_); + var html = generateLegendHTML(e.dygraph, undefined, undefined, oneEmWidth); + this.legend_div_.innerHTML = html; +}; + +legend.drawChart = function(e) { + // TODO(danvk): why doesn't this.deselect(e) work here? + legend.deselect.call(this, e); +} + +// Right edge should be flush with the right edge of the charting area (which +// may not be the same as the right edge of the div, if we have two y-axes. +// TODO(danvk): is any of this really necessary? Could just set "right" in "activate". +/** + * Position the labels div so that: + * - its right edge is flush with the right edge of the charting area + * - its top edge is flush with the top edge of the charting area + * @private + */ +legend.predraw = function(e) { + // Don't touch a user-specified labelsDiv. + if (!this.is_generated_div_) return; + + // TODO(danvk): only use real APIs for this. + var area = e.dygraph.plotter_.area; + this.legend_div_.style.left = area.x + area.w - e.dygraph.getOption("labelsDivWidth") - 1 + "px"; + this.legend_div_.style.top = area.y + "px"; +}; + +/** + * Called when dygraph.destroy() is called. + * You should null out any references and detach any DOM elements. + */ +legend.prototype.destroy = function() { + this.legend_div_ = null; +}; + +/** + * @private + * Generates HTML for the legend which is displayed when hovering over the + * chart. If no selected points are specified, a default legend is returned + * (this may just be the empty string). + * @param { Number } [x] The x-value of the selected points. + * @param { [Object] } [sel_points] List of selected points for the given + * x-value. Should have properties like 'name', 'yval' and 'canvasy'. + * @param { Number } [oneEmWidth] The pixel width for 1em in the legend. Only + * relevant when displaying a legend with no selection (i.e. {legend: + * 'always'}) and with dashed lines. + */ +var generateLegendHTML = function(g, x, sel_points, oneEmWidth) { + // TODO(danvk): deprecate this option in place of {legend: 'never'} + if (g.getOption('showLabelsOnHighlight') !== true) return ''; + + // If no points are selected, we display a default legend. Traditionally, + // this has been blank. But a better default would be a conventional legend, + // which provides essential information for a non-interactive chart. + var html, sepLines, i, c, dash, strokePattern; + var labels = g.getLabels(); + + if (typeof(x) === 'undefined') { + if (g.getOption('legend') != 'always') { + return ''; + } + + sepLines = g.getOption('labelsSeparateLines'); + html = ''; + for (i = 1; i < labels.length; i++) { + var series = g.getPropertiesForSeries(labels[i]); + if (!series.visible) continue; + + if (html !== '') html += (sepLines ? '
' : ' '); + strokePattern = g.getOption("strokePattern", labels[i]); + dash = generateLegendDashHTML(strokePattern, series.color, oneEmWidth); + html += "" + + dash + " " + labels[i] + ""; + } + return html; + } + + // TODO(danvk): remove this use of a private API + var xOptView = g.optionsViewForAxis_('x'); + var xvf = xOptView('valueFormatter'); + html = xvf(x, xOptView, labels[0], g) + ":"; + + var yOptViews = []; + var num_axes = g.numAxes(); + for (i = 0; i < num_axes; i++) { + // TODO(danvk): remove this use of a private API + yOptViews[i] = g.optionsViewForAxis_('y' + (i ? 1 + i : '')); + } + var showZeros = g.getOption("labelsShowZeroValues"); + sepLines = g.getOption("labelsSeparateLines"); + var highlightSeries = g.getHighlightSeries(); + for (i = 0; i < sel_points.length; i++) { + var pt = sel_points[i]; + if (pt.yval === 0 && !showZeros) continue; + if (!Dygraph.isOK(pt.canvasy)) continue; + if (sepLines) html += "
"; + + var series = g.getPropertiesForSeries(pt.name); + var yOptView = yOptViews[series.axis - 1]; + var fmtFunc = yOptView('valueFormatter'); + var yval = fmtFunc(pt.yval, yOptView, pt.name, g); + + var cls = (pt.name == highlightSeries) ? " class='highlight'" : ""; + + // TODO(danvk): use a template string here and make it an attribute. + html += "" + " " + + pt.name + ":" + yval + ""; + } + return html; +}; + + +/** + * Generates html for the "dash" displayed on the legend when using "legend: always". + * In particular, this works for dashed lines with any stroke pattern. It will + * try to scale the pattern to fit in 1em width. Or if small enough repeat the + * pattern for 1em width. + * + * @param strokePattern The pattern + * @param color The color of the series. + * @param oneEmWidth The width in pixels of 1em in the legend. + * @private + */ +var generateLegendDashHTML = function(strokePattern, color, oneEmWidth) { + // IE 7,8 fail at these divs, so they get boring legend, have not tested 9. + var isIE = (/MSIE/.test(navigator.userAgent) && !window.opera); + if (isIE) return "—"; + + // Easy, common case: a solid line + if (!strokePattern || strokePattern.length <= 1) { + return "
"; + } + + var i, j, paddingLeft, marginRight; + var strokePixelLength = 0, segmentLoop = 0; + var normalizedPattern = []; + var loop; + + // Compute the length of the pixels including the first segment twice, + // since we repeat it. + for (i = 0; i <= strokePattern.length; i++) { + strokePixelLength += strokePattern[i%strokePattern.length]; + } + + // See if we can loop the pattern by itself at least twice. + loop = Math.floor(oneEmWidth/(strokePixelLength-strokePattern[0])); + if (loop > 1) { + // This pattern fits at least two times, no scaling just convert to em; + for (i = 0; i < strokePattern.length; i++) { + normalizedPattern[i] = strokePattern[i]/oneEmWidth; + } + // Since we are repeating the pattern, we don't worry about repeating the + // first segment in one draw. + segmentLoop = normalizedPattern.length; + } else { + // If the pattern doesn't fit in the legend we scale it to fit. + loop = 1; + for (i = 0; i < strokePattern.length; i++) { + normalizedPattern[i] = strokePattern[i]/strokePixelLength; + } + // For the scaled patterns we do redraw the first segment. + segmentLoop = normalizedPattern.length+1; + } + + // Now make the pattern. + var dash = ""; + for (j = 0; j < loop; j++) { + for (i = 0; i < segmentLoop; i+=2) { + // The padding is the drawn segment. + paddingLeft = normalizedPattern[i%normalizedPattern.length]; + if (i < strokePattern.length) { + // The margin is the space segment. + marginRight = normalizedPattern[(i+1)%normalizedPattern.length]; + } else { + // The repeated first segment has no right margin. + marginRight = 0; + } + dash += "
"; + } + } + return dash; +}; + + +return legend; +})(); -- 2.7.4