X-Git-Url: https://adrianiainlam.tk/git/?a=blobdiff_plain;f=dygraph.js;h=521f2d7d29c77691cf85f4d554b6ce605cfa84a4;hb=f09fc5458a5e131dc9a9cc914ae122a9507ba54e;hp=6aa6bf460530413f9aa849c28f9f31867feef90d;hpb=f032c51dd2d8d17c30f8f55cf55d663b1f9df066;p=dygraphs.git diff --git a/dygraph.js b/dygraph.js index 6aa6bf4..521f2d7 100644 --- a/dygraph.js +++ b/dygraph.js @@ -90,8 +90,12 @@ Dygraph.DEFAULT_ATTRS = { // TODO(danvk): move defaults from createStatusMessage_ here. }, labelsSeparateLines: false, + labelsShowZeroValues: true, labelsKMB: false, labelsKMG2: false, + showLabelsOnHighlight: true, + + yValueFormatter: function(x) { return Dygraph.round_(x, 2); }, strokeWidth: 1.0, @@ -99,6 +103,7 @@ Dygraph.DEFAULT_ATTRS = { axisLabelFontSize: 14, xAxisLabelWidth: 50, yAxisLabelWidth: 50, + xAxisLabelFormatter: Dygraph.dateAxisFormatter, rightGap: 5, showRoller: false, @@ -119,7 +124,9 @@ Dygraph.DEFAULT_ATTRS = { connectSeparatedPoints: false, stackedGraph: false, - hideOverlayOnMouseOut: true + hideOverlayOnMouseOut: true, + + stepPlot: false }; // Various logging levels. @@ -128,6 +135,9 @@ Dygraph.INFO = 2; Dygraph.WARNING = 3; Dygraph.ERROR = 3; +// Used for initializing annotation CSS rules only once. +Dygraph.addedAnnotationCSS = false; + Dygraph.prototype.__old_init__ = function(div, file, labels, attrs) { // Labels is no longer a constructor parameter, since it's typically set // directly from the data source. It also conains a name for the x-axis, @@ -144,8 +154,8 @@ 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 * on the parameters. + * @param {Element} div the Element to render the graph into. * @param {String | Function} file Source data - * @param {Array.} labels Names of the data series * @param {Object} attrs Miscellaneous other options * @private */ @@ -161,9 +171,9 @@ Dygraph.prototype.__init__ = function(div, file, attrs) { this.previousVerticalX_ = -1; this.fractions_ = attrs.fractions || false; this.dateWindow_ = attrs.dateWindow || null; - this.valueRange_ = attrs.valueRange || null; this.wilsonInterval_ = attrs.wilsonInterval || true; this.is_initial_draw_ = true; + this.annotations_ = []; // Clear the div. This ensure that, if multiple dygraphs are passed the same // div, then only one will be drawn. @@ -182,11 +192,17 @@ Dygraph.prototype.__init__ = function(div, file, attrs) { // 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) { - // Minus ten pixels keeps scrollbars from showing up for a 100% width div. - this.width_ = (this.width_ * self.innerWidth / 100) - 10; + this.width_ = div.offsetWidth; } if (div.style.height.indexOf("%") == div.style.height.length - 1) { - this.height_ = (this.height_ * self.innerHeight / 100) - 10; + 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."); } // TODO(danvk): set fillGraph to be part of attrs_ here, not user_attrs_. @@ -209,20 +225,27 @@ Dygraph.prototype.__init__ = function(div, file, attrs) { this.attrs_ = {}; Dygraph.update(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); + Dygraph.addAnnotationRule(); + // Create the containing DIV and other interactive elements this.createInterface_(); this.start_(); }; -Dygraph.prototype.attr_ = function(name) { - if (typeof(this.user_attrs_[name]) != 'undefined') { +Dygraph.prototype.attr_ = function(name, seriesName) { + if (seriesName && + typeof(this.user_attrs_[seriesName]) != 'undefined' && + this.user_attrs_[seriesName] != null && + typeof(this.user_attrs_[seriesName][name]) != 'undefined') { + return this.user_attrs_[seriesName][name]; + } else if (typeof(this.user_attrs_[name]) != 'undefined') { return this.user_attrs_[name]; } else if (typeof(this.attrs_[name]) != 'undefined') { return this.attrs_[name]; @@ -333,6 +356,32 @@ Dygraph.prototype.toDataCoords = function(x, y) { return ret; }; +/** + * Returns the number of columns (including the independent variable). + */ +Dygraph.prototype.numColumns = function() { + return this.rawData_[0].length; +}; + +/** + * Returns the number of rows (excluding any header/label row). + */ +Dygraph.prototype.numRows = function() { + return this.rawData_.length; +}; + +/** + * 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. + */ +Dygraph.prototype.getValue = function(row, col) { + if (row < 0 || row > this.rawData_.length) return null; + if (col < 0 || col > this.rawData_[row].length) return null; + + return this.rawData_[row][col]; +}; + Dygraph.addEvent = function(el, evt, fn) { var normed_fn = function(e) { if (!e) var e = window.event; @@ -383,20 +432,24 @@ Dygraph.prototype.createInterface_ = function() { this.canvas_.height = this.height_; this.canvas_.style.width = this.width_ + "px"; // for IE this.canvas_.style.height = this.height_ + "px"; // for IE - this.graphDiv.appendChild(this.canvas_); // ... and for static parts of the chart. this.hidden_ = this.createPlotKitCanvas_(this.canvas_); + // 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.hidden_, 'mousemove', function(e) { + Dygraph.addEvent(this.mouseEventElement_, 'mousemove', function(e) { dygraph.mouseMove_(e); }); - Dygraph.addEvent(this.hidden_, 'mouseout', function(e) { + Dygraph.addEvent(this.mouseEventElement_, 'mouseout', function(e) { dygraph.mouseOut_(e); }); @@ -472,7 +525,6 @@ Dygraph.prototype.createPlotKitCanvas_ = function(canvas) { h.height = this.height_; h.style.width = this.width_ + "px"; // for IE h.style.height = this.height_ + "px"; // for IE - this.graphDiv.appendChild(h); return h; }; @@ -524,10 +576,11 @@ Dygraph.prototype.setColors_ = function() { if (!colors) { var sat = this.attr_('colorSaturation') || 1.0; var val = this.attr_('colorValue') || 0.5; + var half = Math.ceil(num / 2); for (var i = 1; i <= num; i++) { if (!this.visibility()[i-1]) continue; // alternate colors for high contrast. - var idx = i - parseInt(i % 2 ? i / 2 : (i - num)/2, 10); + var idx = i % 2 ? Math.ceil(i / 2) : (half + i / 2); var hue = (1.0 * idx/ (1 + num)); this.colors_.push(Dygraph.hsvToRGB(hue, sat, val)); } @@ -561,7 +614,7 @@ Dygraph.prototype.getColors = function() { Dygraph.findPosX = function(obj) { var curleft = 0; if(obj.offsetParent) - while(1) + while(1) { curleft += obj.offsetLeft; if(!obj.offsetParent) @@ -596,7 +649,12 @@ Dygraph.findPosY = function(obj) { * been specified. * @private */ -Dygraph.prototype.createStatusMessage_ = function(){ +Dygraph.prototype.createStatusMessage_ = function() { + var userLabelsDiv = this.user_attrs_["labelsDiv"]; + if (userLabelsDiv && null != userLabelsDiv + && (typeof(userLabelsDiv) == "string" || userLabelsDiv instanceof String)) { + this.user_attrs_["labelsDiv"] = document.getElementById(userLabelsDiv); + } if (!this.attr_("labelsDiv")) { var divWidth = this.attr_('labelsDivWidth'); var messagestyle = { @@ -699,10 +757,10 @@ Dygraph.prototype.createDragInterface_ = function() { var px = 0; var py = 0; var getX = function(e) { return Dygraph.pageX(e) - px }; - var getY = function(e) { return Dygraph.pageX(e) - py }; + 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.hidden_, 'mousemove', function(event) { + Dygraph.addEvent(this.mouseEventElement_, 'mousemove', function(event) { if (isZooming) { dragEndX = getX(event); dragEndY = getY(event); @@ -724,7 +782,7 @@ Dygraph.prototype.createDragInterface_ = function() { }); // Track the beginning of drag events - Dygraph.addEvent(this.hidden_, 'mousedown', function(event) { + Dygraph.addEvent(this.mouseEventElement_, 'mousedown', function(event) { px = Dygraph.findPosX(self.canvas_); py = Dygraph.findPosY(self.canvas_); dragStartX = getX(event); @@ -758,7 +816,7 @@ Dygraph.prototype.createDragInterface_ = function() { }); // Temporarily cancel the dragging event when the mouse leaves the graph - Dygraph.addEvent(this.hidden_, 'mouseout', function(event) { + Dygraph.addEvent(this.mouseEventElement_, 'mouseout', function(event) { if (isZooming) { dragEndX = null; dragEndY = null; @@ -767,7 +825,7 @@ Dygraph.prototype.createDragInterface_ = function() { // 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.hidden_, 'mouseup', function(event) { + Dygraph.addEvent(this.mouseEventElement_, 'mouseup', function(event) { if (isZooming) { isZooming = false; dragEndX = getX(event); @@ -776,10 +834,31 @@ Dygraph.prototype.createDragInterface_ = function() { var regionHeight = Math.abs(dragEndY - dragStartY); if (regionWidth < 2 && regionHeight < 2 && - self.attr_('clickCallback') != null && - self.lastx_ != undefined) { - // TODO(danvk): pass along more info about the points. - self.attr_('clickCallback')(event, self.lastx_, self.selPoints_); + 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; + } + } + + // 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]); + } + } } if (regionWidth >= 10) { @@ -803,7 +882,7 @@ Dygraph.prototype.createDragInterface_ = function() { }); // Double-clicking zooms back out - Dygraph.addEvent(this.hidden_, 'dblclick', function(event) { + Dygraph.addEvent(this.mouseEventElement_, 'dblclick', function(event) { if (self.dateWindow_ == null) return; self.dateWindow_ = null; self.drawGraph_(self.rawData_); @@ -874,7 +953,7 @@ Dygraph.prototype.doZoom_ = function(lowX, highX) { * @private */ Dygraph.prototype.mouseMove_ = function(event) { - var canvasx = Dygraph.pageX(event) - Dygraph.findPosX(this.hidden_); + var canvasx = Dygraph.pageX(event) - Dygraph.findPosX(this.mouseEventElement_); var points = this.layout_.points; var lastx = -1; @@ -897,36 +976,41 @@ Dygraph.prototype.mouseMove_ = function(event) { // Extract the points we've selected this.selPoints_ = []; - for (var i = 0; i < points.length; i++) { - if (points[i].xval == lastx) { - this.selPoints_.push(points[i]); + var l = points.length; + if (!this.attr_("stackedGraph")) { + for (var i = 0; i < l; i++) { + if (points[i].xval == lastx) { + this.selPoints_.push(points[i]); + } } + } else { + // Need to 'unstack' points starting from the bottom + var cumulative_sum = 0; + for (var i = l - 1; i >= 0; i--) { + if (points[i].xval == lastx) { + var p = {}; // Clone the point since we modify it + for (var k in points[i]) { + p[k] = points[i][k]; + } + p.yval -= cumulative_sum; + cumulative_sum += p.yval; + this.selPoints_.push(p); + } + } + this.selPoints_.reverse(); } if (this.attr_("highlightCallback")) { - var px = this.lastHighlightCallbackX; + var px = this.lastx_; if (px !== null && lastx != px) { // only fire if the selected point has changed. - this.lastHighlightCallbackX = lastx; - if (!this.attr_("stackedGraph")) { - this.attr_("highlightCallback")(event, lastx, this.selPoints_); - } else { - // "unstack" the points. - var callbackPoints = this.selPoints_.map( - function(p) { return {xval: p.xval, yval: p.yval, name: p.name} }); - var cumulative_sum = 0; - for (var j = callbackPoints.length - 1; j >= 0; j--) { - callbackPoints[j].yval -= cumulative_sum; - cumulative_sum += callbackPoints[j].yval; - } - this.attr_("highlightCallback")(event, lastx, callbackPoints); - } + this.attr_("highlightCallback")(event, lastx, this.selPoints_); } } // Save last x position for callbacks. this.lastx_ = lastx; - + this.updateSelection_(); }; @@ -937,11 +1021,18 @@ Dygraph.prototype.mouseMove_ = function(event) { */ Dygraph.prototype.updateSelection_ = function() { // Clear the previously drawn vertical, if there is one - var circleSize = this.attr_('highlightCircleSize'); var ctx = this.canvas_.getContext("2d"); if (this.previousVerticalX_ >= 0) { + // Determine the maximum highlight circle size. + var maxCircleSize = 0; + var labels = this.attr_('labels'); + for (var i = 1; i < labels.length; i++) { + var r = this.attr_('highlightCircleSize', labels[i]); + if (r > maxCircleSize) maxCircleSize = r; + } var px = this.previousVerticalX_; - ctx.clearRect(px - circleSize - 1, 0, 2 * circleSize + 2, this.height_); + ctx.clearRect(px - maxCircleSize - 1, 0, + 2 * maxCircleSize + 2, this.height_); } var isOK = function(x) { return x && !isNaN(x); }; @@ -951,26 +1042,36 @@ Dygraph.prototype.updateSelection_ = function() { // 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; - for (var i = 0; i < this.selPoints_.length; i++) { - if (!isOK(this.selPoints_[i].canvasy)) continue; - if (this.attr_("labelsSeparateLines")) { - replace += "
"; + + 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.colors_[i%clen]); + var yval = fmtFunc(point.yval); + replace += " " + + point.name + ":" + + yval; } - var point = this.selPoints_[i]; - var c = new RGBColor(this.plotter_.colors_[point.name]); - replace += " " - + point.name + ":" - + this.round_(point.yval, 2); + + this.attr_("labelsDiv").innerHTML = replace; } - this.attr_("labelsDiv").innerHTML = replace; // Draw colored circles over the center of each selected point 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); ctx.beginPath(); - ctx.fillStyle = this.plotter_.colors_[this.selPoints_[i].name]; + ctx.fillStyle = this.plotter_.colors[this.selPoints_[i].name]; ctx.arc(canvasx, this.selPoints_[i].canvasy, circleSize, 0, 2 * Math.PI, false); ctx.fill(); @@ -991,11 +1092,11 @@ Dygraph.prototype.setSelection = function(row) { // Extract the points we've selected this.selPoints_ = []; var pos = 0; - + if (row !== false) { row = row-this.boundaryIds_[0][0]; } - + if (row !== false && row >= 0) { for (var i in this.layout_.datasets) { if (row < this.layout_.datasets[i].length) { @@ -1004,7 +1105,7 @@ Dygraph.prototype.setSelection = function(row) { pos += this.layout_.datasets[i].length; } } - + if (this.selPoints_.length) { this.lastx_ = this.selPoints_[0].xval; this.updateSelection_(); @@ -1021,6 +1122,10 @@ Dygraph.prototype.setSelection = function(row) { * @private */ Dygraph.prototype.mouseOut_ = function(event) { + if (this.attr_("unhighlightCallback")) { + this.attr_("unhighlightCallback")(event); + } + if (this.attr_("hideOverlayOnMouseOut")) { this.clearSelection(); } @@ -1048,7 +1153,7 @@ 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 - * TODO(danvk): why is this part of the prototype? */ Dygraph.dateString_ = function(date, self) { var zeropad = Dygraph.zeropad; @@ -1099,7 +1224,7 @@ Dygraph.dateString_ = function(date, self) { var ret = ""; var frac = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(); - if (frac) ret = " " + self.hmsString_(date); + if (frac) ret = " " + Dygraph.hmsString_(date); return year + "/" + month + "/" + day + ret; }; @@ -1111,7 +1236,7 @@ Dygraph.dateString_ = function(date, self) { * @return {Number} The rounded number * @private */ -Dygraph.prototype.round_ = function(num, places) { +Dygraph.round_ = function(num, places) { var shift = Math.pow(10, places); return Math.round(num * shift)/shift; }; @@ -1221,6 +1346,7 @@ Dygraph.prototype.NumXTicks = function(start_time, end_time, granularity) { // 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. @@ -1257,14 +1383,7 @@ Dygraph.prototype.GetXAxis = function(start_time, end_time, granularity) { start_time = d.getTime(); for (var t = start_time; t <= end_time; t += spacing) { - var d = new Date(t); - var frac = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(); - if (frac == 0 || granularity >= Dygraph.DAILY) { - // the extra hour covers DST problems. - ticks.push({ v:t, label: new Date(t + 3600*1000).strftime(format) }); - } else { - ticks.push({ v:t, label: this.hmsString_(t) }); - } + 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. @@ -1295,7 +1414,7 @@ Dygraph.prototype.GetXAxis = function(start_time, end_time, granularity) { 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: new Date(t).strftime('%b %y') }); + ticks.push({ v:t, label: formatter(new Date(t), granularity) }); } } } @@ -1335,22 +1454,25 @@ Dygraph.dateTicker = function(startDate, endDate, self) { * @return {Array.} Array of {label, value} tuples. * @public */ -Dygraph.numericTicks = function(minV, maxV, self) { +Dygraph.numericTicks = function(minV, maxV, self, attr) { + // This is a bit of a hack to allow per-axis attributes. + if (!attr) attr = self.attr_; + // 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")) { + if (attr("labelsKMG2")) { var mults = [1, 2, 4, 8]; } 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'); + var pixelsPerTick = attr('pixelsPerYLabel'); for (var i = -10; i < 50; i++) { - if (self.attr_("labelsKMG2")) { + if (attr("labelsKMG2")) { var base_scale = Math.pow(16, i); } else { var base_scale = Math.pow(10, i); @@ -1359,7 +1481,7 @@ Dygraph.numericTicks = function(minV, maxV, self) { scale = base_scale * mults[j]; low_val = Math.floor(minV / scale) * scale; high_val = Math.ceil(maxV / scale) * scale; - nTicks = (high_val - low_val) / 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; @@ -1371,26 +1493,29 @@ Dygraph.numericTicks = function(minV, maxV, self) { var ticks = []; var k; var k_labels = []; - if (self.attr_("labelsKMB")) { + if (attr("labelsKMB")) { k = 1000; k_labels = [ "K", "M", "B", "T" ]; } - if (self.attr_("labelsKMG2")) { + if (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 = self.round_(tickV, 2); + var 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 = self.round_(tickV / n, 1) + k_labels[j]; + label = Dygraph.round_(tickV / n, 1) + k_labels[j]; break; } } @@ -1472,21 +1597,26 @@ Dygraph.prototype.drawGraph_ = function(data) { this.setColors_(); this.attrs_['pointSize'] = 0.5 * this.attr_('highlightCircleSize'); - var connectSeparatedPoints = this.attr_('connectSeparatedPoints'); + // Loop over the fields (series). Go from the last to the first, + // because if they're stacked that's how we accumulate the values. - // For stacked series. - var cumulative_y = []; - var stacked_datasets = []; + var cumulative_y = []; // For stacked series. + var datasets = []; - // Loop over all fields in the dataset - for (var i = 1; i < data[0].length; i++) { + 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 series = []; for (var j = 0; j < data.length; j++) { - if (data[j][i] || !connectSeparatedPoints) { + if (data[j][i] != null || !connectSeparatedPoints) { var date = data[j][0]; - series[j] = [date, data[j][i]]; + series.push([date, data[j][i]]); } } series = this.rollingAverage(series, this.rollPeriod_); @@ -1523,52 +1653,60 @@ 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 || thisMinY < minY) minY = thisMinY; - if (!maxY || thisMaxY > maxY) maxY = thisMaxY; + var seriesExtremes = this.extremeValues_(series); + extremes[seriesName] = seriesExtremes; + var thisMinY = seriesExtremes[0]; + var thisMaxY = seriesExtremes[1]; + if (minY === null || thisMinY < minY) minY = thisMinY; + if (maxY === null || thisMaxY > maxY) maxY = thisMaxY; if (bars) { - var vals = []; - for (var j=0; j maxY) - maxY = cumulative_y[series[j][0]]; + if (!maxY || cumulative_y[x] > maxY) + maxY = cumulative_y[x]; } - stacked_datasets.push([this.attr_("labels")[i], vals]); - //this.layout_.addDataset(this.attr_("labels")[i], vals); - } else { - this.layout_.addDataset(this.attr_("labels")[i], series); } + + datasets[i] = series; } - if (stacked_datasets.length > 0) { - for (var i = (stacked_datasets.length - 1); i >= 0; i--) { - this.layout_.addDataset(stacked_datasets[i][0], stacked_datasets[i][1]); - } + for (var i = 1; i < datasets.length; i++) { + if (!this.visibility()[i - 1]) continue; + this.layout_.addDataset(this.attr_("labels")[i], datasets[i]); } + var out = this.computeYaxes_(extremes); + var axes = out[0]; + var seriesToAxisMap = out[1]; + this.displayedYRange_ = axes[0].valueRange; + this.layout_.updateOptions( { yAxis: axes[0].valueRange, + yTicks: axes[0].ticks } ); + // Use some heuristics to come up with a good maxY value, unless it's been // set explicitly by the user. - if (this.valueRange_ != null) { - this.addYTicks_(this.valueRange_[0], this.valueRange_[1]); - this.displayedYRange_ = this.valueRange_; + /* + var valueRange = this.attr_("valueRange"); + if (valueRange != null) { + this.addYTicks_(valueRange[0], valueRange[1]); + this.displayedYRange_ = valueRange; } else { // This affects the calculation of span, below. if (this.attr_("includeZero") && minY > 0) { @@ -1594,6 +1732,7 @@ Dygraph.prototype.drawGraph_ = function(data) { this.addYTicks_(minAxisY, maxAxisY); this.displayedYRange_ = [minAxisY, maxAxisY]; } + */ this.addXTicks_(); @@ -1611,6 +1750,126 @@ Dygraph.prototype.drawGraph_ = function(data) { }; /** + * Determine all y-axes. + * Inputs: mapping from seriesName -> [low, high] for that series, + * (implicit) per-series axis attributes. + * Returns [ axes, seriesToAxisMap ] + * axes = [ { valueRange: [low, high], otherOptions: ..., ticks: [...] } ] + * seriesToAxisMap = { seriesName: 0, seriesName2: 1, ... } + * indices are into the axes array. + */ +Dygraph.prototype.computeYaxes_ = function(extremes) { + var axes = [{}]; // always have at least one y-axis. + var seriesToAxisMap = {}; + var seriesForAxis = [[]]; + + // all options which could be applied per-axis: + var axisOptions = [ + 'includeZero', + 'valueRange', + 'labelsKMB', + 'labelsKMG2', + 'pixelsPerYLabel', + 'yAxisLabelWidth', + 'axisLabelFontSize', + 'axisTickSize' + ]; + + // 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) axes[0][k] = v; + } + + // Go through once and add all the axes. + for (var seriesName in extremes) { + if (!extremes.hasOwnProperty(seriesName)) continue; + var axis = this.attr_("axis", seriesName); + if (axis == null) { + seriesToAxisMap[seriesName] = 0; + seriesForAxis[0].push(seriesName); + continue; + } + if (typeof(axis) == 'object') { + // Add a new axis, making a copy of its per-axis options. + var opts = {}; + Dygraph.update(opts, axes[0]); + Dygraph.update(opts, { valueRange: null }); // shouldn't inherit this. + Dygraph.update(opts, axis); + axes.push(opts); + seriesToAxisMap[seriesName] = axes.length - 1; + seriesForAxis.push([seriesName]); + } + } + + // 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 extremes) { + if (!extremes.hasOwnProperty(seriesName)) continue; + var axis = this.attr_("axis", seriesName); + if (typeof(axis) == 'string') { + if (!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 = seriesToAxisMap[axis]; + seriesToAxisMap[seriesName] = idx; + seriesForAxis[idx].push(seriesName); + } + } + + // Compute extreme values, a span and tick marks for each axis. + for (var i = 0; i < axes.length; i++) { + var axis = axes[i]; + if (!axis.valueRange) { + // Calcuate the extremes of extremes. + var series = seriesForAxis[i]; + var minY = Infinity; // extremes[series[0]][0]; + var maxY = -Infinity; // extremes[series[0]][1]; + for (var j = 0; j < series.length; j++) { + minY = Math.min(extremes[series[j]][0], minY); + maxY = Math.max(extremes[series[j]][0], maxY); + } + if (axis.includeZero && minY > 0) minY = 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 = 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 (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.valueRange = [minAxisY, maxAxisY]; + } + + // Add ticks. + axis.ticks = + Dygraph.numericTicks(axis.valueRange[0], + axis.valueRange[1], + this, + function(self, axis) { + return function(a) { + if (axis.hasOwnProperty(a)) return axis[a]; + return self.attr_(a); + }; + }(this, axis)); + } + + return [axes, seriesToAxisMap]; +}; + +/** * 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] @@ -1800,10 +2059,12 @@ Dygraph.prototype.detectTypeFromString_ = function(str) { this.attrs_.xValueFormatter = Dygraph.dateString_; this.attrs_.xValueParser = Dygraph.dateParser; this.attrs_.xTicker = Dygraph.dateTicker; + this.attrs_.xAxisLabelFormatter = Dygraph.dateAxisFormatter; } else { 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; } }; @@ -1840,6 +2101,12 @@ Dygraph.prototype.parseCSV_ = function(data) { this.attrs_.labels = lines[0].split(delim); } + // 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 xParser; var defaultParserSet = false; // attempt to auto-detect x value type var expectedCols = this.attr_("labels").length; @@ -1864,25 +2131,25 @@ 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] = [parseFloat(vals[0]), parseFloat(vals[1])]; + fields[j] = [parseFloatOrNull(vals[0]), parseFloatOrNull(vals[1])]; } } 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] = [parseFloat(inFields[j]), - parseFloat(inFields[j + 1])]; + fields[(j + 1) / 2] = [parseFloatOrNull(inFields[j]), + parseFloatOrNull(inFields[j + 1])]; } 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] = [ parseFloat(vals[0]), - parseFloat(vals[1]), - parseFloat(vals[2]) ]; + fields[j] = [ parseFloatOrNull(vals[0]), + parseFloatOrNull(vals[1]), + parseFloatOrNull(vals[2]) ]; } } else { // Values are just numbers for (var j = 1; j < inFields.length; j++) { - fields[j] = parseFloat(inFields[j]); + fields[j] = parseFloatOrNull(inFields[j]); } } if (ret.length > 0 && fields[0] < ret[ret.length - 1][0]) { @@ -1935,13 +2202,14 @@ 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; // Assume they're all dates. var parsedData = Dygraph.clone(data); for (var i = 0; i < data.length; i++) { if (parsedData[i].length == 0) { - this.error("Row " << (1 + i) << " of data is empty"); + this.error("Row " + (1 + i) + " of data is empty"); return null; } if (parsedData[i][0] == null @@ -1966,7 +2234,7 @@ Dygraph.prototype.parseArray_ = function(data) { * The data is expected to have a first column that is either a date or a * 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. Returned value is in the same format as return value of parseCSV_. + * fixed. Fills out rawData_. * @param {Array.} data See above. * @private */ @@ -1974,38 +2242,64 @@ Dygraph.prototype.parseDataTable_ = function(data) { var cols = data.getNumberOfColumns(); var rows = data.getNumberOfRows(); - // Read column labels - var labels = []; - for (var i = 0; i < cols; i++) { - labels.push(data.getColumnLabel(i)); - if (i != 0 && this.attr_("errorBars")) i += 1; - } - this.attrs_.labels = labels; - cols = labels.length; - 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; } 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; } else { this.error("only 'date', 'datetime' and 'number' types are supported for " + "column 1 of DataTable input (Got '" + indepType + "')"); return null; } + // Array of the column indices which contain data (and not annotations). + var colIdx = []; + var annotationCols = {}; // data index -> [annotation cols] + var hasAnnotations = false; + for (var i = 1; i < cols; i++) { + var type = data.getColumnType(i); + if (type == 'number') { + colIdx.push(i); + } else if (type == 'string' && this.attr_('displayAnnotations')) { + // This is OK -- it's an annotation column. + var dataIdx = colIdx[colIdx.length - 1]; + if (!annotationCols.hasOwnProperty(dataIdx)) { + annotationCols[dataIdx] = [i]; + } else { + annotationCols[dataIdx].push(i); + } + hasAnnotations = true; + } else { + this.error("Only 'number' is supported as a dependent type with Gviz." + + " 'string' is only supported if displayAnnotations is true"); + } + } + + // Read column labels + // TODO(danvk): add support back for errorBars + var labels = [data.getColumnLabel(0)]; + for (var i = 0; i < colIdx.length; i++) { + labels.push(data.getColumnLabel(colIdx[i])); + } + this.attrs_.labels = labels; + cols = labels.length; + var ret = []; var outOfOrder = false; + var annotations = []; for (var i = 0; i < rows; i++) { var row = []; if (typeof(data.getValue(i, 0)) === 'undefined' || data.getValue(i, 0) === null) { - this.warning("Ignoring row " + i + - " of DataTable because of undefined or null first column."); + this.warn("Ignoring row " + i + + " of DataTable because of undefined or null first column."); continue; } @@ -2015,8 +2309,23 @@ Dygraph.prototype.parseDataTable_ = function(data) { row.push(data.getValue(i, 0)); } if (!this.attr_("errorBars")) { - for (var j = 1; j < cols; j++) { - row.push(data.getValue(i, j)); + for (var j = 0; j < colIdx.length; j++) { + var col = colIdx[j]; + row.push(data.getValue(i, col)); + if (hasAnnotations && + annotationCols.hasOwnProperty(col) && + data.getValue(i, annotationCols[col][0]) != null) { + var ann = {}; + ann.series = data.getColumnLabel(col); + ann.xval = row[0]; + ann.shortText = String.fromCharCode(65 /* A */ + annotations.length) + ann.text = ''; + for (var k = 0; k < annotationCols[col].length; k++) { + if (k) ann.text += "\n"; + ann.text += data.getValue(i, annotationCols[col][k]); + } + annotations.push(ann); + } } } else { for (var j = 0; j < cols - 1; j++) { @@ -2033,7 +2342,11 @@ Dygraph.prototype.parseDataTable_ = function(data) { this.warn("DataTable is out of order; order it correctly to speed loading."); ret.sort(function(a,b) { return a[0] - b[0] }); } - return ret; + this.rawData_ = ret; + + if (annotations.length > 0) { + this.setAnnotations(annotations, true); + } } // These functions are all based on MochiKit. @@ -2099,7 +2412,7 @@ Dygraph.prototype.start_ = function() { } else if (typeof this.file_ == 'object' && typeof this.file_.getColumnRange == 'function') { // must be a DataTable from gviz. - this.rawData_ = this.parseDataTable_(this.file_); + this.parseDataTable_(this.file_); this.drawGraph_(this.rawData_); } else if (typeof this.file_ == 'string') { // Heuristic: a newline means it's CSV data. Otherwise it's an URL. @@ -2140,16 +2453,22 @@ Dygraph.prototype.updateOptions = function(attrs) { if (attrs.dateWindow) { this.dateWindow_ = attrs.dateWindow; } - if (attrs.valueRange) { - this.valueRange_ = attrs.valueRange; - } + + // TODO(danvk): validate per-series options. + // Supported: + // strokeWidth + // pointSize + // drawPoints + // highlightCircleSize + Dygraph.update(this.user_attrs_, attrs); + Dygraph.update(this.renderOptions_, attrs); this.labelsFromCSV_ = (this.attr_("labels") == null); // TODO(danvk): this doesn't match the constructor logic this.layout_.updateOptions({ 'errorBars': this.attr_("errorBars") }); - if (attrs['file'] && attrs['file'] != this.file_) { + if (attrs['file']) { this.file_ = attrs['file']; this.start_(); } else { @@ -2169,6 +2488,11 @@ Dygraph.prototype.updateOptions = function(attrs) { * @param {Number} height Height (in pixels) */ Dygraph.prototype.resize = function(width, height) { + if (this.resize_lock) { + return; + } + this.resize_lock = true; + if ((width === null) != (height === null)) { this.warn("Dygraph.resize() should be called with zero parameters or " + "two non-NULL parameters. Pretending it was zero."); @@ -2191,6 +2515,8 @@ Dygraph.prototype.resize = function(width, height) { this.createInterface_(); this.drawGraph_(this.rawData_); + + this.resize_lock = false; }; /** @@ -2232,6 +2558,64 @@ Dygraph.prototype.setVisibility = function(num, value) { }; /** + * Update the list of annotations and redraw the chart. + */ +Dygraph.prototype.setAnnotations = function(ann, suppressDraw) { + this.annotations_ = ann; + this.layout_.setAnnotations(this.annotations_); + if (!suppressDraw) { + this.drawGraph_(this.rawData_); + } +}; + +/** + * Return the list of annotations. + */ +Dygraph.prototype.annotations = function() { + return this.annotations_; +}; + +/** + * Get the index of a series (column) given its name. The first column is the + * x-axis, so the data series start with index 1. + */ +Dygraph.prototype.indexFromSetName = function(name) { + var labels = this.attr_("labels"); + for (var i = 0; i < labels.length; i++) { + if (labels[i] == name) return i; + } + return null; +}; + +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 + mysheet.insertRule(".dygraphDefaultAnnotation { " + rule + " }", 0); + } 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. */ @@ -2262,8 +2646,7 @@ Dygraph.GVizChart.prototype.draw = function(data, options) { /** * Google charts compatible setSelection - * Only row selection is supported, all points in the - * row will be highlighted + * Only row selection is supported, all points in the row will be highlighted * @param {Array} array of the selected cells * @public */ @@ -2282,11 +2665,11 @@ Dygraph.GVizChart.prototype.setSelection = function(selection_array) { */ 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});