add support for numeric axes in gviz. fixes issue 27
[dygraphs.git] / dygraph.js
index 6369f11..f5b5d72 100644 (file)
@@ -84,6 +84,7 @@ DateGraph.AXIS_LINE_WIDTH = 0.3;
  */
 DateGraph.prototype.__init__ = function(div, file, labels, attrs) {
   // Copy the important bits into the object
+  // TODO(danvk): most of these should just stay in the attrs_ dictionary.
   this.maindiv_ = div;
   this.labels_ = labels;
   this.file_ = file;
@@ -109,6 +110,10 @@ DateGraph.prototype.__init__ = function(div, file, labels, attrs) {
   this.customBars_ = attrs.customBars || false;
   this.attrs_ = attrs;
 
+  if (typeof this.attrs_.pixelsPerXLabel == 'undefined') {
+    this.attrs_.pixelsPerXLabel = 60;
+  }
+
   // Make a note of whether labels will be pulled from the CSV file.
   this.labelsFromCSV_ = (this.labels_ == null);
   if (this.labels_ == null)
@@ -144,7 +149,8 @@ DateGraph.prototype.__init__ = function(div, file, labels, attrs) {
   this.createRollInterface_();
   this.createDragInterface_();
 
-  MochiKit.DOM.addLoadEvent(this.start_());
+  // connect(window, 'onload', this, function(e) { this.start_(); });
+  this.start_();
 };
 
 /**
@@ -257,13 +263,18 @@ DateGraph.prototype.createStatusMessage_ = function(){
  */
 DateGraph.prototype.createRollInterface_ = function() {
   var padding = this.plotter_.options.padding;
+  if (typeof this.attrs_.showRoller == 'undefined') {
+    this.attrs_.showRoller = false;
+  }
+  var display = this.attrs_.showRoller ? "block" : "none";
   var textAttr = { "type": "text",
                    "size": "2",
                    "value": this.rollPeriod_,
                    "style": { "position": "absolute",
                               "zIndex": 10,
                               "top": (this.height_ - 25 - padding.bottom) + "px",
-                              "left": (padding.left+1) + "px" }
+                              "left": (padding.left+1) + "px",
+                              "display": display }
                   };
   var roller = MochiKit.DOM.INPUT(textAttr);
   var pa = this.graphDiv;
@@ -290,8 +301,8 @@ DateGraph.prototype.createDragInterface_ = function() {
   var prevEndX = null;
 
   // Utility function to convert page-wide coordinates to canvas coords
-  var px = PlotKit.Base.findPosX(this.canvas_);
-  var py = PlotKit.Base.findPosY(this.canvas_);
+  var px = 0;
+  var py = 0;
   var getX = function(e) { return e.mouse().page.x - px };
   var getY = function(e) { return e.mouse().page.y - py };
 
@@ -309,6 +320,8 @@ DateGraph.prototype.createDragInterface_ = function() {
   // Track the beginning of drag events
   connect(this.hidden_, 'onmousedown', function(event) {
     mouseDown = true;
+    px = PlotKit.Base.findPosX(self.canvas_);
+    py = PlotKit.Base.findPosY(self.canvas_);
     dragStartX = getX(event);
     dragStartY = getY(event);
   });
@@ -367,7 +380,9 @@ DateGraph.prototype.createDragInterface_ = function() {
     self.drawGraph_(self.rawData_);
     var minDate = self.rawData_[0][0];
     var maxDate = self.rawData_[self.rawData_.length - 1][0];
-    self.zoomCallback_(minDate, maxDate);
+    if (self.zoomCallback_) {
+      self.zoomCallback_(minDate, maxDate);
+    }
   });
 };
 
@@ -426,7 +441,9 @@ DateGraph.prototype.doZoom_ = function(lowX, highX) {
 
   this.dateWindow_ = [minDate, maxDate];
   this.drawGraph_(this.rawData_);
-  this.zoomCallback_(minDate, maxDate);
+  if (this.zoomCallback_) {
+    this.zoomCallback_(minDate, maxDate);
+  }
 };
 
 /**
@@ -520,6 +537,30 @@ DateGraph.prototype.mouseOut_ = function(event) {
   this.labelsDiv_.innerHTML = "";
 };
 
+DateGraph.zeropad = function(x) {
+  if (x < 10) return "0" + x; else return "" + x;
+}
+
+/**
+ * Return a string version of the hours, minutes and seconds portion of a date.
+ * @param {Number} date The JavaScript date (ms since epoch)
+ * @return {String} A time of the form "HH:MM:SS"
+ * @private
+ */
+DateGraph.prototype.hmsString_ = function(date) {
+  var zeropad = DateGraph.zeropad;
+  var d = new Date(date);
+  if (d.getSeconds()) {
+    return zeropad(d.getHours()) + ":" +
+           zeropad(d.getMinutes()) + ":" +
+           zeropad(d.getSeconds());
+  } else if (d.getMinutes()) {
+    return zeropad(d.getHours()) + ":" + zeropad(d.getMinutes());
+  } else {
+    return zeropad(d.getHours());
+  }
+}
+
 /**
  * Convert a JS date (millis since epoch) to YYYY/MM/DD
  * @param {Number} date The JavaScript date (ms since epoch)
@@ -527,18 +568,21 @@ DateGraph.prototype.mouseOut_ = function(event) {
  * @private
  */
 DateGraph.prototype.dateString_ = function(date) {
+  var zeropad = DateGraph.zeropad;
   var d = new Date(date);
 
   // Get the year:
   var year = "" + d.getFullYear();
   // Get a 0 padded month string
-  var month = "" + (d.getMonth() + 1);  //months are 0-offset, sigh
-  if (month.length < 2)  month = "0" + month;
+  var month = zeropad(d.getMonth() + 1);  //months are 0-offset, sigh
   // Get a 0 padded day string
-  var day = "" + d.getDate();
-  if (day.length < 2)  day = "0" + day;
+  var day = zeropad(d.getDate());
+
+  var ret = "";
+  var frac = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
+  if (frac) ret = " " + this.hmsString_(date);
 
-  return year + "/" + month + "/" + day;
+  return year + "/" + month + "/" + day + ret;
 };
 
 /**
@@ -584,7 +628,113 @@ DateGraph.prototype.addXTicks_ = function() {
 
   var xTicks = this.xTicker_(startDate, endDate);
   this.layout_.updateOptions({xTicks: xTicks});
-}
+};
+
+// Time granularity enumeration
+DateGraph.SECONDLY = 0;
+DateGraph.MINUTELY = 1;
+DateGraph.HOURLY = 2;
+DateGraph.DAILY = 3;
+DateGraph.WEEKLY = 4;
+DateGraph.MONTHLY = 5;
+DateGraph.QUARTERLY = 6;
+DateGraph.BIANNUAL = 7;
+DateGraph.ANNUAL = 8;
+DateGraph.DECADAL = 9;
+DateGraph.NUM_GRANULARITIES = 10;
+
+DateGraph.SHORT_SPACINGS = [];
+DateGraph.SHORT_SPACINGS[DateGraph.SECONDLY] = 1000 * 1;
+DateGraph.SHORT_SPACINGS[DateGraph.MINUTELY] = 1000 * 60;
+DateGraph.SHORT_SPACINGS[DateGraph.HOURLY]   = 1000 * 3600;
+DateGraph.SHORT_SPACINGS[DateGraph.DAILY]    = 1000 * 86400;
+DateGraph.SHORT_SPACINGS[DateGraph.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.
+//
+DateGraph.prototype.NumXTicks = function(start_time, end_time, granularity) {
+  if (granularity < DateGraph.MONTHLY) {
+    // Generate one tick mark for every fixed interval of time.
+    var spacing = DateGraph.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 == DateGraph.QUARTERLY) num_months = 3;
+    if (granularity == DateGraph.BIANNUAL) num_months = 2;
+    if (granularity == DateGraph.ANNUAL) num_months = 1;
+    if (granularity == DateGraph.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.
+//
+DateGraph.prototype.GetXAxis = function(start_time, end_time, granularity) {
+  var ticks = [];
+  if (granularity < DateGraph.MONTHLY) {
+    // Generate one tick mark for every fixed interval of time.
+    var spacing = DateGraph.SHORT_SPACINGS[granularity];
+    var format = '%d%b';  // e.g. "1 Jan"
+    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 >= DateGraph.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) });
+      }
+    }
+  } 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.
+
+    // TODO(danvk): use CachingRoundTime where appropriate to get boundaries.
+    if (granularity == DateGraph.MONTHLY) {
+      months = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ];
+    } else if (granularity == DateGraph.QUARTERLY) {
+      months = [ 0, 3, 6, 9 ];
+    } else if (granularity == DateGraph.BIANNUAL) {
+      months = [ 0, 6 ];
+    } else if (granularity == DateGraph.ANNUAL) {
+      months = [ 0 ];
+    } else if (granularity == DateGraph.DECADAL) {
+      months = [ 0 ];
+      year_mod = 10;
+    }
+
+    var start_year = new Date(start_time).getFullYear();
+    var end_year   = new Date(end_time).getFullYear();
+    var zeropad = DateGraph.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: new Date(t).strftime('%b %y') });
+      }
+    }
+  }
+
+  return ticks;
+};
+
 
 /**
  * Add ticks to the x-axis based on a date range.
@@ -594,59 +744,20 @@ DateGraph.prototype.addXTicks_ = function() {
  * @public
  */
 DateGraph.prototype.dateTicker = function(startDate, endDate) {
-  var ONE_DAY = 24*60*60*1000;
-  startDate = startDate / ONE_DAY;
-  endDate = endDate / ONE_DAY;
-  var dateSpan = endDate - startDate;
-
-  var scale = [];
-  var isMonthly = false;
-  var yearMod = 1;
-  if (dateSpan > 30 * 366) {      // decadal
-    isMonthly = true;
-    scale = ["Jan"];
-    yearMod = 10;
-  } else if (dateSpan > 4*366) {  // annual
-    scale = ["Jan"];
-    isMonthly = true;
-  } else if (dateSpan > 366) {    // quarterly
-    scale = this.quarters;
-    isMonthly = true;
-  } else if (dateSpan > 40) {     // monthly
-    scale = this.months;
-    isMonthly = true;
-  } else if (dateSpan > 10) {     // weekly
-    for (var week = startDate - 14; week < endDate + 14; week += 7) {
-      scale.push(week * ONE_DAY);
-    }
-  } else {                        // daily
-    for (var day = startDate - 14; day < endDate + 14; day += 1) {
-      scale.push(day * ONE_DAY);
+  var chosen = -1;
+  for (var i = 0; i < DateGraph.NUM_GRANULARITIES; i++) {
+    var num_ticks = this.NumXTicks(startDate, endDate, i);
+    if (this.width_ / num_ticks >= this.attrs_.pixelsPerXLabel) {
+      chosen = i;
+      break;
     }
   }
 
-  var xTicks = [];
-
-  if (isMonthly) {
-    var startYear = 1900 + (new Date(startDate* ONE_DAY)).getYear();
-    var endYear   = 1900 + (new Date(endDate  * ONE_DAY)).getYear();
-    for (var i = startYear; i <= endYear; i++) {
-      if (i % yearMod != 0) continue;
-      for (var j = 0; j < scale.length; j++ ) {
-        var date = Date.parse(scale[j] + " 1, " + i);
-        xTicks.push( {label: scale[j] + "'" + ("" + i).substr(2,2), v: date } );
-      }
-    }
+  if (chosen >= 0) {
+    return this.GetXAxis(startDate, endDate, chosen);
   } else {
-    for (var i = 0; i < scale.length; i++) {
-      var date = new Date(scale[i]);
-      var year = date.getFullYear().toString();
-      var label = this.months[date.getMonth()] + date.getDate();
-      label += "'" + year.substr(year.length - 2, 2);
-      xTicks.push( {label: label, v: date} );
-    }
+    // TODO(danvk): signal error.
   }
-  return xTicks;
 };
 
 /**
@@ -841,12 +952,29 @@ DateGraph.prototype.rollingAverage = function(originalData, rollPeriod) {
       }
     }
   } else if (this.customBars_) {
-    // just ignore the rolling for now.
-    // TODO(danvk): do something reasonable.
+    var low = 0;
+    var mid = 0;
+    var high = 0;
+    var count = 0;
     for (var i = 0; i < originalData.length; i++) {
       var data = originalData[i][1];
       var y = data[1];
       rollingData[i] = [originalData[i][0], [y, y - data[0], data[2] - y]];
+
+      low += data[0];
+      mid += y;
+      high += data[2];
+      count += 1;
+      if (i - rollPeriod >= 0) {
+        var prev = originalData[i - rollPeriod];
+        low -= prev[1][0];
+        mid -= prev[1][1];
+        high -= prev[1][2];
+        count -= 1;
+      }
+      rollingData[i] = [originalData[i][0], [ 1.0 * mid / count,
+                                              1.0 * (mid - low) / count,
+                                              1.0 * (high - mid) / count ]];
     }
   } else {
     // Calculate the rolling average for the first rollPeriod - 1 points where
@@ -909,15 +1037,21 @@ DateGraph.prototype.rollingAverage = function(originalData, rollPeriod) {
  */
 DateGraph.prototype.dateParser = function(dateStr) {
   var dateStrSlashed;
-  if (dateStr.search("-") != -1) {
+  if (dateStr.length == 10 && dateStr.search("-") != -1) {  // e.g. '2009-07-12'
     dateStrSlashed = dateStr.replace("-", "/", "g");
-  } else if (dateStr.search("/") != -1) {
-    return Date.parse(dateStr);
-  } else {
+    while (dateStrSlashed.search("-") != -1) {
+      dateStrSlashed = dateStrSlashed.replace("-", "/");
+    }
+    return Date.parse(dateStrSlashed);
+  } else if (dateStr.length == 8) {  // e.g. '20090712'
     dateStrSlashed = dateStr.substr(0,4) + "/" + dateStr.substr(4,2)
                        + "/" + dateStr.substr(6,2);
+    return Date.parse(dateStrSlashed);
+  } else {
+    // Any format that Date.parse will accept, e.g. "2009/07/12" or
+    // "2009/07/12 12:34:56"
+    return Date.parse(dateStr);
   }
-  return Date.parse(dateStrSlashed);
 };
 
 /**
@@ -985,6 +1119,56 @@ DateGraph.prototype.parseCSV_ = function(data) {
 };
 
 /**
+ * Parses a DataTable object from gviz.
+ * 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_.
+ * @param {Array.<Object>} data See above.
+ * @private
+ */
+DateGraph.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));
+  }
+  labels.shift();  // the x-axis parameter is assumed and unnamed.
+  this.labels_ = labels;
+  // regenerate automatic colors.
+  this.setColors_(this.attrs_);
+  this.renderOptions_.colorScheme = this.colors_;
+  MochiKit.Base.update(this.plotter_.options, this.renderOptions_);
+  MochiKit.Base.update(this.layoutOptions_, this.attrs_);
+
+  var indepType = data.getColumnType(0);
+  if (indepType != 'date' && indepType != 'number') {
+    // TODO(danvk): standardize error reporting.
+    alert("only 'date' and 'number' types are supported for column 1" +
+          "of DataTable input (Got '" + indepType + "')");
+    return null;
+  }
+
+  var ret = [];
+  for (var i = 0; i < rows; i++) {
+    var row = [];
+    if (indepType == 'date') {
+      row.push(data.getValue(i, 0).getTime());
+    } else {
+      row.push(data.getValue(i, 0));
+    }
+    for (var j = 1; j < cols; j++) {
+      row.push(data.getValue(i, j));
+    }
+    ret.push(row);
+  }
+  return ret;
+}
+
+/**
  * 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.
  * @private
@@ -993,6 +1177,11 @@ DateGraph.prototype.start_ = function() {
   if (typeof this.file_ == 'function') {
     // Stubbed out to allow this to run off a filesystem
     this.loadedEvent_(this.file_());
+  } else if (typeof this.file_ == 'object' &&
+             typeof this.file_.getColumnRange == 'function') {
+    // must be a DataTable from gviz.
+    this.rawData_ = this.parseDataTable_(this.file_);
+    this.drawGraph_(this.rawData_);
   } else {
     var req = new XMLHttpRequest();
     var caller = this;
@@ -1061,3 +1250,17 @@ DateGraph.prototype.adjustRoll = function(length) {
   this.rollPeriod_ = length;
   this.drawGraph_(this.rawData_);
 };
+
+
+/**
+ * A wrapper around DateGraph that implements the gviz API.
+ * @param {Object} container The DOM object the visualization should live in.
+ */
+DateGraph.GVizChart = function(container) {
+  this.container = container;
+}
+
+DateGraph.GVizChart.prototype.draw = function(data, options) {
+  this.container.innerHTML = '';
+  this.date_graph = new DateGraph(this.container, data, null, options || {});
+}