X-Git-Url: https://adrianiainlam.tk/git/?a=blobdiff_plain;f=dygraph.js;h=4f9216d3c0c848e518f5d4dab32dfcf75d6fd08e;hb=88e95c462340958bdfd0ac4c0736b94c5fd21024;hp=6bcb0e2ca02f01b9b73b5cb5ad86ed92dd53cc33;hpb=8a7cc60e98491e33ffc56f16e43cf4a5d8d7d53b;p=dygraphs.git diff --git a/dygraph.js b/dygraph.js index 6bcb0e2..66d32fa 100644 --- a/dygraph.js +++ b/dygraph.js @@ -1,5 +1,8 @@ -// Copyright 2006 Dan Vanderkam (danvdk@gmail.com) -// All Rights Reserved. +/** + * @license + * Copyright 2006 Dan Vanderkam (danvdk@gmail.com) + * MIT-licensed (http://opensource.org/licenses/MIT) + */ /** * @fileoverview Creates an interactive, zoomable graph based on a CSV file or @@ -24,7 +27,6 @@ If the 'errorBars' option is set in the constructor, the input should be of the form - Date,SeriesA,SeriesB,... YYYYMMDD,A1,sigmaA1,B1,sigmaB1,... YYYYMMDD,A2,sigmaA2,B2,sigmaB2,... @@ -37,18 +39,23 @@ And error bars will be calculated automatically using a binomial distribution. - For further documentation and examples, see http://www.danvk.org/dygraphs + For further documentation and examples, see http://dygraphs.com/ */ /** - * An interactive, zoomable graph - * @param {String | Function} file A file containing CSV data or a function that - * returns this data. The expected format for each line is - * YYYYMMDD,val1,val2,... or, if attrs.errorBars is set, - * YYYYMMDD,val1,stddev1,val2,stddev2,... + * Creates an interactive, zoomable chart. + * + * @constructor + * @param {div | String} div A div or the id of a div into which to construct + * the chart. + * @param {String | Function} file A file containing CSV data or a function + * that returns this data. The most basic expected format for each line is + * "YYYY/MM/DD,val1,val2,...". For more information, see + * http://dygraphs.com/data.html. * @param {Object} attrs Various other attributes, e.g. errorBars determines - * whether the input data contains error ranges. + * whether the input data contains error ranges. For a complete list of + * options, see http://dygraphs.com/options.html. */ Dygraph = function(div, data, opts) { if (arguments.length > 0) { @@ -69,6 +76,10 @@ Dygraph.VERSION = "1.2"; Dygraph.__repr__ = function() { return "[" + this.NAME + " " + this.VERSION + "]"; }; + +/** + * Returns information about the Dygraph class. + */ Dygraph.toString = function() { return this.__repr__(); }; @@ -77,13 +88,97 @@ Dygraph.toString = function() { Dygraph.DEFAULT_ROLL_PERIOD = 1; Dygraph.DEFAULT_WIDTH = 480; Dygraph.DEFAULT_HEIGHT = 320; -Dygraph.AXIS_LINE_WIDTH = 0.3; + +// These are defined before DEFAULT_ATTRS so that it can refer to them. +/** + * @private + * Return a string version of a number. This respects the digitsAfterDecimal + * and maxNumberWidth options. + * @param {Number} x The number to be formatted + * @param {Dygraph} opts An options view + * @param {String} name The name of the point's data series + * @param {Dygraph} g The dygraph object + */ +Dygraph.numberValueFormatter = function(x, opts, pt, g) { + var sigFigs = opts('sigFigs'); + + if (sigFigs !== null) { + // User has opted for a fixed number of significant figures. + return Dygraph.floatFormat(x, sigFigs); + } + + var digits = opts('digitsAfterDecimal'); + var maxNumberWidth = opts('maxNumberWidth'); + + // switch to scientific notation if we underflow or overflow fixed display. + if (x !== 0.0 && + (Math.abs(x) >= Math.pow(10, maxNumberWidth) || + Math.abs(x) < Math.pow(10, -digits))) { + return x.toExponential(digits); + } else { + return '' + Dygraph.round_(x, digits); + } +}; + +/** + * variant for use as an axisLabelFormatter. + * @private + */ +Dygraph.numberAxisLabelFormatter = function(x, granularity, opts, g) { + return Dygraph.numberValueFormatter(x, opts, g); +}; + +/** + * Convert a JS date (millis since epoch) to YYYY/MM/DD + * @param {Number} date The JavaScript date (ms since epoch) + * @return {String} A date of the form "YYYY/MM/DD" + * @private + */ +Dygraph.dateString_ = function(date) { + var zeropad = Dygraph.zeropad; + var d = new Date(date); + + // Get the year: + var year = "" + d.getFullYear(); + // Get a 0 padded month string + var month = zeropad(d.getMonth() + 1); //months are 0-offset, sigh + // Get a 0 padded day string + var day = zeropad(d.getDate()); + + var ret = ""; + var frac = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(); + if (frac) ret = " " + Dygraph.hmsString_(date); + + return year + "/" + month + "/" + day + ret; +}; + +/** + * Convert a JS date to a string appropriate to display on an axis that + * is displaying values at the stated granularity. + * @param {Date} date The date to format + * @param {Number} granularity One of the Dygraph granularity constants + * @return {String} The formatted date + * @private + */ +Dygraph.dateAxisFormatter = function(date, granularity) { + if (granularity >= Dygraph.DECADAL) { + return date.strftime('%Y'); + } else if (granularity >= Dygraph.MONTHLY) { + return date.strftime('%b %y'); + } else { + var frac = date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds() + date.getMilliseconds(); + if (frac == 0 || granularity >= Dygraph.DAILY) { + return new Date(date.getTime() + 3600*1000).strftime('%d%b'); + } else { + return Dygraph.hmsString_(date.getTime()); + } + } +}; + // Default attribute values. Dygraph.DEFAULT_ATTRS = { highlightCircleSize: 3, - pixelsPerXLabel: 60, - pixelsPerYLabel: 30, labelsDivWidth: 250, labelsDivStyles: { @@ -95,7 +190,9 @@ Dygraph.DEFAULT_ATTRS = { labelsKMG2: false, showLabelsOnHighlight: true, - yValueFormatter: function(x) { return Dygraph.round_(x, 2); }, + digitsAfterDecimal: 2, + maxNumberWidth: 6, + sigFigs: null, strokeWidth: 1.0, @@ -103,17 +200,13 @@ Dygraph.DEFAULT_ATTRS = { axisLabelFontSize: 14, xAxisLabelWidth: 50, yAxisLabelWidth: 50, - xAxisLabelFormatter: Dygraph.dateAxisFormatter, rightGap: 5, showRoller: false, - xValueFormatter: Dygraph.dateString_, xValueParser: Dygraph.dateParser, - xTicker: Dygraph.dateTicker, delimiter: ',', - logScale: false, sigma: 2.0, errorBars: false, fractions: false, @@ -126,16 +219,54 @@ Dygraph.DEFAULT_ATTRS = { stackedGraph: false, hideOverlayOnMouseOut: true, + // TODO(danvk): support 'onmouseover' and 'never', and remove synonyms. + legend: 'onmouseover', // the only relevant value at the moment is 'always'. + stepPlot: false, - avoidMinZero: false + avoidMinZero: false, + + // Sizes of the various chart labels. + titleHeight: 28, + xLabelHeight: 18, + yLabelWidth: 18, + + drawXAxis: true, + drawYAxis: true, + axisLineColor: "black", + axisLineWidth: 0.3, + gridLineWidth: 0.3, + axisLabelColor: "black", + axisLabelFont: "Arial", // TODO(danvk): is this implemented? + axisLabelWidth: 50, + drawYGrid: true, + drawXGrid: true, + gridLineColor: "rgb(128,128,128)", + + interactionModel: null, // will be set to Dygraph.Interaction.defaultModel + + // per-axis options + axes: { + x: { + pixelsPerLabel: 60, + axisLabelFormatter: Dygraph.dateAxisFormatter, + valueFormatter: Dygraph.dateString_, + ticker: null // will be set in dygraph-tickers.js + }, + y: { + pixelsPerLabel: 30, + valueFormatter: Dygraph.numberValueFormatter, + axisLabelFormatter: Dygraph.numberAxisLabelFormatter, + ticker: null // will be set in dygraph-tickers.js + }, + y2: { + pixelsPerLabel: 30, + valueFormatter: Dygraph.numberValueFormatter, + axisLabelFormatter: Dygraph.numberAxisLabelFormatter, + ticker: null // will be set in dygraph-tickers.js + } + } }; -// Various logging levels. -Dygraph.DEBUG = 1; -Dygraph.INFO = 2; -Dygraph.WARNING = 3; -Dygraph.ERROR = 3; - // Directions for panning and zooming. Use bit operations when combined // values are possible. Dygraph.HORIZONTAL = 1; @@ -158,7 +289,7 @@ Dygraph.prototype.__old_init__ = function(div, file, labels, attrs) { /** * Initializes the Dygraph. This creates a new DIV and constructs the PlotKit - * and interaction <canvas> inside of it. See the constructor for details + * and context <canvas> inside of it. See the constructor for details. * on the parameters. * @param {Element} div the Element to render the graph into. * @param {String | Function} file Source data @@ -166,9 +297,26 @@ Dygraph.prototype.__old_init__ = function(div, file, labels, attrs) { * @private */ Dygraph.prototype.__init__ = function(div, file, attrs) { + // Hack for IE: if we're using excanvas and the document hasn't finished + // loading yet (and hence may not have initialized whatever it needs to + // initialize), then keep calling this routine periodically until it has. + if (/MSIE/.test(navigator.userAgent) && !window.opera && + typeof(G_vmlCanvasManager) != 'undefined' && + document.readyState != 'complete') { + var self = this; + setTimeout(function() { self.__init__(div, file, attrs) }, 100); + } + // Support two-argument constructor if (attrs == null) { attrs = {}; } + attrs = Dygraph.mapLegacyOptions_(attrs); + + if (!div) { + Dygraph.error("Constructing dygraph with a non-existent div!"); + return; + } + // Copy the important bits into the object // TODO(danvk): most of these should just stay in the attrs_ dictionary. this.maindiv_ = div; @@ -177,46 +325,38 @@ Dygraph.prototype.__init__ = function(div, file, attrs) { this.previousVerticalX_ = -1; this.fractions_ = attrs.fractions || false; this.dateWindow_ = attrs.dateWindow || null; - // valueRange and valueWindow are similar, but not the same. valueRange is a - // locally-stored copy of the attribute. valueWindow starts off the same as - // valueRange but is impacted by zoom or pan effects. valueRange is kept - // around to restore the original value back to valueRange. - this.valueRange_ = attrs.valueRange || null; - this.valueWindow_ = this.valueRange_; this.wilsonInterval_ = attrs.wilsonInterval || true; this.is_initial_draw_ = true; this.annotations_ = []; + // Zoomed indicators - These indicate when the graph has been zoomed and on what axis. + this.zoomed_x_ = false; + this.zoomed_y_ = false; + // Clear the div. This ensure that, if multiple dygraphs are passed the same // div, then only one will be drawn. div.innerHTML = ""; - // If the div isn't already sized then inherit from our attrs or - // give it a default size. - if (div.style.width == '') { - div.style.width = attrs.width || Dygraph.DEFAULT_WIDTH + "px"; - } - if (div.style.height == '') { - div.style.height = attrs.height || Dygraph.DEFAULT_HEIGHT + "px"; + // For historical reasons, the 'width' and 'height' options trump all CSS + // rules _except_ for an explicit 'width' or 'height' on the div. + // As an added convenience, if the div has zero height (like
does + // without any styles), then we use a default height/width. + if (div.style.width == '' && attrs.width) { + div.style.width = attrs.width + "px"; } - this.width_ = parseInt(div.style.width, 10); - this.height_ = parseInt(div.style.height, 10); - // The div might have been specified as percent of the current window size, - // convert that to an appropriate number of pixels. - if (div.style.width.indexOf("%") == div.style.width.length - 1) { - this.width_ = div.offsetWidth; + if (div.style.height == '' && attrs.height) { + div.style.height = attrs.height + "px"; } - if (div.style.height.indexOf("%") == div.style.height.length - 1) { - this.height_ = div.offsetHeight; - } - - if (this.width_ == 0) { - this.error("dygraph has zero width. Please specify a width in pixels."); - } - if (this.height_ == 0) { - this.error("dygraph has zero height. Please specify a height in pixels."); + if (div.style.height == '' && div.offsetHeight == 0) { + div.style.height = Dygraph.DEFAULT_HEIGHT + "px"; + if (div.style.width == '') { + div.style.width = Dygraph.DEFAULT_WIDTH + "px"; + } } + // these will be zero if the dygraph's div is hidden. + this.width_ = div.offsetWidth; + this.height_ = div.offsetHeight; // TODO(danvk): set fillGraph to be part of attrs_ here, not user_attrs_. if (attrs['stackedGraph']) { @@ -236,21 +376,65 @@ Dygraph.prototype.__init__ = function(div, file, attrs) { this.user_attrs_ = {}; Dygraph.update(this.user_attrs_, attrs); + // This sequence ensures that Dygraph.DEFAULT_ATTRS is never modified. this.attrs_ = {}; - Dygraph.update(this.attrs_, Dygraph.DEFAULT_ATTRS); + Dygraph.updateDeep(this.attrs_, Dygraph.DEFAULT_ATTRS); this.boundaryIds_ = []; - // Make a note of whether labels will be pulled from the CSV file. - this.labelsFromCSV_ = (this.attr_("labels") == null); - // Create the containing DIV and other interactive elements this.createInterface_(); this.start_(); }; +/** + * Returns the zoomed status of the chart for one or both axes. + * + * Axis is an optional parameter. Can be set to 'x' or 'y'. + * + * The zoomed status for an axis is set whenever a user zooms using the mouse + * or when the dateWindow or valueRange are updated (unless the isZoomedIgnoreProgrammaticZoom + * option is also specified). + */ +Dygraph.prototype.isZoomed = function(axis) { + if (axis == null) return this.zoomed_x_ || this.zoomed_y_; + if (axis == 'x') return this.zoomed_x_; + if (axis == 'y') return this.zoomed_y_; + throw "axis parameter to Dygraph.isZoomed must be missing, 'x' or 'y'."; +}; + +/** + * Returns information about the Dygraph object, including its containing ID. + */ +Dygraph.prototype.toString = function() { + var maindiv = this.maindiv_; + var id = (maindiv && maindiv.id) ? maindiv.id : maindiv + return "[Dygraph " + id + "]"; +} + +/** + * @private + * Returns the value of an option. This may be set by the user (either in the + * constructor or by calling updateOptions) or by dygraphs, and may be set to a + * per-series value. + * @param { String } name The name of the option, e.g. 'rollPeriod'. + * @param { String } [seriesName] The name of the series to which the option + * will be applied. If no per-series value of this option is available, then + * the global value is returned. This is optional. + * @return { ... } The value of the option. + */ Dygraph.prototype.attr_ = function(name, seriesName) { +// + if (typeof(Dygraph.OPTIONS_REFERENCE) === 'undefined') { + this.error('Must include options reference JS for testing'); + } else if (!Dygraph.OPTIONS_REFERENCE.hasOwnProperty(name)) { + this.error('Dygraphs is using property ' + name + ', which has no entry ' + + 'in the Dygraphs.OPTIONS_REFERENCE listing.'); + // Only log this error once. + Dygraph.OPTIONS_REFERENCE[name] = true; + } +// if (seriesName && typeof(this.user_attrs_[seriesName]) != 'undefined' && this.user_attrs_[seriesName] != null && @@ -265,38 +449,42 @@ Dygraph.prototype.attr_ = function(name, seriesName) { } }; -// TODO(danvk): any way I can get the line numbers to be this.warn call? -Dygraph.prototype.log = function(severity, message) { - if (typeof(console) != 'undefined') { - switch (severity) { - case Dygraph.DEBUG: - console.debug('dygraphs: ' + message); - break; - case Dygraph.INFO: - console.info('dygraphs: ' + message); - break; - case Dygraph.WARNING: - console.warn('dygraphs: ' + message); - break; - case Dygraph.ERROR: - console.error('dygraphs: ' + message); - break; +/** + * @private + * @param String} axis The name of the axis (i.e. 'x', 'y' or 'y2') + * @return { ... } A function mapping string -> option value + */ +Dygraph.prototype.optionsViewForAxis_ = function(axis) { + var self = this; + return function(opt) { + var axis_opts = self.user_attrs_['axes']; + if (axis_opts && axis_opts[axis] && axis_opts[axis][opt]) { + return axis_opts[axis][opt]; + } + // user-specified attributes always trump defaults, even if they're less + // specific. + if (typeof(self.user_attrs_[opt]) != 'undefined') { + return self.user_attrs_[opt]; } - } -} -Dygraph.prototype.info = function(message) { - this.log(Dygraph.INFO, message); -} -Dygraph.prototype.warn = function(message) { - this.log(Dygraph.WARNING, message); -} -Dygraph.prototype.error = function(message) { - this.log(Dygraph.ERROR, message); -} + + axis_opts = self.attrs_['axes']; + if (axis_opts && axis_opts[axis] && axis_opts[axis][opt]) { + return axis_opts[axis][opt]; + } + // check old-style axis options + // TODO(danvk): add a deprecation warning if either of these match. + if (axis == 'y' && self.axes_[0].hasOwnProperty(opt)) { + return self.axes_[0][opt]; + } else if (axis == 'y2' && self.axes_[1].hasOwnProperty(opt)) { + return self.axes_[1][opt]; + } + return self.attr_(opt); + }; +}; /** * Returns the current rolling period, as set by the user or an option. - * @return {Number} The number of days in the rolling window + * @return {Number} The number of points in the rolling window */ Dygraph.prototype.rollPeriod = function() { return this.rollPeriod_; @@ -309,66 +497,229 @@ Dygraph.prototype.rollPeriod = function() { * If the Dygraph has dates on the x-axis, these will be millis since epoch. */ Dygraph.prototype.xAxisRange = function() { - if (this.dateWindow_) return this.dateWindow_; + return this.dateWindow_ ? this.dateWindow_ : this.xAxisExtremes(); +}; - // The entire chart is visible. +/** + * Returns the lower- and upper-bound x-axis values of the + * data set. + */ +Dygraph.prototype.xAxisExtremes = function() { var left = this.rawData_[0][0]; var right = this.rawData_[this.rawData_.length - 1][0]; return [left, right]; }; /** - * Returns the currently-visible y-range. This can be affected by zooming, - * panning or a call to updateOptions. + * Returns the currently-visible y-range for an axis. This can be affected by + * zooming, panning or a call to updateOptions. Axis indices are zero-based. If + * called with no arguments, returns the range of the first axis. * Returns a two-element array: [bottom, top]. */ -Dygraph.prototype.yAxisRange = function() { - return this.displayedYRange_; +Dygraph.prototype.yAxisRange = function(idx) { + if (typeof(idx) == "undefined") idx = 0; + if (idx < 0 || idx >= this.axes_.length) { + return null; + } + var axis = this.axes_[idx]; + return [ axis.computedValueRange[0], axis.computedValueRange[1] ]; +}; + +/** + * Returns the currently-visible y-ranges for each axis. This can be affected by + * zooming, panning, calls to updateOptions, etc. + * Returns an array of [bottom, top] pairs, one for each y-axis. + */ +Dygraph.prototype.yAxisRanges = function() { + var ret = []; + for (var i = 0; i < this.axes_.length; i++) { + ret.push(this.yAxisRange(i)); + } + return ret; }; +// TODO(danvk): use these functions throughout dygraphs. /** * Convert from data coordinates to canvas/div X/Y coordinates. + * If specified, do this conversion for the coordinate system of a particular + * axis. Uses the first axis by default. * Returns a two-element array: [X, Y] + * + * Note: use toDomXCoord instead of toDomCoords(x, null) and use toDomYCoord + * instead of toDomCoords(null, y, axis). */ -Dygraph.prototype.toDomCoords = function(x, y) { - var ret = [null, null]; +Dygraph.prototype.toDomCoords = function(x, y, axis) { + return [ this.toDomXCoord(x), this.toDomYCoord(y, axis) ]; +}; + +/** + * Convert from data x coordinates to canvas/div X coordinate. + * If specified, do this conversion for the coordinate system of a particular + * axis. + * Returns a single value or null if x is null. + */ +Dygraph.prototype.toDomXCoord = function(x) { + if (x == null) { + return null; + }; + var area = this.plotter_.area; - if (x !== null) { - var xRange = this.xAxisRange(); - ret[0] = area.x + (x - xRange[0]) / (xRange[1] - xRange[0]) * area.w; + var xRange = this.xAxisRange(); + return area.x + (x - xRange[0]) / (xRange[1] - xRange[0]) * area.w; +} + +/** + * Convert from data x coordinates to canvas/div Y coordinate and optional + * axis. Uses the first axis by default. + * + * returns a single value or null if y is null. + */ +Dygraph.prototype.toDomYCoord = function(y, axis) { + var pct = this.toPercentYCoord(y, axis); + + if (pct == null) { + return null; } + var area = this.plotter_.area; + return area.y + pct * area.h; +} - if (y !== null) { - var yRange = this.yAxisRange(); - ret[1] = area.y + (yRange[1] - y) / (yRange[1] - yRange[0]) * area.h; +/** + * Convert from canvas/div coords to data coordinates. + * If specified, do this conversion for the coordinate system of a particular + * axis. Uses the first axis by default. + * Returns a two-element array: [X, Y]. + * + * Note: use toDataXCoord instead of toDataCoords(x, null) and use toDataYCoord + * instead of toDataCoords(null, y, axis). + */ +Dygraph.prototype.toDataCoords = function(x, y, axis) { + return [ this.toDataXCoord(x), this.toDataYCoord(y, axis) ]; +}; + +/** + * Convert from canvas/div x coordinate to data coordinate. + * + * If x is null, this returns null. + */ +Dygraph.prototype.toDataXCoord = function(x) { + if (x == null) { + return null; } - return ret; + var area = this.plotter_.area; + var xRange = this.xAxisRange(); + return xRange[0] + (x - area.x) / area.w * (xRange[1] - xRange[0]); }; -// TODO(danvk): use these functions throughout dygraphs. /** - * Convert from canvas/div coords to data coordinates. - * Returns a two-element array: [X, Y] + * Convert from canvas/div y coord to value. + * + * If y is null, this returns null. + * if axis is null, this uses the first axis. */ -Dygraph.prototype.toDataCoords = function(x, y) { - var ret = [null, null]; +Dygraph.prototype.toDataYCoord = function(y, axis) { + if (y == null) { + return null; + } + var area = this.plotter_.area; - if (x !== null) { - var xRange = this.xAxisRange(); - ret[0] = xRange[0] + (x - area.x) / area.w * (xRange[1] - xRange[0]); + var yRange = this.yAxisRange(axis); + + if (typeof(axis) == "undefined") axis = 0; + if (!this.axes_[axis].logscale) { + return yRange[0] + (area.y + area.h - y) / area.h * (yRange[1] - yRange[0]); + } else { + // Computing the inverse of toDomCoord. + var pct = (y - area.y) / area.h + + // Computing the inverse of toPercentYCoord. The function was arrived at with + // the following steps: + // + // Original calcuation: + // pct = (logr1 - Dygraph.log10(y)) / (logr1 - Dygraph.log10(yRange[0])); + // + // Move denominator to both sides: + // pct * (logr1 - Dygraph.log10(yRange[0])) = logr1 - Dygraph.log10(y); + // + // subtract logr1, and take the negative value. + // logr1 - (pct * (logr1 - Dygraph.log10(yRange[0]))) = Dygraph.log10(y); + // + // Swap both sides of the equation, and we can compute the log of the + // return value. Which means we just need to use that as the exponent in + // e^exponent. + // Dygraph.log10(y) = logr1 - (pct * (logr1 - Dygraph.log10(yRange[0]))); + + var logr1 = Dygraph.log10(yRange[1]); + var exponent = logr1 - (pct * (logr1 - Dygraph.log10(yRange[0]))); + var value = Math.pow(Dygraph.LOG_SCALE, exponent); + return value; } +}; - if (y !== null) { - var yRange = this.yAxisRange(); - ret[1] = yRange[0] + (area.h - y) / area.h * (yRange[1] - yRange[0]); +/** + * Converts a y for an axis to a percentage from the top to the + * bottom of the drawing area. + * + * If the coordinate represents a value visible on the canvas, then + * the value will be between 0 and 1, where 0 is the top of the canvas. + * However, this method will return values outside the range, as + * values can fall outside the canvas. + * + * If y is null, this returns null. + * if axis is null, this uses the first axis. + * + * @param { Number } y The data y-coordinate. + * @param { Number } [axis] The axis number on which the data coordinate lives. + * @return { Number } A fraction in [0, 1] where 0 = the top edge. + */ +Dygraph.prototype.toPercentYCoord = function(y, axis) { + if (y == null) { + return null; } + if (typeof(axis) == "undefined") axis = 0; - return ret; + var area = this.plotter_.area; + var yRange = this.yAxisRange(axis); + + var pct; + if (!this.axes_[axis].logscale) { + // yRange[1] - y is unit distance from the bottom. + // yRange[1] - yRange[0] is the scale of the range. + // (yRange[1] - y) / (yRange[1] - yRange[0]) is the % from the bottom. + pct = (yRange[1] - y) / (yRange[1] - yRange[0]); + } else { + var logr1 = Dygraph.log10(yRange[1]); + pct = (logr1 - Dygraph.log10(y)) / (logr1 - Dygraph.log10(yRange[0])); + } + return pct; +} + +/** + * Converts an x value to a percentage from the left to the right of + * the drawing area. + * + * If the coordinate represents a value visible on the canvas, then + * the value will be between 0 and 1, where 0 is the left of the canvas. + * However, this method will return values outside the range, as + * values can fall outside the canvas. + * + * If x is null, this returns null. + * @param { Number } x The data x-coordinate. + * @return { Number } A fraction in [0, 1] where 0 = the left edge. + */ +Dygraph.prototype.toPercentXCoord = function(x) { + if (x == null) { + return null; + } + + var xRange = this.xAxisRange(); + return (x - xRange[0]) / (xRange[1] - xRange[0]); }; /** * Returns the number of columns (including the independent variable). + * @return { Integer } The number of columns. */ Dygraph.prototype.numColumns = function() { return this.rawData_[0].length; @@ -376,6 +727,7 @@ Dygraph.prototype.numColumns = function() { /** * Returns the number of rows (excluding any header/label row). + * @return { Integer } The number of rows, less any header. */ Dygraph.prototype.numRows = function() { return this.rawData_.length; @@ -385,6 +737,11 @@ Dygraph.prototype.numRows = function() { * Returns the value in the given row and column. If the row and column exceed * the bounds on the data, returns null. Also returns null if the value is * missing. + * @param { Number} row The row number of the data (0-based). Row 0 is the + * first row of data, not a header row. + * @param { Number} col The column number of the data (0-based) + * @return { Number } The value in the specified cell or null if the row/col + * were out of range. */ Dygraph.prototype.getValue = function(row, col) { if (row < 0 || row > this.rawData_.length) return null; @@ -393,25 +750,6 @@ Dygraph.prototype.getValue = function(row, col) { return this.rawData_[row][col]; }; -Dygraph.addEvent = function(el, evt, fn) { - var normed_fn = function(e) { - if (!e) var e = window.event; - fn(e); - }; - if (window.addEventListener) { // Mozilla, Netscape, Firefox - el.addEventListener(evt, normed_fn, false); - } else { // IE - el.attachEvent('on' + evt, normed_fn); - } -}; - -Dygraph.clipCanvas_ = function(cnv, clip) { - var ctx = cnv.getContext("2d"); - ctx.beginPath(); - ctx.rect(clip.left, clip.top, clip.width, clip.height); - ctx.clip(); -}; - /** * Generates interface elements for the Dygraph: a containing div, a div to * display the current point, and a textbox to adjust the rolling average @@ -427,15 +765,6 @@ Dygraph.prototype.createInterface_ = function() { this.graphDiv.style.height = this.height_ + "px"; enclosing.appendChild(this.graphDiv); - var clip = { - top: 0, - left: this.attr_("yAxisLabelWidth") + 2 * this.attr_("axisTickSize") - }; - clip.width = this.width_ - clip.left - this.attr_("rightGap"); - clip.height = this.height_ - this.attr_("axisLabelFontSize") - - 2 * this.attr_("axisTickSize"); - this.clippingArea_ = clip; - // Create the canvas for interactive parts of the chart. this.canvas_ = Dygraph.createCanvas(); this.canvas_.style.position = "absolute"; @@ -444,18 +773,17 @@ Dygraph.prototype.createInterface_ = function() { this.canvas_.style.width = this.width_ + "px"; // for IE this.canvas_.style.height = this.height_ + "px"; // for IE + this.canvas_ctx_ = Dygraph.getContext(this.canvas_); + // ... and for static parts of the chart. this.hidden_ = this.createPlotKitCanvas_(this.canvas_); + this.hidden_ctx_ = Dygraph.getContext(this.hidden_); // The interactive parts of the graph are drawn on top of the chart. this.graphDiv.appendChild(this.hidden_); this.graphDiv.appendChild(this.canvas_); this.mouseEventElement_ = this.canvas_; - // Make sure we don't overdraw. - Dygraph.clipCanvas_(this.hidden_, this.clippingArea_); - Dygraph.clipCanvas_(this.canvas_, this.clippingArea_); - var dygraph = this; Dygraph.addEvent(this.mouseEventElement_, 'mousemove', function(e) { dygraph.mouseMove_(e); @@ -465,28 +793,16 @@ Dygraph.prototype.createInterface_ = function() { }); // Create the grapher - // TODO(danvk): why does the Layout need its own set of options? - this.layoutOptions_ = { 'xOriginIsZero': false }; - Dygraph.update(this.layoutOptions_, this.attrs_); - Dygraph.update(this.layoutOptions_, this.user_attrs_); - Dygraph.update(this.layoutOptions_, { - 'errorBars': (this.attr_("errorBars") || this.attr_("customBars")) }); - - this.layout_ = new DygraphLayout(this, this.layoutOptions_); - - // TODO(danvk): why does the Renderer need its own set of options? - this.renderOptions_ = { colorScheme: this.colors_, - strokeColor: null, - axisLineWidth: Dygraph.AXIS_LINE_WIDTH }; - Dygraph.update(this.renderOptions_, this.attrs_); - Dygraph.update(this.renderOptions_, this.user_attrs_); - this.plotter_ = new DygraphCanvasRenderer(this, - this.hidden_, this.layout_, - this.renderOptions_); + this.layout_ = new DygraphLayout(this); this.createStatusMessage_(); - this.createRollInterface_(); this.createDragInterface_(); + + // Update when the window is resized. + // TODO(danvk): drop frames depending on complexity of the chart. + Dygraph.addEvent(window, 'resize', function(e) { + dygraph.resize(); + }); }; /** @@ -518,8 +834,9 @@ Dygraph.prototype.destroy = function() { }; /** - * Creates the canvas containing the PlotKit graph. Only plotkit ever draws on - * this particular canvas. All Dygraph work is done on this.canvas_. + * Creates the canvas on which the chart will be drawn. Only the Renderer ever + * draws on this particular canvas. All Dygraph work (i.e. drawing hover dots + * or the zoom rectangles) is done on this.canvas_. * @param {Object} canvas The Dygraph canvas over which to overlay the plot * @return {Object} The newly-created canvas * @private @@ -539,38 +856,6 @@ Dygraph.prototype.createPlotKitCanvas_ = function(canvas) { return h; }; -// Taken from MochiKit.Color -Dygraph.hsvToRGB = function (hue, saturation, value) { - var red; - var green; - var blue; - if (saturation === 0) { - red = value; - green = value; - blue = value; - } else { - var i = Math.floor(hue * 6); - var f = (hue * 6) - i; - var p = value * (1 - saturation); - var q = value * (1 - (saturation * f)); - var t = value * (1 - (saturation * (1 - f))); - switch (i) { - case 1: red = q; green = value; blue = p; break; - case 2: red = p; green = value; blue = t; break; - case 3: red = p; green = q; blue = value; break; - case 4: red = t; green = p; blue = value; break; - case 5: red = value; green = p; blue = q; break; - case 6: // fall through - case 0: red = value; green = t; blue = p; break; - } - } - red = Math.floor(255 * red + 0.5); - green = Math.floor(255 * green + 0.5); - blue = Math.floor(255 * blue + 0.5); - return 'rgb(' + red + ',' + green + ',' + blue + ')'; -}; - - /** * Generate a set of distinct colors for the data series. This is done with a * color wheel. Saturation/Value are customizable, and the hue is @@ -579,8 +864,6 @@ Dygraph.hsvToRGB = function (hue, saturation, value) { * @private */ Dygraph.prototype.setColors_ = function() { - // TODO(danvk): compute this directly into this.attrs_['colorScheme'] and do - // away with this.renderOptions_. var num = this.attr_("labels").length - 1; this.colors_ = []; var colors = this.attr_('colors'); @@ -603,57 +886,18 @@ Dygraph.prototype.setColors_ = function() { } } - // TODO(danvk): update this w/r/t/ the new options system. - this.renderOptions_.colorScheme = this.colors_; - Dygraph.update(this.plotter_.options, this.renderOptions_); - Dygraph.update(this.layoutOptions_, this.user_attrs_); - Dygraph.update(this.layoutOptions_, this.attrs_); -} + this.plotter_.setColors(this.colors_); +}; /** * 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. + * attributes or the autogenerated list of rgb(r,g,b) strings. * @return {Array} The list of colors. */ Dygraph.prototype.getColors = function() { return this.colors_; }; -// The following functions are from quirksmode.org with a modification for Safari from -// http://blog.firetree.net/2005/07/04/javascript-find-position/ -// http://www.quirksmode.org/js/findpos.html -Dygraph.findPosX = function(obj) { - var curleft = 0; - if(obj.offsetParent) - while(1) - { - curleft += obj.offsetLeft; - if(!obj.offsetParent) - break; - obj = obj.offsetParent; - } - else if(obj.x) - curleft += obj.x; - return curleft; -}; - -Dygraph.findPosY = function(obj) { - var curtop = 0; - if(obj.offsetParent) - while(1) - { - curtop += obj.offsetTop; - if(!obj.offsetParent) - break; - obj = obj.offsetParent; - } - else if(obj.y) - curtop += obj.y; - return curtop; -}; - - - /** * 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 @@ -680,6 +924,7 @@ Dygraph.prototype.createStatusMessage_ = function() { "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)) { div.style[name] = messagestyle[name]; @@ -691,58 +936,71 @@ Dygraph.prototype.createStatusMessage_ = function() { }; /** + * 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; + + 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"; +}; + +/** * Create the text box to adjust the averaging period - * @return {Object} The newly-created text box * @private */ Dygraph.prototype.createRollInterface_ = function() { - var display = this.attr_('showRoller') ? "block" : "none"; + // Create a roller if one doesn't exist already. + if (!this.roller_) { + this.roller_ = document.createElement("input"); + this.roller_.type = "text"; + this.roller_.style.display = "none"; + this.graphDiv.appendChild(this.roller_); + } + + var display = this.attr_('showRoller') ? 'block' : 'none'; + + var area = this.plotter_.area; var textAttr = { "position": "absolute", "zIndex": 10, - "top": (this.plotter_.area.h - 25) + "px", - "left": (this.plotter_.area.x + 1) + "px", + "top": (area.y + area.h - 25) + "px", + "left": (area.x + 1) + "px", "display": display }; - var roller = document.createElement("input"); - roller.type = "text"; - roller.size = "2"; - roller.value = this.rollPeriod_; + this.roller_.size = "2"; + this.roller_.value = this.rollPeriod_; for (var name in textAttr) { if (textAttr.hasOwnProperty(name)) { - roller.style[name] = textAttr[name]; + this.roller_.style[name] = textAttr[name]; } } - var pa = this.graphDiv; - pa.appendChild(roller); var dygraph = this; - roller.onchange = function() { dygraph.adjustRoll(roller.value); }; - return roller; + this.roller_.onchange = function() { dygraph.adjustRoll(dygraph.roller_.value); }; }; -// These functions are taken from MochiKit.Signal -Dygraph.pageX = function(e) { - if (e.pageX) { - return (!e.pageX || e.pageX < 0) ? 0 : e.pageX; - } else { - var de = document; - var b = document.body; - return e.clientX + - (de.scrollLeft || b.scrollLeft) - - (de.clientLeft || 0); - } +/** + * @private + * Converts page the x-coordinate of the event to pixel x-coordinates on the + * canvas (i.e. DOM Coords). + */ +Dygraph.prototype.dragGetX_ = function(e, context) { + return Dygraph.pageX(e) - context.px }; -Dygraph.pageY = function(e) { - if (e.pageY) { - return (!e.pageY || e.pageY < 0) ? 0 : e.pageY; - } else { - var de = document; - var b = document.body; - return e.clientY + - (de.scrollTop || b.scrollTop) - - (de.clientTop || 0); - } +/** + * @private + * Converts page the y-coordinate of the event to pixel y-coordinates on the + * canvas (i.e. DOM Coords). + */ +Dygraph.prototype.dragGetY_ = function(e, context) { + return Dygraph.pageY(e) - context.py }; /** @@ -751,209 +1009,98 @@ Dygraph.pageY = function(e) { * @private */ Dygraph.prototype.createDragInterface_ = function() { - var self = this; - - // Tracks whether the mouse is down right now - var isZooming = false; - var isPanning = false; - var dragStartX = null; - var dragStartY = null; - var dragEndX = null; - var dragEndY = null; - var dragDirection = null; - var prevEndX = null; - var prevEndY = null; - var prevDragDirection = null; - - // draggingDate and draggingValue represent the [date,value] point on the - // graph at which the mouse was pressed. As the mouse moves while panning, - // the viewport must pan so that the mouse position points to - // [draggingDate, draggingValue] - var draggingDate = null; - var draggingValue = null; - - // The range in second/value units that the viewport encompasses during a - // panning operation. - var dateRange = null; - var valueRange = null; - - // Utility function to convert page-wide coordinates to canvas coords - var px = 0; - var py = 0; - var getX = function(e) { return Dygraph.pageX(e) - px }; - var getY = function(e) { return Dygraph.pageY(e) - py }; - - // Draw zoom rectangles when the mouse is down and the user moves around - Dygraph.addEvent(this.mouseEventElement_, 'mousemove', function(event) { - if (isZooming) { - dragEndX = getX(event); - dragEndY = getY(event); - - var xDelta = Math.abs(dragStartX - dragEndX); - var yDelta = Math.abs(dragStartY - dragEndY); - - // drag direction threshold for y axis is twice as large as x axis - dragDirection = (xDelta < yDelta / 2) ? Dygraph.VERTICAL : Dygraph.HORIZONTAL; - - self.drawZoomRect_(dragDirection, dragStartX, dragEndX, dragStartY, dragEndY, - prevDragDirection, prevEndX, prevEndY); - - prevEndX = dragEndX; - prevEndY = dragEndY; - prevDragDirection = dragDirection; - } else if (isPanning) { - dragEndX = getX(event); - dragEndY = getY(event); - - // Want to have it so that: - // 1. draggingDate appears at dragEndX, draggingValue appears at dragEndY. - // 2. daterange = (dateWindow_[1] - dateWindow_[0]) is unaltered. - // 3. draggingValue appears at dragEndY. - // 4. valueRange is unaltered. - - var minDate = draggingDate - (dragEndX / self.width_) * dateRange; - var maxDate = minDate + dateRange; - self.dateWindow_ = [minDate, maxDate]; - - - // y-axis scaling is automatic unless a valueRange is defined or - // if the user zooms in on the y-axis. If neither is true, valueWindow_ - // will be null. - if (self.valueWindow_) { - var maxValue = draggingValue + (dragEndY / self.height_) * valueRange; - var minValue = maxValue - valueRange; - self.valueWindow_ = [ minValue, maxValue ]; + var context = { + // Tracks whether the mouse is down right now + isZooming: false, + isPanning: false, // is this drag part of a pan? + is2DPan: false, // if so, is that pan 1- or 2-dimensional? + dragStartX: null, // pixel coordinates + dragStartY: null, // pixel coordinates + dragEndX: null, // pixel coordinates + dragEndY: null, // pixel coordinates + dragDirection: null, + prevEndX: null, // pixel coordinates + prevEndY: null, // pixel coordinates + prevDragDirection: null, + + // The value on the left side of the graph when a pan operation starts. + initialLeftmostDate: null, + + // The number of units each pixel spans. (This won't be valid for log + // scales) + xUnitsPerPixel: null, + + // TODO(danvk): update this comment + // The range in second/value units that the viewport encompasses during a + // panning operation. + dateRange: null, + + // Top-left corner of the canvas, in DOM coords + // TODO(konigsberg): Rename topLeftCanvasX, topLeftCanvasY. + px: 0, + py: 0, + + // Values for use with panEdgeFraction, which limit how far outside the + // graph's data boundaries it can be panned. + boundedDates: null, // [minDate, maxDate] + boundedValues: null, // [[minValue, maxValue] ...] + + initializeMouseDown: function(event, g, context) { + // prevents mouse drags from selecting page text. + if (event.preventDefault) { + event.preventDefault(); // Firefox, Chrome, etc. + } else { + event.returnValue = false; // IE + event.cancelBubble = true; } - self.drawGraph_(self.rawData_); - } - }); - // Track the beginning of drag events - Dygraph.addEvent(this.mouseEventElement_, 'mousedown', function(event) { - px = Dygraph.findPosX(self.canvas_); - py = Dygraph.findPosY(self.canvas_); - dragStartX = getX(event); - dragStartY = getY(event); - - if (event.altKey || event.shiftKey) { - // have to be zoomed in to pan. - if (!self.dateWindow_ && !self.valueWindow_) return; - - isPanning = true; - var xRange = self.xAxisRange(); - dateRange = xRange[1] - xRange[0]; - var yRange = self.yAxisRange(); - valueRange = yRange[1] - yRange[0]; - - // TODO(konigsberg): Switch from all this math to toDataCoords? - // Seems to work for the dragging value. - draggingDate = (dragStartX / self.width_) * dateRange + - xRange[0]; - var r = self.toDataCoords(null, dragStartY); - draggingValue = r[1]; - } else { - isZooming = true; - } - }); - - // If the user releases the mouse button during a drag, but not over the - // canvas, then it doesn't count as a zooming action. - Dygraph.addEvent(document, 'mouseup', function(event) { - if (isZooming || isPanning) { - isZooming = false; - dragStartX = null; - dragStartY = null; + context.px = Dygraph.findPosX(g.canvas_); + context.py = Dygraph.findPosY(g.canvas_); + context.dragStartX = g.dragGetX_(event, context); + context.dragStartY = g.dragGetY_(event, context); } + }; - if (isPanning) { - isPanning = false; - draggingDate = null; - draggingValue = null; - dateRange = null; - valueRange = null; - } - }); + var interactionModel = this.attr_("interactionModel"); - // Temporarily cancel the dragging event when the mouse leaves the graph - Dygraph.addEvent(this.mouseEventElement_, 'mouseout', function(event) { - if (isZooming) { - dragEndX = null; - dragEndY = null; - } - }); + // Self is the graph. + var self = this; - // If the mouse is released on the canvas during a drag event, then it's a - // zoom. Only do the zoom if it's over a large enough area (>= 10 pixels) - Dygraph.addEvent(this.mouseEventElement_, 'mouseup', function(event) { - if (isZooming) { - isZooming = false; - dragEndX = getX(event); - dragEndY = getY(event); - var regionWidth = Math.abs(dragEndX - dragStartX); - var regionHeight = Math.abs(dragEndY - dragStartY); - - if (regionWidth < 2 && regionHeight < 2 && - self.lastx_ != undefined && self.lastx_ != -1) { - // TODO(danvk): pass along more info about the points, e.g. 'x' - if (self.attr_('clickCallback') != null) { - self.attr_('clickCallback')(event, self.lastx_, self.selPoints_); - } - if (self.attr_('pointClickCallback')) { - // check if the click was on a particular point. - var closestIdx = -1; - var closestDistance = 0; - for (var i = 0; i < self.selPoints_.length; i++) { - var p = self.selPoints_[i]; - var distance = Math.pow(p.canvasx - dragEndX, 2) + - Math.pow(p.canvasy - dragEndY, 2); - if (closestIdx == -1 || distance < closestDistance) { - closestDistance = distance; - closestIdx = i; - } - } + // Function that binds the graph and context to the handler. + var bindHandler = function(handler) { + return function(event) { + handler(event, self, context); + }; + }; - // Allow any click within two pixels of the dot. - var radius = self.attr_('highlightCircleSize') + 2; - if (closestDistance <= 5 * 5) { - self.attr_('pointClickCallback')(event, self.selPoints_[closestIdx]); - } - } - } + for (var eventName in interactionModel) { + if (!interactionModel.hasOwnProperty(eventName)) continue; + Dygraph.addEvent(this.mouseEventElement_, eventName, + bindHandler(interactionModel[eventName])); + } - if (regionWidth >= 10 && dragDirection == Dygraph.HORIZONTAL) { - self.doZoomX_(Math.min(dragStartX, dragEndX), - Math.max(dragStartX, dragEndX)); - } else if (regionHeight >= 10 && dragDirection == Dygraph.VERTICAL){ - self.doZoomY_(Math.min(dragStartY, dragEndY), - Math.max(dragStartY, dragEndY)); - } else { - self.canvas_.getContext("2d").clearRect(0, 0, - self.canvas_.width, - self.canvas_.height); + // If the user releases the mouse button during a drag, but not over the + // canvas, then it doesn't count as a zooming action. + Dygraph.addEvent(document, 'mouseup', function(event) { + if (context.isZooming || context.isPanning) { + context.isZooming = false; + context.dragStartX = null; + context.dragStartY = null; + } + + if (context.isPanning) { + context.isPanning = false; + context.draggingDate = null; + context.dateRange = null; + for (var i = 0; i < self.axes_.length; i++) { + delete self.axes_[i].draggingValue; + delete self.axes_[i].dragValueRange; } - - dragStartX = null; - dragStartY = null; } - - if (isPanning) { - isPanning = false; - draggingDate = null; - draggingValue = null; - dateRange = null; - valueRange = null; - } - }); - - // Double-clicking zooms back out - Dygraph.addEvent(this.mouseEventElement_, 'dblclick', function(event) { - // Disable zooming out if panning. - if (event.altKey || event.shiftKey) return; - - self.doUnzoom_(); }); }; + /** * Draw a gray zoom rectangle over the desired area of the canvas. Also clears * up any previous zoom rectangles that were drawn. This could be optimized to @@ -976,9 +1123,10 @@ Dygraph.prototype.createDragInterface_ = function() { * function. Used to avoid excess redrawing * @private */ -Dygraph.prototype.drawZoomRect_ = function(direction, startX, endX, startY, endY, - prevDirection, prevEndX, prevEndY) { - var ctx = this.canvas_.getContext("2d"); +Dygraph.prototype.drawZoomRect_ = function(direction, startX, endX, startY, + endY, prevDirection, prevEndX, + prevEndY) { + var ctx = this.canvas_ctx_; // Clean up from the previous rect if necessary if (prevDirection == Dygraph.HORIZONTAL) { @@ -1011,7 +1159,7 @@ Dygraph.prototype.drawZoomRect_ = function(direction, startX, endX, startY, endY * the canvas. The exact zoom window may be slightly larger if there are no data * points near lowX or highX. Don't confuse this function with doZoomXDates, * which accepts dates that match the raw data. This function redraws the graph. - * + * * @param {Number} lowX The leftmost pixel value that should be visible. * @param {Number} highX The rightmost pixel value that should be visible. * @private @@ -1019,10 +1167,8 @@ Dygraph.prototype.drawZoomRect_ = function(direction, startX, endX, startY, endY Dygraph.prototype.doZoomX_ = function(lowX, highX) { // Find the earliest and latest dates contained in this canvasx range. // Convert the call to date ranges of the raw data. - var r = this.toDataCoords(lowX, null); - var minDate = r[0]; - r = this.toDataCoords(highX, null); - var maxDate = r[0]; + var minDate = this.toDataXCoord(lowX); + var maxDate = this.toDataXCoord(highX); this.doZoomXDates_(minDate, maxDate); }; @@ -1030,86 +1176,83 @@ Dygraph.prototype.doZoomX_ = function(lowX, highX) { * Zoom to something containing [minDate, maxDate] values. Don't confuse this * method with doZoomX which accepts pixel coordinates. This function redraws * the graph. - * + * * @param {Number} minDate The minimum date that should be visible. * @param {Number} maxDate The maximum date that should be visible. * @private */ Dygraph.prototype.doZoomXDates_ = function(minDate, maxDate) { this.dateWindow_ = [minDate, maxDate]; - this.drawGraph_(this.rawData_); + this.zoomed_x_ = true; + this.drawGraph_(); if (this.attr_("zoomCallback")) { - var yRange = this.yAxisRange(); - this.attr_("zoomCallback")(minDate, maxDate, yRange[0], yRange[1]); + this.attr_("zoomCallback")(minDate, maxDate, this.yAxisRanges()); } }; /** * Zoom to something containing [lowY, highY]. These are pixel coordinates in - * the canvas. The exact zoom window may be slightly larger if there are no - * data points near lowY or highY. Don't confuse this function with - * doZoomYValues, which accepts parameters that match the raw data. This - * function redraws the graph. - * + * the canvas. This function redraws the graph. + * * @param {Number} lowY The topmost pixel value that should be visible. * @param {Number} highY The lowest pixel value that should be visible. * @private */ Dygraph.prototype.doZoomY_ = function(lowY, highY) { - // Find the highest and lowest values in pixel range. - var r = this.toDataCoords(null, lowY); - var maxValue = r[1]; - r = this.toDataCoords(null, highY); - var minValue = r[1]; - - this.doZoomYValues_(minValue, maxValue); -}; - -/** - * Zoom to something containing [minValue, maxValue] values. Don't confuse this - * method with doZoomY which accepts pixel coordinates. This function redraws - * the graph. - * - * @param {Number} minValue The minimum Value that should be visible. - * @param {Number} maxValue The maximum value that should be visible. - * @private - */ -Dygraph.prototype.doZoomYValues_ = function(minValue, maxValue) { - this.valueWindow_ = [minValue, maxValue]; - this.drawGraph_(this.rawData_); + // Find the highest and lowest values in pixel range for each axis. + // Note that lowY (in pixels) corresponds to the max Value (in data coords). + // This is because pixels increase as you go down on the screen, whereas data + // coordinates increase as you go up the screen. + var valueRanges = []; + for (var i = 0; i < this.axes_.length; i++) { + var hi = this.toDataYCoord(lowY, i); + var low = this.toDataYCoord(highY, i); + this.axes_[i].valueWindow = [low, hi]; + valueRanges.push([low, hi]); + } + + this.zoomed_y_ = true; + this.drawGraph_(); if (this.attr_("zoomCallback")) { - var xRange = this.xAxisRange(); - this.attr_("zoomCallback")(xRange[0], xRange[1], minValue, maxValue); + var xRange = this.xAxisRange(); + var yRange = this.yAxisRange(); + this.attr_("zoomCallback")(xRange[0], xRange[1], this.yAxisRanges()); } }; /** * Reset the zoom to the original view coordinates. This is the same as * double-clicking on the graph. - * + * * @private */ Dygraph.prototype.doUnzoom_ = function() { - var dirty = null; + var dirty = false; if (this.dateWindow_ != null) { - dirty = 1; + dirty = true; this.dateWindow_ = null; } - if (this.valueWindow_ != null) { - dirty = 1; - this.valueWindow_ = this.valueRange_; + + for (var i = 0; i < this.axes_.length; i++) { + if (this.axes_[i].valueWindow != null) { + dirty = true; + delete this.axes_[i].valueWindow; + } } + // Clear any selection, since it's likely to be drawn in the wrong place. + this.clearSelection(); + if (dirty) { // Putting the drawing operation before the callback because it resets // yAxisRange. - this.drawGraph_(this.rawData_); + this.zoomed_x_ = false; + this.zoomed_y_ = false; + this.drawGraph_(); if (this.attr_("zoomCallback")) { var minDate = this.rawData_[0][0]; var maxDate = this.rawData_[this.rawData_.length - 1][0]; - var minValue = this.yAxisRange()[0]; - var maxValue = this.yAxisRange()[1]; - this.attr_("zoomCallback")(minDate, maxDate, minValue, maxValue); + this.attr_("zoomCallback")(minDate, maxDate, this.yAxisRanges()); } } }; @@ -1122,8 +1265,11 @@ Dygraph.prototype.doUnzoom_ = function() { * @private */ Dygraph.prototype.mouseMove_ = function(event) { - var canvasx = Dygraph.pageX(event) - Dygraph.findPosX(this.mouseEventElement_); + // This prevents JS errors when mousing over the canvas before data loads. var points = this.layout_.points; + if (points === undefined) return; + + var canvasx = Dygraph.pageX(event) - Dygraph.findPosX(this.mouseEventElement_); var lastx = -1; var lasty = -1; @@ -1135,16 +1281,12 @@ Dygraph.prototype.mouseMove_ = function(event) { for (var i = 0; i < points.length; i++) { var point = points[i]; if (point == null) continue; - var dist = Math.abs(points[i].canvasx - canvasx); + var dist = Math.abs(point.canvasx - canvasx); if (dist > minDist) continue; minDist = dist; idx = i; } if (idx >= 0) lastx = points[idx].xval; - // Check that you can really highlight the last day's data - var last = points[points.length-1]; - if (last != null && canvasx > last.canvasx) - lastx = points[points.length-1].xval; // Extract the points we've selected this.selPoints_ = []; @@ -1176,7 +1318,7 @@ Dygraph.prototype.mouseMove_ = function(event) { var px = this.lastx_; if (px !== null && lastx != px) { // only fire if the selected point has changed. - this.attr_("highlightCallback")(event, lastx, this.selPoints_); + this.attr_("highlightCallback")(event, lastx, this.selPoints_, this.idxToRow_(idx)); } } @@ -1187,13 +1329,111 @@ Dygraph.prototype.mouseMove_ = function(event) { }; /** + * Transforms layout_.points index into data row number. + * @param int layout_.points index + * @return int row number, or -1 if none could be found. + * @private + */ +Dygraph.prototype.idxToRow_ = function(idx) { + if (idx < 0) return -1; + + for (var i in this.layout_.datasets) { + if (idx < this.layout_.datasets[i].length) { + return this.boundaryIds_[0][0]+idx; + } + idx -= this.layout_.datasets[i].length; + } + return -1; +}; + +/** + * @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'. + */ +Dygraph.prototype.generateLegendHTML_ = function(x, sel_points) { + // 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. + if (typeof(x) === 'undefined') { + if (this.attr_('legend') != 'always') return ''; + + var sepLines = this.attr_('labelsSeparateLines'); + var labels = this.attr_('labels'); + var html = ''; + for (var i = 1; i < labels.length; i++) { + if (!this.visibility()[i - 1]) continue; + var c = this.plotter_.colors[labels[i]]; + if (html != '') html += (sepLines ? '
' : ' '); + html += "—" + labels[i] + + ""; + } + return html; + } + + var xOptView = this.optionsViewForAxis_('x'); + var xvf = xOptView('valueFormatter'); + var html = xvf(x, xOptView, this.attr_('labels')[0], this) + ":"; + + var yOptViews = []; + var num_axes = this.numAxes(); + for (var i = 0; i < num_axes; i++) { + yOptViews[i] = this.optionsViewForAxis_('y' + (i ? 1 + i : '')); + } + var showZeros = this.attr_("labelsShowZeroValues"); + var sepLines = this.attr_("labelsSeparateLines"); + for (var 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'); + var c = this.plotter_.colors[pt.name]; + var yval = fmtFunc(pt.yval, yOptView, pt.name, this); + + // 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 html = this.generateLegendHTML_(x, sel_points); + var labelsDiv = this.attr_("labelsDiv"); + 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; + } + } +}; + +/** * Draw dots over the selectied points in the data series. This function * takes care of cleanup of previously-drawn dots. * @private */ Dygraph.prototype.updateSelection_ = function() { // Clear the previously drawn vertical, if there is one - var ctx = this.canvas_.getContext("2d"); + var ctx = this.canvas_ctx_; if (this.previousVerticalX_ >= 0) { // Determine the maximum highlight circle size. var maxCircleSize = 0; @@ -1207,45 +1447,23 @@ Dygraph.prototype.updateSelection_ = function() { 2 * maxCircleSize + 2, this.height_); } - var isOK = function(x) { return x && !isNaN(x); }; - if (this.selPoints_.length > 0) { - var canvasx = this.selPoints_[0].canvasx; - // Set the status message to indicate the selected point(s) - var replace = this.attr_('xValueFormatter')(this.lastx_, this) + ":"; - var fmtFunc = this.attr_('yValueFormatter'); - var clen = this.colors_.length; - if (this.attr_('showLabelsOnHighlight')) { - // Set the status message to indicate the selected point(s) - for (var i = 0; i < this.selPoints_.length; i++) { - if (!this.attr_("labelsShowZeroValues") && this.selPoints_[i].yval == 0) continue; - if (!isOK(this.selPoints_[i].canvasy)) continue; - if (this.attr_("labelsSeparateLines")) { - replace += "
"; - } - var point = this.selPoints_[i]; - var c = new RGBColor(this.plotter_.colors[point.name]); - var yval = fmtFunc(point.yval); - replace += " " - + point.name + ":" - + yval; - } - - this.attr_("labelsDiv").innerHTML = replace; + this.setLegendHTML_(this.lastx_, this.selPoints_); } // Draw colored circles over the center of each selected point + var canvasx = this.selPoints_[0].canvasx; ctx.save(); for (var i = 0; i < this.selPoints_.length; i++) { - if (!isOK(this.selPoints_[i].canvasy)) continue; - var circleSize = - this.attr_('highlightCircleSize', this.selPoints_[i].name); + var pt = this.selPoints_[i]; + if (!Dygraph.isOK(pt.canvasy)) continue; + + var circleSize = this.attr_('highlightCircleSize', pt.name); ctx.beginPath(); - ctx.fillStyle = this.plotter_.colors[this.selPoints_[i].name]; - ctx.arc(canvasx, this.selPoints_[i].canvasy, circleSize, - 0, 2 * Math.PI, false); + ctx.fillStyle = this.plotter_.colors[pt.name]; + ctx.arc(canvasx, pt.canvasy, circleSize, 0, 2 * Math.PI, false); ctx.fill(); } ctx.restore(); @@ -1255,10 +1473,11 @@ Dygraph.prototype.updateSelection_ = function() { }; /** - * Set manually set selected dots, and display information about them - * @param int row number that should by highlighted - * false value clears the selection - * @public + * Manually set the selected points and display information about them in the + * legend. The selection can be cleared using clearSelection() and queried + * using getSelection(). + * @param { Integer } row number that should be highlighted (i.e. appear with + * hover dots on the chart). Set to false to clear any selection. */ Dygraph.prototype.setSelection = function(row) { // Extract the points we've selected @@ -1288,7 +1507,6 @@ Dygraph.prototype.setSelection = function(row) { this.lastx_ = this.selPoints_[0].xval; this.updateSelection_(); } else { - this.lastx_ = -1; this.clearSelection(); } @@ -1310,422 +1528,77 @@ Dygraph.prototype.mouseOut_ = function(event) { }; /** - * Remove all selection from the canvas - * @public + * Clears the current selection (i.e. points that were highlighted by moving + * the mouse over the chart). */ Dygraph.prototype.clearSelection = function() { // Get rid of the overlay data - var ctx = this.canvas_.getContext("2d"); - ctx.clearRect(0, 0, this.width_, this.height_); - this.attr_("labelsDiv").innerHTML = ""; + this.canvas_ctx_.clearRect(0, 0, this.width_, this.height_); + this.setLegendHTML_(); this.selPoints_ = []; this.lastx_ = -1; } /** - * Returns the number of the currently selected row - * @return int row number, of -1 if nothing is selected - * @public + * Returns the number of the currently selected row. To get data for this row, + * you can use the getValue method. + * @return { Integer } row number, or -1 if nothing is selected */ Dygraph.prototype.getSelection = function() { - if (!this.selPoints_ || this.selPoints_.length < 1) { - return -1; - } - - for (var row=0; row= Dygraph.MONTHLY) { - return date.strftime('%b %y'); - } else { - var frac = date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds() + date.getMilliseconds(); - if (frac == 0 || granularity >= Dygraph.DAILY) { - return new Date(date.getTime() + 3600*1000).strftime('%d%b'); - } else { - return Dygraph.hmsString_(date.getTime()); - } - } -} - -/** - * Convert a JS date (millis since epoch) to YYYY/MM/DD - * @param {Number} date The JavaScript date (ms since epoch) - * @return {String} A date of the form "YYYY/MM/DD" - * @private - */ -Dygraph.dateString_ = function(date, self) { - var zeropad = Dygraph.zeropad; - var d = new Date(date); - - // Get the year: - var year = "" + d.getFullYear(); - // Get a 0 padded month string - var month = zeropad(d.getMonth() + 1); //months are 0-offset, sigh - // Get a 0 padded day string - var day = zeropad(d.getDate()); - - var ret = ""; - var frac = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(); - if (frac) ret = " " + Dygraph.hmsString_(date); - - return year + "/" + month + "/" + day + ret; -}; - -/** - * Round a number to the specified number of digits past the decimal point. - * @param {Number} num The number to round - * @param {Number} places The number of decimals to which to round - * @return {Number} The rounded number - * @private - */ -Dygraph.round_ = function(num, places) { - var shift = Math.pow(10, places); - return Math.round(num * shift)/shift; -}; - -/** - * Fires when there's data available to be graphed. - * @param {String} data Raw CSV data to be plotted - * @private - */ -Dygraph.prototype.loadedEvent_ = function(data) { - this.rawData_ = this.parseCSV_(data); - this.drawGraph_(this.rawData_); -}; - -Dygraph.prototype.months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; -Dygraph.prototype.quarters = ["Jan", "Apr", "Jul", "Oct"]; - -/** - * Add ticks on the x-axis representing years, months, quarters, weeks, or days - * @private - */ -Dygraph.prototype.addXTicks_ = function() { - // Determine the correct ticks scale on the x-axis: quarterly, monthly, ... - var startDate, endDate; - if (this.dateWindow_) { - startDate = this.dateWindow_[0]; - endDate = this.dateWindow_[1]; - } else { - startDate = this.rawData_[0][0]; - endDate = this.rawData_[this.rawData_.length - 1][0]; - } - - var xTicks = this.attr_('xTicker')(startDate, endDate, this); - this.layout_.updateOptions({xTicks: xTicks}); -}; - -// Time granularity enumeration -Dygraph.SECONDLY = 0; -Dygraph.TWO_SECONDLY = 1; -Dygraph.FIVE_SECONDLY = 2; -Dygraph.TEN_SECONDLY = 3; -Dygraph.THIRTY_SECONDLY = 4; -Dygraph.MINUTELY = 5; -Dygraph.TWO_MINUTELY = 6; -Dygraph.FIVE_MINUTELY = 7; -Dygraph.TEN_MINUTELY = 8; -Dygraph.THIRTY_MINUTELY = 9; -Dygraph.HOURLY = 10; -Dygraph.TWO_HOURLY = 11; -Dygraph.SIX_HOURLY = 12; -Dygraph.DAILY = 13; -Dygraph.WEEKLY = 14; -Dygraph.MONTHLY = 15; -Dygraph.QUARTERLY = 16; -Dygraph.BIANNUAL = 17; -Dygraph.ANNUAL = 18; -Dygraph.DECADAL = 19; -Dygraph.NUM_GRANULARITIES = 20; - -Dygraph.SHORT_SPACINGS = []; -Dygraph.SHORT_SPACINGS[Dygraph.SECONDLY] = 1000 * 1; -Dygraph.SHORT_SPACINGS[Dygraph.TWO_SECONDLY] = 1000 * 2; -Dygraph.SHORT_SPACINGS[Dygraph.FIVE_SECONDLY] = 1000 * 5; -Dygraph.SHORT_SPACINGS[Dygraph.TEN_SECONDLY] = 1000 * 10; -Dygraph.SHORT_SPACINGS[Dygraph.THIRTY_SECONDLY] = 1000 * 30; -Dygraph.SHORT_SPACINGS[Dygraph.MINUTELY] = 1000 * 60; -Dygraph.SHORT_SPACINGS[Dygraph.TWO_MINUTELY] = 1000 * 60 * 2; -Dygraph.SHORT_SPACINGS[Dygraph.FIVE_MINUTELY] = 1000 * 60 * 5; -Dygraph.SHORT_SPACINGS[Dygraph.TEN_MINUTELY] = 1000 * 60 * 10; -Dygraph.SHORT_SPACINGS[Dygraph.THIRTY_MINUTELY] = 1000 * 60 * 30; -Dygraph.SHORT_SPACINGS[Dygraph.HOURLY] = 1000 * 3600; -Dygraph.SHORT_SPACINGS[Dygraph.TWO_HOURLY] = 1000 * 3600 * 2; -Dygraph.SHORT_SPACINGS[Dygraph.SIX_HOURLY] = 1000 * 3600 * 6; -Dygraph.SHORT_SPACINGS[Dygraph.DAILY] = 1000 * 86400; -Dygraph.SHORT_SPACINGS[Dygraph.WEEKLY] = 1000 * 604800; - -// NumXTicks() -// -// If we used this time granularity, how many ticks would there be? -// This is only an approximation, but it's generally good enough. -// -Dygraph.prototype.NumXTicks = function(start_time, end_time, granularity) { - if (granularity < Dygraph.MONTHLY) { - // Generate one tick mark for every fixed interval of time. - var spacing = Dygraph.SHORT_SPACINGS[granularity]; - return Math.floor(0.5 + 1.0 * (end_time - start_time) / spacing); - } else { - var year_mod = 1; // e.g. to only print one point every 10 years. - var num_months = 12; - if (granularity == Dygraph.QUARTERLY) num_months = 3; - if (granularity == Dygraph.BIANNUAL) num_months = 2; - if (granularity == Dygraph.ANNUAL) num_months = 1; - if (granularity == Dygraph.DECADAL) { num_months = 1; year_mod = 10; } - - var msInYear = 365.2524 * 24 * 3600 * 1000; - var num_years = 1.0 * (end_time - start_time) / msInYear; - return Math.floor(0.5 + 1.0 * num_years * num_months / year_mod); - } -}; - -// GetXAxis() -// -// Construct an x-axis of nicely-formatted times on meaningful boundaries -// (e.g. 'Jan 09' rather than 'Jan 22, 2009'). -// -// Returns an array containing {v: millis, label: label} dictionaries. -// -Dygraph.prototype.GetXAxis = function(start_time, end_time, granularity) { - var formatter = this.attr_("xAxisLabelFormatter"); - var ticks = []; - if (granularity < Dygraph.MONTHLY) { - // Generate one tick mark for every fixed interval of time. - var spacing = Dygraph.SHORT_SPACINGS[granularity]; - var format = '%d%b'; // e.g. "1Jan" - - // Find a time less than start_time which occurs on a "nice" time boundary - // for this granularity. - var g = spacing / 1000; - var d = new Date(start_time); - if (g <= 60) { // seconds - var x = d.getSeconds(); d.setSeconds(x - x % g); - } else { - d.setSeconds(0); - g /= 60; - if (g <= 60) { // minutes - var x = d.getMinutes(); d.setMinutes(x - x % g); - } else { - d.setMinutes(0); - g /= 60; - - if (g <= 24) { // days - var x = d.getHours(); d.setHours(x - x % g); - } else { - d.setHours(0); - g /= 24; - - if (g == 7) { // one week - d.setDate(d.getDate() - d.getDay()); - } - } - } - } - start_time = d.getTime(); + if (!this.selPoints_ || this.selPoints_.length < 1) { + return -1; + } - for (var t = start_time; t <= end_time; t += spacing) { - ticks.push({ v:t, label: formatter(new Date(t), granularity) }); - } - } else { - // Display a tick mark on the first of a set of months of each year. - // Years get a tick mark iff y % year_mod == 0. This is useful for - // displaying a tick mark once every 10 years, say, on long time scales. - var months; - var year_mod = 1; // e.g. to only print one point every 10 years. - - if (granularity == Dygraph.MONTHLY) { - months = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ]; - } else if (granularity == Dygraph.QUARTERLY) { - months = [ 0, 3, 6, 9 ]; - } else if (granularity == Dygraph.BIANNUAL) { - months = [ 0, 6 ]; - } else if (granularity == Dygraph.ANNUAL) { - months = [ 0 ]; - } else if (granularity == Dygraph.DECADAL) { - months = [ 0 ]; - year_mod = 10; - } - - var start_year = new Date(start_time).getFullYear(); - var end_year = new Date(end_time).getFullYear(); - var zeropad = Dygraph.zeropad; - for (var i = start_year; i <= end_year; i++) { - if (i % year_mod != 0) continue; - for (var j = 0; j < months.length; j++) { - var date_str = i + "/" + zeropad(1 + months[j]) + "/01"; - var t = Date.parse(date_str); - if (t < start_time || t > end_time) continue; - ticks.push({ v:t, label: formatter(new Date(t), granularity) }); - } + for (var row=0; row} Array of {label, value} tuples. - * @public + * Fires when there's data available to be graphed. + * @param {String} data Raw CSV data to be plotted + * @private */ -Dygraph.dateTicker = function(startDate, endDate, self) { - var chosen = -1; - for (var i = 0; i < Dygraph.NUM_GRANULARITIES; i++) { - var num_ticks = self.NumXTicks(startDate, endDate, i); - if (self.width_ / num_ticks >= self.attr_('pixelsPerXLabel')) { - chosen = i; - break; - } - } - - if (chosen >= 0) { - return self.GetXAxis(startDate, endDate, chosen); - } else { - // TODO(danvk): signal error. - } +Dygraph.prototype.loadedEvent_ = function(data) { + this.rawData_ = this.parseCSV_(data); + this.predraw_(); }; /** - * Add ticks when the x axis has numbers on it (instead of dates) - * @param {Number} startDate Start of the date window (millis since epoch) - * @param {Number} endDate End of the date window (millis since epoch) - * @param self - * @param {function} formatter: Optional formatter to use for each tick value - * @return {Array.} Array of {label, value} tuples. - * @public + * Add ticks on the x-axis representing years, months, quarters, weeks, or days + * @private */ -Dygraph.numericTicks = function(minV, maxV, self, formatter) { - // Basic idea: - // Try labels every 1, 2, 5, 10, 20, 50, 100, etc. - // Calculate the resulting tick spacing (i.e. this.height_ / nTicks). - // The first spacing greater than pixelsPerYLabel is what we use. - // TODO(danvk): version that works on a log scale. - if (self.attr_("labelsKMG2")) { - var mults = [1, 2, 4, 8]; +Dygraph.prototype.addXTicks_ = function() { + // Determine the correct ticks scale on the x-axis: quarterly, monthly, ... + var range; + if (this.dateWindow_) { + range = [this.dateWindow_[0], this.dateWindow_[1]]; } else { - var mults = [1, 2, 5]; - } - var scale, low_val, high_val, nTicks; - // TODO(danvk): make it possible to set this for x- and y-axes independently. - var pixelsPerTick = self.attr_('pixelsPerYLabel'); - for (var i = -10; i < 50; i++) { - if (self.attr_("labelsKMG2")) { - var base_scale = Math.pow(16, i); - } else { - var base_scale = Math.pow(10, i); - } - for (var j = 0; j < mults.length; j++) { - scale = base_scale * mults[j]; - low_val = Math.floor(minV / scale) * scale; - high_val = Math.ceil(maxV / scale) * scale; - nTicks = Math.abs(high_val - low_val) / scale; - var spacing = self.height_ / nTicks; - // wish I could break out of both loops at once... - if (spacing > pixelsPerTick) break; - } - if (spacing > pixelsPerTick) break; - } - - // Construct labels for the ticks - var ticks = []; - var k; - var k_labels = []; - if (self.attr_("labelsKMB")) { - k = 1000; - k_labels = [ "K", "M", "B", "T" ]; - } - if (self.attr_("labelsKMG2")) { - if (k) self.warn("Setting both labelsKMB and labelsKMG2. Pick one!"); - k = 1024; - k_labels = [ "k", "M", "G", "T" ]; - } - - // Allow reverse y-axis if it's explicitly requested. - if (low_val > high_val) scale *= -1; - - for (var i = 0; i < nTicks; i++) { - var tickV = low_val + i * scale; - var absTickV = Math.abs(tickV); - var label; - if (formatter != undefined) { - label = formatter(tickV); - } else { - label = Dygraph.round_(tickV, 2); - } - if (k_labels.length) { - // Round up to an appropriate unit. - var n = k*k*k*k; - for (var j = 3; j >= 0; j--, n /= k) { - if (absTickV >= n) { - label = Dygraph.round_(tickV / n, 1) + k_labels[j]; - break; - } - } - } - ticks.push( {label: label, v: tickV} ); + range = [this.rawData_[0][0], this.rawData_[this.rawData_.length - 1][0]]; } - return ticks; + + var xAxisOptionsView = this.optionsViewForAxis_('x'); + var xTicks = xAxisOptionsView('ticker')( + range[0], + range[1], + this.width_, // TODO(danvk): should be area.width + xAxisOptionsView, + this); + // var msg = 'ticker(' + range[0] + ', ' + range[1] + ', ' + this.width_ + ', ' + this.attr_('pixelsPerXLabel') + ') -> ' + JSON.stringify(xTicks); + // console.log(msg); + this.layout_.setXTicks(xTicks); }; /** - * Adds appropriate ticks on the y-axis - * @param {Number} minY The minimum Y value in the data set - * @param {Number} maxY The maximum Y value in the data set * @private + * Computes the range of the data series (including confidence intervals). + * @param { [Array] } series either [ [x1, y1], [x2, y2], ... ] or + * [ [x1, [y1, dev_low, dev_high]], [x2, [y2, dev_low, dev_high]], ... + * @return [low, high] */ -Dygraph.prototype.addYTicks_ = function(minY, maxY) { - // Set the number of ticks so that the labels are human-friendly. - // TODO(danvk): make this an attribute as well. - var formatter = this.attr_('yAxisLabelFormatter') ? this.attr_('yAxisLabelFormatter') : this.attr_('yValueFormatter'); - var ticks = Dygraph.numericTicks(minY, maxY, this, formatter); - this.layout_.updateOptions( { yAxis: [minY, maxY], - yTicks: ticks } ); -}; - -// Computes the range of the data series (including confidence intervals). -// series is either [ [x1, y1], [x2, y2], ... ] or -// [ [x1, [y1, dev_low, dev_high]], [x2, [y2, dev_low, dev_high]], ... -// Returns [low, high] Dygraph.prototype.extremeValues_ = function(series) { var minY = null, maxY = null; @@ -1763,14 +1636,64 @@ Dygraph.prototype.extremeValues_ = function(series) { }; /** - * Update the graph with new data. Data is in the format - * [ [date1, val1, val2, ...], [date2, val1, val2, ...] if errorBars=false - * or, if errorBars=true, - * [ [date1, [val1,stddev1], [val2,stddev2], ...], [date2, ...], ...] - * @param {Array.} data The data (see above) + * @private + * This function is called once when the chart's data is changed or the options + * dictionary is updated. It is _not_ called when the user pans or zooms. The + * idea is that values derived from the chart's data can be computed here, + * rather than every time the chart is drawn. This includes things like the + * number of axes, rolling averages, etc. + */ +Dygraph.prototype.predraw_ = function() { + var start = new Date(); + + // TODO(danvk): move more computations out of drawGraph_ and into here. + this.computeYAxes_(); + + // Create a new plotter. + if (this.plotter_) this.plotter_.clear(); + this.plotter_ = new DygraphCanvasRenderer(this, + this.hidden_, + this.hidden_ctx_, + this.layout_); + + // The roller sits in the bottom left corner of the chart. We don't know where + // 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_(); + + // If the data or options have changed, then we'd better redraw. + this.drawGraph_(); + + // This is used to determine whether to do various animations. + var end = new Date(); + this.drawingTimeMs_ = (end - start); +}; + +/** + * Update the graph with new data. This method is called when the viewing area + * 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(data) { +Dygraph.prototype.drawGraph_ = function(clearSelection) { + var start = new Date(); + + if (typeof(clearSelection) === 'undefined') { + clearSelection = true; + } + + var data = this.rawData_; + // This is used to set the second parameter to drawCallback, below. var is_initial_draw = this.is_initial_draw_; this.is_initial_draw_ = false; @@ -1786,19 +1709,36 @@ Dygraph.prototype.drawGraph_ = function(data) { var cumulative_y = []; // For stacked series. var datasets = []; + var extremes = {}; // series name -> [low, high] + // Loop over all fields and create datasets for (var i = data[0].length - 1; i >= 1; i--) { if (!this.visibility()[i - 1]) continue; + var seriesName = this.attr_("labels")[i]; var connectSeparatedPoints = this.attr_('connectSeparatedPoints', i); + var logScale = this.attr_('logscale', i); var series = []; for (var j = 0; j < data.length; j++) { - if (data[j][i] != null || !connectSeparatedPoints) { - var date = data[j][0]; - series.push([date, data[j][i]]); + var date = data[j][0]; + var point = data[j][i]; + if (logScale) { + // On the log scale, points less than zero do not exist. + // This will create a gap in the chart. Note that this ignores + // connectSeparatedPoints. + if (point <= 0) { + point = null; + } + series.push([date, point]); + } else { + if (point != null || !connectSeparatedPoints) { + series.push([date, point]); + } } } + + // TODO(danvk): move this into predraw_. It's insane to do it here. series = this.rollingAverage(series, this.rollPeriod_); // Prune down to the desired range, if necessary (for zooming) @@ -1833,11 +1773,7 @@ Dygraph.prototype.drawGraph_ = function(data) { this.boundaryIds_[i-1] = [0, series.length-1]; } - var extremes = this.extremeValues_(series); - var thisMinY = extremes[0]; - var thisMaxY = extremes[1]; - if (minY === null || (thisMinY != null && thisMinY < minY)) minY = thisMinY; - if (maxY === null || (thisMaxY != null && thisMaxY > maxY)) maxY = thisMaxY; + var seriesExtremes = this.extremeValues_(series); if (bars) { for (var j=0; j maxY) - maxY = cumulative_y[x]; + if (cumulative_y[x] > seriesExtremes[1]) { + seriesExtremes[1] = cumulative_y[x]; + } + if (cumulative_y[x] < seriesExtremes[0]) { + seriesExtremes[0] = cumulative_y[x]; + } } } + extremes[seriesName] = seriesExtremes; datasets[i] = series; } @@ -1872,48 +1814,48 @@ Dygraph.prototype.drawGraph_ = function(data) { this.layout_.addDataset(this.attr_("labels")[i], datasets[i]); } - // Use some heuristics to come up with a good maxY value, unless it's been - // set explicitly by the developer or end-user (via drag) - if (this.valueWindow_ != null) { - this.addYTicks_(this.valueWindow_[0], this.valueWindow_[1]); - this.displayedYRange_ = this.valueWindow_; - } else { - // This affects the calculation of span, below. - if (this.attr_("includeZero") && minY > 0) { - minY = 0; - } + this.computeYAxisRanges_(extremes); + this.layout_.setYAxes(this.axes_); - // Add some padding and round up to an integer to be human-friendly. - var span = maxY - minY; - // special case: if we have no sense of scale, use +/-10% of the sole value. - if (span == 0) { span = maxY; } - var maxAxisY = maxY + 0.1 * span; - var minAxisY = minY - 0.1 * span; + this.addXTicks_(); - // Try to include zero and make it minAxisY (or maxAxisY) if it makes sense. - if (!this.attr_("avoidMinZero")) { - if (minAxisY < 0 && minY >= 0) minAxisY = 0; - if (maxAxisY > 0 && maxY <= 0) maxAxisY = 0; - } + // Save the X axis zoomed status as the updateOptions call will tend to set it erroneously + var tmp_zoomed_x = this.zoomed_x_; + // Tell PlotKit to use this new data and render itself + this.layout_.setDateWindow(this.dateWindow_); + this.zoomed_x_ = tmp_zoomed_x; + this.layout_.evaluateWithError(); + this.renderGraph_(is_initial_draw, false); - if (this.attr_("includeZero")) { - if (maxY < 0) maxAxisY = 0; - if (minY > 0) minAxisY = 0; + if (this.attr_("timingName")) { + var end = new Date(); + if (console) { + console.log(this.attr_("timingName") + " - drawGraph: " + (end - start) + "ms") } - - this.addYTicks_(minAxisY, maxAxisY); - this.displayedYRange_ = [minAxisY, maxAxisY]; } +}; - this.addXTicks_(); - - // Tell PlotKit to use this new data and render itself - this.layout_.updateOptions({dateWindow: this.dateWindow_}); - this.layout_.evaluateWithError(); +Dygraph.prototype.renderGraph_ = function(is_initial_draw, clearSelection) { this.plotter_.clear(); this.plotter_.render(); this.canvas_.getContext('2d').clearRect(0, 0, this.canvas_.width, - this.canvas_.height); + this.canvas_.height); + + if (is_initial_draw) { + // Generate a static legend before any particular point is selected. + this.setLegendHTML_(); + } else { + 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.attr_("drawCallback") !== null) { this.attr_("drawCallback")(this, is_initial_draw); @@ -1921,6 +1863,258 @@ Dygraph.prototype.drawGraph_ = function(data) { }; /** + * @private + * Determine properties of the y-axes which are independent of the data + * currently being displayed. This includes things like the number of axes and + * the style of the axes. It does not include the range of each axis and its + * tick marks. + * This fills in this.axes_ and this.seriesToAxisMap_. + * axes_ = [ { options } ] + * seriesToAxisMap_ = { seriesName: 0, seriesName2: 1, ... } + * indices are into the axes_ array. + */ +Dygraph.prototype.computeYAxes_ = function() { + // Preserve valueWindow settings if they exist, and if the user hasn't + // specified a new valueRange. + var valueWindows; + if (this.axes_ != undefined && this.user_attrs_.hasOwnProperty("valueRange") == false) { + valueWindows = []; + for (var index = 0; index < this.axes_.length; index++) { + valueWindows.push(this.axes_[index].valueWindow); + } + } + + this.axes_ = [{ yAxisId : 0, g : this }]; // always have at least one y-axis. + this.seriesToAxisMap_ = {}; + + // Get a list of series names. + var labels = this.attr_("labels"); + var series = {}; + for (var i = 1; i < labels.length; i++) series[labels[i]] = (i - 1); + + // all options which could be applied per-axis: + var axisOptions = [ + 'includeZero', + 'valueRange', + 'labelsKMB', + 'labelsKMG2', + 'pixelsPerYLabel', + 'yAxisLabelWidth', + 'axisLabelFontSize', + 'axisTickSize', + 'logscale' + ]; + + // Copy global axis options over to the first axis. + for (var i = 0; i < axisOptions.length; i++) { + var k = axisOptions[i]; + var v = this.attr_(k); + if (v) this.axes_[0][k] = v; + } + + // Go through once and add all the axes. + for (var seriesName in series) { + if (!series.hasOwnProperty(seriesName)) continue; + var axis = this.attr_("axis", seriesName); + if (axis == null) { + this.seriesToAxisMap_[seriesName] = 0; + continue; + } + if (typeof(axis) == 'object') { + // Add a new axis, making a copy of its per-axis options. + var opts = {}; + Dygraph.update(opts, this.axes_[0]); + Dygraph.update(opts, { valueRange: null }); // shouldn't inherit this. + var yAxisId = this.axes_.length; + opts.yAxisId = yAxisId; + opts.g = this; + Dygraph.update(opts, axis); + this.axes_.push(opts); + this.seriesToAxisMap_[seriesName] = yAxisId; + } + } + + // Go through one more time and assign series to an axis defined by another + // series, e.g. { 'Y1: { axis: {} }, 'Y2': { axis: 'Y1' } } + for (var seriesName in series) { + if (!series.hasOwnProperty(seriesName)) continue; + var axis = this.attr_("axis", seriesName); + if (typeof(axis) == 'string') { + if (!this.seriesToAxisMap_.hasOwnProperty(axis)) { + this.error("Series " + seriesName + " wants to share a y-axis with " + + "series " + axis + ", which does not define its own axis."); + return null; + } + var idx = this.seriesToAxisMap_[axis]; + this.seriesToAxisMap_[seriesName] = idx; + } + } + + // Now we remove series from seriesToAxisMap_ which are not visible. We do + // this last so that hiding the first series doesn't destroy the axis + // properties of the primary axis. + var seriesToAxisFiltered = {}; + var vis = this.visibility(); + for (var i = 1; i < labels.length; i++) { + var s = labels[i]; + if (vis[i - 1]) seriesToAxisFiltered[s] = this.seriesToAxisMap_[s]; + } + this.seriesToAxisMap_ = seriesToAxisFiltered; + + if (valueWindows != undefined) { + // Restore valueWindow settings. + for (var index = 0; index < valueWindows.length; index++) { + this.axes_[index].valueWindow = valueWindows[index]; + } + } +}; + +/** + * Returns the number of y-axes on the chart. + * @return {Number} the number of axes. + */ +Dygraph.prototype.numAxes = function() { + var last_axis = 0; + for (var series in this.seriesToAxisMap_) { + if (!this.seriesToAxisMap_.hasOwnProperty(series)) continue; + var idx = this.seriesToAxisMap_[series]; + if (idx > last_axis) last_axis = idx; + } + return 1 + last_axis; +}; + +/** + * @private + * Returns axis properties for the given series. + * @param { String } setName The name of the series for which to get axis + * properties, e.g. 'Y1'. + * @return { Object } The axis properties. + */ +Dygraph.prototype.axisPropertiesForSeries = function(series) { + // TODO(danvk): handle errors. + return this.axes_[this.seriesToAxisMap_[series]]; +}; + +/** + * @private + * Determine the value range and tick marks for each axis. + * @param {Object} extremes A mapping from seriesName -> [low, high] + * This fills in the valueRange and ticks fields in each entry of this.axes_. + */ +Dygraph.prototype.computeYAxisRanges_ = function(extremes) { + // Build a map from axis number -> [list of series names] + var seriesForAxis = []; + for (var series in this.seriesToAxisMap_) { + if (!this.seriesToAxisMap_.hasOwnProperty(series)) continue; + var idx = this.seriesToAxisMap_[series]; + while (seriesForAxis.length <= idx) seriesForAxis.push([]); + seriesForAxis[idx].push(series); + } + + // Compute extreme values, a span and tick marks for each axis. + for (var i = 0; i < this.axes_.length; i++) { + var axis = this.axes_[i]; + + if (!seriesForAxis[i]) { + // If no series are defined or visible then use a reasonable default + axis.extremeRange = [0, 1]; + } else { + // Calculate the extremes of extremes. + var series = seriesForAxis[i]; + var minY = Infinity; // extremes[series[0]][0]; + var maxY = -Infinity; // extremes[series[0]][1]; + var extremeMinY, extremeMaxY; + for (var j = 0; j < series.length; j++) { + // Only use valid extremes to stop null data series' from corrupting the scale. + extremeMinY = extremes[series[j]][0]; + if (extremeMinY != null) { + minY = Math.min(extremeMinY, minY); + } + extremeMaxY = extremes[series[j]][1]; + if (extremeMaxY != null) { + maxY = Math.max(extremeMaxY, maxY); + } + } + if (axis.includeZero && minY > 0) minY = 0; + + // Ensure we have a valid scale, otherwise defualt to zero for safety. + if (minY == Infinity) minY = 0; + if (maxY == -Infinity) maxY = 0; + + // Add some padding and round up to an integer to be human-friendly. + var span = maxY - minY; + // special case: if we have no sense of scale, use +/-10% of the sole value. + if (span == 0) { span = maxY; } + + var maxAxisY; + var minAxisY; + if (axis.logscale) { + var maxAxisY = maxY + 0.1 * span; + var minAxisY = minY; + } else { + var maxAxisY = maxY + 0.1 * span; + var minAxisY = minY - 0.1 * span; + + // Try to include zero and make it minAxisY (or maxAxisY) if it makes sense. + if (!this.attr_("avoidMinZero")) { + if (minAxisY < 0 && minY >= 0) minAxisY = 0; + if (maxAxisY > 0 && maxY <= 0) maxAxisY = 0; + } + + if (this.attr_("includeZero")) { + if (maxY < 0) maxAxisY = 0; + if (minY > 0) minAxisY = 0; + } + } + axis.extremeRange = [minAxisY, maxAxisY]; + } + if (axis.valueWindow) { + // This is only set if the user has zoomed on the y-axis. It is never set + // by a user. It takes precedence over axis.valueRange because, if you set + // valueRange, you'd still expect to be able to pan. + axis.computedValueRange = [axis.valueWindow[0], axis.valueWindow[1]]; + } else if (axis.valueRange) { + // This is a user-set value range for this axis. + axis.computedValueRange = [axis.valueRange[0], axis.valueRange[1]]; + } else { + axis.computedValueRange = axis.extremeRange; + } + + // Add ticks. By default, all axes inherit the tick positions of the + // primary axis. However, if an axis is specifically marked as having + // independent ticks, then that is permissible as well. + var opts = this.optionsViewForAxis_('y' + (i ? '2' : '')); + var ticker = opts('ticker'); + if (i == 0 || axis.independentTicks) { + axis.ticks = ticker(axis.computedValueRange[0], + axis.computedValueRange[1], + this.height_, // TODO(danvk): should be area.height + opts, + this); + } else { + var p_axis = this.axes_[0]; + var p_ticks = p_axis.ticks; + var p_scale = p_axis.computedValueRange[1] - p_axis.computedValueRange[0]; + var scale = axis.computedValueRange[1] - axis.computedValueRange[0]; + var tick_values = []; + for (var k = 0; k < p_ticks.length; k++) { + var y_frac = (p_ticks[k].v - p_axis.computedValueRange[0]) / p_scale; + var y_val = axis.computedValueRange[0] + y_frac * scale; + tick_values.push(y_val); + } + + axis.ticks = ticker(axis.computedValueRange[0], + axis.computedValueRange[1], + this.height_, // TODO(danvk): should be area.height + opts, + this, + tick_values); + } + } +}; + +/** + * @private * Calculates the rolling average of a data set. * If originalData is [label, val], rolls the average of those. * If originalData is [label, [, it's interpreted as [value, stddev] @@ -1929,12 +2123,13 @@ Dygraph.prototype.drawGraph_ = function(data) { * Note that this is where fractional input (i.e. '5/10') is converted into * decimal values. * @param {Array} originalData The data in the appropriate format (see above) - * @param {Number} rollPeriod The number of days over which to average the data + * @param {Number} rollPeriod The number of points over which to average the + * data */ Dygraph.prototype.rollingAverage = function(originalData, rollPeriod) { if (originalData.length < 2) return originalData; - var rollPeriod = Math.min(rollPeriod, originalData.length - 1); + var rollPeriod = Math.min(rollPeriod, originalData.length); var rollingData = []; var sigma = this.attr_("sigma"); @@ -2000,13 +2195,17 @@ Dygraph.prototype.rollingAverage = function(originalData, rollPeriod) { count -= 1; } } - rollingData[i] = [originalData[i][0], [ 1.0 * mid / count, - 1.0 * (mid - low) / count, - 1.0 * (high - mid) / count ]]; + if (count) { + rollingData[i] = [originalData[i][0], [ 1.0 * mid / count, + 1.0 * (mid - low) / count, + 1.0 * (high - mid) / count ]]; + } else { + rollingData[i] = [originalData[i][0], [null, null, null]]; + } } } else { // Calculate the rolling average for the first rollPeriod - 1 points where - // there is not enough data to roll over the full number of days + // there is not enough data to roll over the full number of points var num_init_points = Math.min(rollPeriod - 1, originalData.length - 2); if (!this.attr_("errorBars")){ if (rollPeriod == 1) { @@ -2056,40 +2255,6 @@ Dygraph.prototype.rollingAverage = function(originalData, rollPeriod) { }; /** - * Parses a date, returning the number of milliseconds since epoch. This can be - * passed in as an xValueParser in the Dygraph constructor. - * TODO(danvk): enumerate formats that this understands. - * @param {String} A date in YYYYMMDD format. - * @return {Number} Milliseconds since epoch. - * @public - */ -Dygraph.dateParser = function(dateStr, self) { - var dateStrSlashed; - var d; - if (dateStr.search("-") != -1) { // e.g. '2009-7-12' or '2009-07-12' - dateStrSlashed = dateStr.replace("-", "/", "g"); - while (dateStrSlashed.search("-") != -1) { - dateStrSlashed = dateStrSlashed.replace("-", "/"); - } - d = Date.parse(dateStrSlashed); - } else if (dateStr.length == 8) { // e.g. '20090712' - // TODO(danvk): remove support for this format. It's confusing. - dateStrSlashed = dateStr.substr(0,4) + "/" + dateStr.substr(4,2) - + "/" + dateStr.substr(6,2); - d = Date.parse(dateStrSlashed); - } else { - // Any format that Date.parse will accept, e.g. "2009/07/12" or - // "2009/07/12 12:34:56" - d = Date.parse(dateStr); - } - - if (!d || isNaN(d)) { - self.error("Couldn't parse " + dateStr + " as a date"); - } - return d; -}; - -/** * Detects the type of the str (date or numeric) and sets the various * formatting attributes in this.attrs_ based on this type. * @param {String} str An x value. @@ -2097,7 +2262,7 @@ Dygraph.dateParser = function(dateStr, self) { */ Dygraph.prototype.detectTypeFromString_ = function(str) { var isDate = false; - if (str.indexOf('-') >= 0 || + if (str.indexOf('-') > 0 || str.indexOf('/') >= 0 || isNaN(parseFloat(str))) { isDate = true; @@ -2107,28 +2272,65 @@ Dygraph.prototype.detectTypeFromString_ = function(str) { } if (isDate) { - this.attrs_.xValueFormatter = Dygraph.dateString_; this.attrs_.xValueParser = Dygraph.dateParser; - this.attrs_.xTicker = Dygraph.dateTicker; - this.attrs_.xAxisLabelFormatter = Dygraph.dateAxisFormatter; + this.attrs_.axes.x.valueFormatter = Dygraph.dateString_; + this.attrs_.axes.x.ticker = Dygraph.dateTicker; + this.attrs_.axes.x.axisLabelFormatter = Dygraph.dateAxisFormatter; } else { - this.attrs_.xValueFormatter = function(x) { return x; }; + /** @private (shut up, jsdoc!) */ this.attrs_.xValueParser = function(x) { return parseFloat(x); }; - this.attrs_.xTicker = Dygraph.numericTicks; - this.attrs_.xAxisLabelFormatter = this.attrs_.xValueFormatter; + // TODO(danvk): use Dygraph.numberValueFormatter here? + /** @private (shut up, jsdoc!) */ + this.attrs_.axes.x.valueFormatter = function(x) { return x; }; + this.attrs_.axes.x.ticker = Dygraph.numericTicks; + this.attrs_.axes.x.axisLabelFormatter = this.attrs_.axes.x.valueFormatter; + } +}; + +/** + * Parses the value as a floating point number. This is like the parseFloat() + * built-in, but with a few differences: + * - the empty string is parsed as null, rather than NaN. + * - if the string cannot be parsed at all, an error is logged. + * If the string can't be parsed, this method returns null. + * @param {String} x The string to be parsed + * @param {Number} opt_line_no The line number from which the string comes. + * @param {String} opt_line The text of the line from which the string comes. + * @private + */ + +// Parse the x as a float or return null if it's not a number. +Dygraph.prototype.parseFloat_ = function(x, opt_line_no, opt_line) { + var val = parseFloat(x); + if (!isNaN(val)) return val; + + // Try to figure out what happeend. + // If the value is the empty string, parse it as null. + if (/^ *$/.test(x)) return null; + + // If it was actually "NaN", return it as NaN. + if (/^ *nan *$/i.test(x)) return NaN; + + // Looks like a parsing error. + var msg = "Unable to parse '" + x + "' as a number"; + if (opt_line !== null && opt_line_no !== null) { + msg += " on line " + (1+opt_line_no) + " ('" + opt_line + "') of CSV."; } + this.error(msg); + + return null; }; /** + * @private * Parses a string in a special csv format. We expect a csv file where each * line is a date point, and the first field in each line is the date string. * We also expect that all remaining fields represent series. * if the errorBars attribute is set, then interpret the fields as: * date, series1, stddev1, series2, stddev2, ... - * @param {Array.} data See above. - * @private + * @param {[Object]} data See above. * - * @return Array. An array with one entry for each row. These entries + * @return [Object] An array with one entry for each row. These entries * are an array of cells in that row. The first entry is the parsed x-value for * the row. The second, third, etc. are the y-values. These can take on one of * three forms, depending on the CSV and constructor parameters: @@ -2147,16 +2349,12 @@ Dygraph.prototype.parseCSV_ = function(data) { } var start = 0; - if (this.labelsFromCSV_) { + if (!('labels' in this.user_attrs_)) { + // User hasn't explicitly set labels, so they're (presumably) in the CSV. start = 1; - this.attrs_.labels = lines[0].split(delim); + this.attrs_.labels = lines[0].split(delim); // NOTE: _not_ user_attrs_. } - - // Parse the x as a float or return null if it's not a number. - var parseFloatOrNull = function(x) { - var val = parseFloat(x); - return isNaN(val) ? null : val; - }; + var line_no = 0; var xParser; var defaultParserSet = false; // attempt to auto-detect x value type @@ -2164,6 +2362,7 @@ Dygraph.prototype.parseCSV_ = function(data) { var outOfOrder = false; for (var i = start; i < lines.length; i++) { var line = lines[i]; + line_no = i; if (line.length == 0) continue; // skip blank lines if (line[0] == '#') continue; // skip comment lines var inFields = line.split(delim); @@ -2182,37 +2381,79 @@ Dygraph.prototype.parseCSV_ = function(data) { for (var j = 1; j < inFields.length; j++) { // TODO(danvk): figure out an appropriate way to flag parse errors. var vals = inFields[j].split("/"); - fields[j] = [parseFloatOrNull(vals[0]), parseFloatOrNull(vals[1])]; + if (vals.length != 2) { + this.error('Expected fractional "num/den" values in CSV data ' + + "but found a value '" + inFields[j] + "' on line " + + (1 + i) + " ('" + line + "') which is not of this form."); + fields[j] = [0, 0]; + } else { + fields[j] = [this.parseFloat_(vals[0], i, line), + this.parseFloat_(vals[1], i, line)]; + } } } else if (this.attr_("errorBars")) { // If there are error bars, values are (value, stddev) pairs - for (var j = 1; j < inFields.length; j += 2) - fields[(j + 1) / 2] = [parseFloatOrNull(inFields[j]), - parseFloatOrNull(inFields[j + 1])]; + if (inFields.length % 2 != 1) { + this.error('Expected alternating (value, stdev.) pairs in CSV data ' + + 'but line ' + (1 + i) + ' has an odd number of values (' + + (inFields.length - 1) + "): '" + line + "'"); + } + for (var j = 1; j < inFields.length; j += 2) { + fields[(j + 1) / 2] = [this.parseFloat_(inFields[j], i, line), + this.parseFloat_(inFields[j + 1], i, line)]; + } } else if (this.attr_("customBars")) { // Bars are a low;center;high tuple for (var j = 1; j < inFields.length; j++) { - var vals = inFields[j].split(";"); - fields[j] = [ parseFloatOrNull(vals[0]), - parseFloatOrNull(vals[1]), - parseFloatOrNull(vals[2]) ]; + var val = inFields[j]; + if (/^ *$/.test(val)) { + fields[j] = [null, null, null]; + } else { + var vals = val.split(";"); + if (vals.length == 3) { + fields[j] = [ this.parseFloat_(vals[0], i, line), + this.parseFloat_(vals[1], i, line), + this.parseFloat_(vals[2], i, line) ]; + } else { + this.warning('When using customBars, values must be either blank ' + + 'or "low;center;high" tuples (got "' + val + + '" on line ' + (1+i)); + } + } } } else { // Values are just numbers for (var j = 1; j < inFields.length; j++) { - fields[j] = parseFloatOrNull(inFields[j]); + fields[j] = this.parseFloat_(inFields[j], i, line); } } if (ret.length > 0 && fields[0] < ret[ret.length - 1][0]) { outOfOrder = true; } - ret.push(fields); if (fields.length != expectedCols) { this.error("Number of columns in line " + i + " (" + fields.length + ") does not agree with number of labels (" + expectedCols + ") " + line); } + + // If the user specified the 'labels' option and none of the cells of the + // first row parsed correctly, then they probably double-specified the + // labels. We go with the values set in the option, discard this row and + // log a warning to the JS console. + if (i == 0 && this.attr_('labels')) { + var all_null = true; + for (var j = 0; all_null && j < fields.length; j++) { + if (fields[j]) all_null = false; + } + if (all_null) { + this.warn("The dygraphs 'labels' option is set, but the first row of " + + "CSV data ('" + line + "') appears to also contain labels. " + + "Will drop the CSV labels and use the option labels."); + continue; + } + } + ret.push(fields); } if (outOfOrder) { @@ -2224,11 +2465,12 @@ Dygraph.prototype.parseCSV_ = function(data) { }; /** + * @private * The user has provided their data as a pre-packaged JS array. If the x values * are numeric, this is the same as dygraphs' internal format. If the x values * are dates, we need to convert them from Date objects to ms since epoch. - * @param {Array.} data - * @return {Array.} data with numeric x values. + * @param {[Object]} data + * @return {[Object]} data with numeric x values. */ Dygraph.prototype.parseArray_ = function(data) { // Peek at the first x value to see if it's numeric. @@ -2252,9 +2494,9 @@ Dygraph.prototype.parseArray_ = function(data) { if (Dygraph.isDateLike(data[0][0])) { // Some intelligent defaults for a date x-axis. - this.attrs_.xValueFormatter = Dygraph.dateString_; - this.attrs_.xAxisLabelFormatter = Dygraph.dateAxisFormatter; - this.attrs_.xTicker = Dygraph.dateTicker; + this.attrs_.axes.x.valueFormatter = Dygraph.dateString_; + this.attrs_.axes.x.axisLabelFormatter = Dygraph.dateAxisFormatter; + this.attrs_.axes.x.ticker = Dygraph.dateTicker; // Assume they're all dates. var parsedData = Dygraph.clone(data); @@ -2274,8 +2516,10 @@ Dygraph.prototype.parseArray_ = function(data) { return parsedData; } else { // Some intelligent defaults for a numeric x-axis. - this.attrs_.xValueFormatter = function(x) { return x; }; - this.attrs_.xTicker = Dygraph.numericTicks; + /** @private (shut up, jsdoc!) */ + this.attrs_.axes.x.valueFormatter = function(x) { return x; }; + this.attrs_.axes.x.axisLabelFormatter = Dygraph.numberAxisLabelFormatter; + this.attrs_.axes.x.ticker = Dygraph.numericTicks; return data; } }; @@ -2286,7 +2530,7 @@ Dygraph.prototype.parseArray_ = function(data) { * number. All subsequent columns must be numbers. If there is a clear mismatch * between this.xValueParser_ and the type of the first column, it will be * fixed. Fills out rawData_. - * @param {Array.} data See above. + * @param {[Object]} data See above. * @private */ Dygraph.prototype.parseDataTable_ = function(data) { @@ -2295,15 +2539,15 @@ Dygraph.prototype.parseDataTable_ = function(data) { var indepType = data.getColumnType(0); if (indepType == 'date' || indepType == 'datetime') { - this.attrs_.xValueFormatter = Dygraph.dateString_; this.attrs_.xValueParser = Dygraph.dateParser; - this.attrs_.xTicker = Dygraph.dateTicker; - this.attrs_.xAxisLabelFormatter = Dygraph.dateAxisFormatter; + this.attrs_.axes.x.valueFormatter = Dygraph.dateString_; + this.attrs_.axes.x.ticker = Dygraph.dateTicker; + this.attrs_.axes.x.axisLabelFormatter = Dygraph.dateAxisFormatter; } else if (indepType == 'number') { - this.attrs_.xValueFormatter = function(x) { return x; }; this.attrs_.xValueParser = function(x) { return parseFloat(x); }; - this.attrs_.xTicker = Dygraph.numericTicks; - this.attrs_.xAxisLabelFormatter = this.attrs_.xValueFormatter; + this.attrs_.axes.x.valueFormatter = function(x) { return x; }; + this.attrs_.axes.x.ticker = Dygraph.numericTicks; + this.attrs_.axes.x.axisLabelFormatter = this.attrs_.axes.x.valueFormatter; } else { this.error("only 'date', 'datetime' and 'number' types are supported for " + "column 1 of DataTable input (Got '" + indepType + "')"); @@ -2379,6 +2623,11 @@ Dygraph.prototype.parseDataTable_ = function(data) { annotations.push(ann); } } + + // Strip out infinities, which give dygraphs problems later on. + for (var j = 0; j < row.length; j++) { + if (!isFinite(row[j])) row[j] = null; + } } else { for (var j = 0; j < cols - 1; j++) { row.push([ data.getValue(i, 1 + 2 * j), data.getValue(i, 2 + 2 * j) ]); @@ -2401,54 +2650,6 @@ Dygraph.prototype.parseDataTable_ = function(data) { } } -// These functions are all based on MochiKit. -Dygraph.update = function (self, o) { - if (typeof(o) != 'undefined' && o !== null) { - for (var k in o) { - if (o.hasOwnProperty(k)) { - self[k] = o[k]; - } - } - } - return self; -}; - -Dygraph.isArrayLike = function (o) { - var typ = typeof(o); - if ( - (typ != 'object' && !(typ == 'function' && - typeof(o.item) == 'function')) || - o === null || - typeof(o.length) != 'number' || - o.nodeType === 3 - ) { - return false; - } - return true; -}; - -Dygraph.isDateLike = function (o) { - if (typeof(o) != "object" || o === null || - typeof(o.getTime) != 'function') { - return false; - } - return true; -}; - -Dygraph.clone = function(o) { - // TODO(danvk): figure out how MochiKit's version works - var r = []; - for (var i = 0; i < o.length; i++) { - if (Dygraph.isArrayLike(o[i])) { - r.push(Dygraph.clone(o[i])); - } else { - r.push(o[i]); - } - } - return r; -}; - - /** * Get the CSV data. If it's in a function, call that function. If it's in a * file, do an XMLHttpRequest to get it. @@ -2460,12 +2661,12 @@ Dygraph.prototype.start_ = function() { this.loadedEvent_(this.file_()); } else if (Dygraph.isArrayLike(this.file_)) { this.rawData_ = this.parseArray_(this.file_); - this.drawGraph_(this.rawData_); + this.predraw_(); } else if (typeof this.file_ == 'object' && typeof this.file_.getColumnRange == 'function') { // must be a DataTable from gviz. this.parseDataTable_(this.file_); - this.drawGraph_(this.rawData_); + this.predraw_(); } else if (typeof this.file_ == 'string') { // Heuristic: a newline means it's CSV data. Otherwise it's an URL. if (this.file_.indexOf('\n') >= 0) { @@ -2475,7 +2676,8 @@ Dygraph.prototype.start_ = function() { var caller = this; req.onreadystatechange = function () { if (req.readyState == 4) { - if (req.status == 200) { + if (req.status == 200 || // Normal http + req.status == 0) { // Chrome w/ --allow-file-access-from-files caller.loadedEvent_(req.responseText); } } @@ -2495,19 +2697,36 @@ Dygraph.prototype.start_ = function() { *
  • file: changes the source data for the graph
  • *
  • errorBars: changes whether the data contains stddev
  • * + * + * There's a huge variety of options that can be passed to this method. For a + * full list, see http://dygraphs.com/options.html. + * * @param {Object} attrs The new properties and values + * @param {Boolean} [block_redraw] Usually the chart is redrawn after every + * call to updateOptions(). If you know better, you can pass true to explicitly + * block the redraw. This can be useful for chaining updateOptions() calls, + * avoiding the occasional infinite loop and preventing redraws when it's not + * necessary (e.g. when updating a callback). */ -Dygraph.prototype.updateOptions = function(attrs) { - // TODO(danvk): this is a mess. Rethink this function. +Dygraph.prototype.updateOptions = function(input_attrs, block_redraw) { + if (typeof(block_redraw) == 'undefined') block_redraw = false; + + // mapLegacyOptions_ drops the "file" parameter as a convenience to us. + var file = input_attrs['file']; + var attrs = Dygraph.mapLegacyOptions_(input_attrs); + + // TODO(danvk): this is a mess. Move these options into attr_. if ('rollPeriod' in attrs) { this.rollPeriod_ = attrs.rollPeriod; } if ('dateWindow' in attrs) { this.dateWindow_ = attrs.dateWindow; + if (!('isZoomedIgnoreProgrammaticZoom' in attrs)) { + this.zoomed_x_ = attrs.dateWindow != null; + } } - if ('valueRange' in attrs) { - this.valueRange_ = attrs.valueRange; - this.valueWindow_ = attrs.valueRange; + if ('valueRange' in attrs && !('isZoomedIgnoreProgrammaticZoom' in attrs)) { + this.zoomed_y_ = attrs.valueRange != null; } // TODO(danvk): validate per-series options. @@ -2517,19 +2736,60 @@ Dygraph.prototype.updateOptions = function(attrs) { // drawPoints // highlightCircleSize - Dygraph.update(this.user_attrs_, attrs); - Dygraph.update(this.renderOptions_, attrs); + // Check if this set options will require new points. + var requiresNewPoints = Dygraph.isPixelChangingOptionList(this.attr_("labels"), attrs); - this.labelsFromCSV_ = (this.attr_("labels") == null); + Dygraph.updateDeep(this.user_attrs_, attrs); - // TODO(danvk): this doesn't match the constructor logic - this.layout_.updateOptions({ 'errorBars': this.attr_("errorBars") }); - if (attrs['file']) { - this.file_ = attrs['file']; - this.start_(); + if (file) { + this.file_ = file; + if (!block_redraw) this.start_(); } else { - this.drawGraph_(this.rawData_); + if (!block_redraw) { + if (requiresNewPoints) { + this.predraw_(); + } else { + this.renderGraph_(false, false); + } + } + } +}; + +/** + * Returns a copy of the options with deprecated names converted into current + * names. Also drops the (potentially-large) 'file' attribute. If the caller is + * interested in that, they should save a copy before calling this. + * @private + */ +Dygraph.mapLegacyOptions_ = function(attrs) { + var my_attrs = {}; + for (var k in attrs) { + if (k == 'file') continue; + if (attrs.hasOwnProperty(k)) my_attrs[k] = attrs[k]; } + + var set = function(axis, opt, value) { + if (!my_attrs.axes) my_attrs.axes = {}; + if (!my_attrs.axes[axis]) my_attrs.axes[axis] = {}; + my_attrs.axes[axis][opt] = value; + }; + var map = function(opt, axis, new_opt) { + if (typeof(attrs[opt]) != 'undefined') { + set(axis, new_opt, attrs[opt]); + delete my_attrs[opt]; + } + }; + + // This maps, e.g., xValueFormater -> axes: { x: { valueFormatter: ... } } + map('xValueFormatter', 'x', 'valueFormatter'); + map('pixelsPerXLabel', 'x', 'pixelsPerLabel'); + map('xAxisLabelFormatter', 'x', 'axisLabelFormatter'); + map('xTicker', 'x', 'ticker'); + map('yValueFormatter', 'y', 'valueFormatter'); + map('pixelsPerYLabel', 'y', 'pixelsPerLabel'); + map('yAxisLabelFormatter', 'y', 'axisLabelFormatter'); + map('yTicker', 'y', 'ticker'); + return my_attrs; }; /** @@ -2540,8 +2800,8 @@ Dygraph.prototype.updateOptions = function(attrs) { * This is far more efficient than destroying and re-instantiating a * Dygraph, since it doesn't have to reparse the underlying data. * - * @param {Number} width Width (in pixels) - * @param {Number} height Height (in pixels) + * @param {Number} [width] Width (in pixels) + * @param {Number} [height] Height (in pixels) */ Dygraph.prototype.resize = function(width, height) { if (this.resize_lock) { @@ -2555,9 +2815,8 @@ Dygraph.prototype.resize = function(width, height) { width = height = null; } - // TODO(danvk): there should be a clear() method. - this.maindiv_.innerHTML = ""; - this.attrs_.labelsDiv = null; + var old_width = this.width_; + var old_height = this.height_; if (width) { this.maindiv_.style.width = width + "px"; @@ -2569,20 +2828,30 @@ Dygraph.prototype.resize = function(width, height) { this.height_ = this.maindiv_.offsetHeight; } - this.createInterface_(); - this.drawGraph_(this.rawData_); + if (old_width != this.width_ || old_height != this.height_) { + // TODO(danvk): there should be a clear() method. + this.maindiv_.innerHTML = ""; + this.roller_ = null; + this.attrs_.labelsDiv = null; + this.createInterface_(); + if (this.annotations_.length) { + // createInterface_ reset the layout, so we need to do this. + this.layout_.setAnnotations(this.annotations_); + } + this.predraw_(); + } this.resize_lock = false; }; /** - * Adjusts the number of days in the rolling average. Updates the graph to + * Adjusts the number of points in the rolling average. Updates the graph to * reflect the new averaging period. - * @param {Number} length Number of days over which to average the data. + * @param {Number} length Number of points over which to average the data. */ Dygraph.prototype.adjustRoll = function(length) { this.rollPeriod_ = length; - this.drawGraph_(this.rawData_); + this.predraw_(); }; /** @@ -2605,15 +2874,25 @@ Dygraph.prototype.visibility = function() { */ Dygraph.prototype.setVisibility = function(num, value) { var x = this.visibility(); - if (num < 0 && num >= x.length) { + if (num < 0 || num >= x.length) { this.warn("invalid series number in setVisibility: " + num); } else { x[num] = value; - this.drawGraph_(this.rawData_); + this.predraw_(); } }; /** + * How large of an area will the dygraph render itself in? + * This is used for testing. + * @return A {width: w, height: h} object. + * @private + */ +Dygraph.prototype.size = function() { + return { width: this.width_, height: this.height_ }; +}; + +/** * Update the list of annotations and redraw the chart. */ Dygraph.prototype.setAnnotations = function(ann, suppressDraw) { @@ -2622,7 +2901,7 @@ Dygraph.prototype.setAnnotations = function(ann, suppressDraw) { this.annotations_ = ann; this.layout_.setAnnotations(this.annotations_); if (!suppressDraw) { - this.drawGraph_(this.rawData_); + this.predraw_(); } }; @@ -2645,97 +2924,45 @@ Dygraph.prototype.indexFromSetName = function(name) { return null; }; +/** + * @private + * Adds a default style for the annotation CSS classes to the document. This is + * only executed when annotations are actually used. It is designed to only be + * called once -- all calls after the first will return immediately. + */ Dygraph.addAnnotationRule = function() { if (Dygraph.addedAnnotationCSS) return; - var mysheet; - if (document.styleSheets.length > 0) { - mysheet = document.styleSheets[0]; - } else { - var styleSheetElement = document.createElement("style"); - styleSheetElement.type = "text/css"; - document.getElementsByTagName("head")[0].appendChild(styleSheetElement); - for(i = 0; i < document.styleSheets.length; i++) { - if (document.styleSheets[i].disabled) continue; - mysheet = document.styleSheets[i]; - } - } - var rule = "border: 1px solid black; " + "background-color: white; " + "text-align: center;"; - if (mysheet.insertRule) { // Firefox - var idx = mysheet.cssRules ? mysheet.cssRules.length : 0; - mysheet.insertRule(".dygraphDefaultAnnotation { " + rule + " }", idx); - } else if (mysheet.addRule) { // IE - mysheet.addRule(".dygraphDefaultAnnotation", rule); - } - - Dygraph.addedAnnotationCSS = true; -} - -/** - * Create a new canvas element. This is more complex than a simple - * document.createElement("canvas") because of IE and excanvas. - */ -Dygraph.createCanvas = function() { - var canvas = document.createElement("canvas"); - - isIE = (/MSIE/.test(navigator.userAgent) && !window.opera); - if (isIE && (typeof(G_vmlCanvasManager) != 'undefined')) { - canvas = G_vmlCanvasManager.initElement(canvas); - } - - return canvas; -}; - - -/** - * A wrapper around Dygraph that implements the gviz API. - * @param {Object} container The DOM object the visualization should live in. - */ -Dygraph.GVizChart = function(container) { - this.container = container; -} - -Dygraph.GVizChart.prototype.draw = function(data, options) { - this.container.innerHTML = ''; - this.date_graph = new Dygraph(this.container, data, options); -} - -/** - * Google charts compatible setSelection - * Only row selection is supported, all points in the row will be highlighted - * @param {Array} array of the selected cells - * @public - */ -Dygraph.GVizChart.prototype.setSelection = function(selection_array) { - var row = false; - if (selection_array.length) { - row = selection_array[0].row; - } - this.date_graph.setSelection(row); -} - -/** - * Google charts compatible getSelection implementation - * @return {Array} array of the selected cells - * @public - */ -Dygraph.GVizChart.prototype.getSelection = function() { - var selection = []; - var row = this.date_graph.getSelection(); - - if (row < 0) return selection; - - col = 1; - for (var i in this.date_graph.layout_.datasets) { - selection.push({row: row, column: col}); - col++; + var styleSheetElement = document.createElement("style"); + styleSheetElement.type = "text/css"; + document.getElementsByTagName("head")[0].appendChild(styleSheetElement); + + // Find the first style sheet that we can access. + // We may not add a rule to a style sheet from another domain for security + // reasons. This sometimes comes up when using gviz, since the Google gviz JS + // adds its own style sheets from google.com. + for (var i = 0; i < document.styleSheets.length; i++) { + if (document.styleSheets[i].disabled) continue; + var mysheet = document.styleSheets[i]; + try { + if (mysheet.insertRule) { // Firefox + var idx = mysheet.cssRules ? mysheet.cssRules.length : 0; + mysheet.insertRule(".dygraphDefaultAnnotation { " + rule + " }", idx); + } else if (mysheet.addRule) { // IE + mysheet.addRule(".dygraphDefaultAnnotation", rule); + } + Dygraph.addedAnnotationCSS = true; + return; + } catch(err) { + // Was likely a security exception. + } } - return selection; + this.warn("Unable to add default annotation CSS rule; display may be off."); } // Older pages may still use this name.