DateGraph.DEFAULT_STROKE_WIDTH = 1.0;
DateGraph.AXIS_LINE_WIDTH = 0.3;
+// Default attribute values.
+DateGraph.DEFAULT_ATTRS = {
+ highlightCircleSize: 3,
+ pixelsPerXLabel: 60,
+ pixelsPerYLabel: 30,
+ labelsDivWidth: 250,
+ labelsDivStyles: {
+ // TODO(danvk): move defaults from createStatusMessage_ here.
+ }
+
+ // TODO(danvk): default padding
+};
+
/**
* Initializes the DateGraph. This creates a new DIV and constructs the PlotKit
* and interaction <canvas> inside of it. See the constructor for details
*/
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;
this.labelsSeparateLines = attrs.labelsSeparateLines || false;
this.labelsDiv_ = attrs.labelsDiv || null;
this.labelsKMB_ = attrs.labelsKMB || false;
- this.minTickSize_ = attrs.minTickSize || 0;
this.xValueParser_ = attrs.xValueParser || DateGraph.prototype.dateParser;
this.xValueFormatter_ = attrs.xValueFormatter ||
DateGraph.prototype.dateString_;
this.sigma_ = attrs.sigma || 2.0;
this.wilsonInterval_ = attrs.wilsonInterval || true;
this.customBars_ = attrs.customBars || false;
- this.attrs_ = attrs;
+
+ this.attrs_ = {};
+ MochiKit.Base.update(this.attrs_, DateGraph.DEFAULT_ATTRS);
+ MochiKit.Base.update(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);
this.createRollInterface_();
this.createDragInterface_();
- connect(window, 'onload', this, function(e) { this.start_(); });
+ // connect(window, 'onload', this, function(e) { this.start_(); });
+ this.start_();
};
/**
*/
DateGraph.prototype.createStatusMessage_ = function(){
if (!this.labelsDiv_) {
- var divWidth = 250;
+ var divWidth = this.attrs_.labelsDivWidth;
var messagestyle = { "style": {
"position": "absolute",
"fontSize": "14px",
"background": "white",
"textAlign": "left",
"overflow": "hidden"}};
+ MochiKit.Base.update(messagestyle["style"], this.attrs_.labelsDivStyles);
this.labelsDiv_ = MochiKit.DOM.DIV(messagestyle);
MochiKit.DOM.appendChildNodes(this.graphDiv, this.labelsDiv_);
}
*/
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;
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 };
// 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);
});
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);
+ }
});
};
this.dateWindow_ = [minDate, maxDate];
this.drawGraph_(this.rawData_);
- this.zoomCallback_(minDate, maxDate);
+ if (this.zoomCallback_) {
+ this.zoomCallback_(minDate, maxDate);
+ }
};
/**
}
// Clear the previously drawn vertical, if there is one
- var circleSize = 3;
+ var circleSize = this.attrs_.highlightCircleSize;
var ctx = this.canvas_.getContext("2d");
if (this.previousVerticalX_ >= 0) {
var px = this.previousVerticalX_;
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)
* @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;
};
/**
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.
* @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;
};
/**
* @public
*/
DateGraph.prototype.numericTicks = function(minV, maxV) {
- var scale;
- if (maxV <= 0.0) {
- scale = 1.0;
- } else {
- scale = Math.pow( 10, Math.floor(Math.log(maxV)/Math.log(10.0)) );
- }
-
- // Add a smallish number of ticks at human-friendly points
- var nTicks = (maxV - minV) / scale;
- while (2 * nTicks < 20) {
- nTicks *= 2;
- }
- if ((maxV - minV) / nTicks < this.minTickSize_) {
- nTicks = this.round_((maxV - minV) / this.minTickSize_, 1);
+ // 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 this.attrs_.pixelsPerYLabel is what we use.
+ var mults = [1, 2, 5];
+ var scale, low_val, high_val, nTicks;
+ for (var i = -10; i < 50; i++) {
+ var base_scale = Math.pow(10, i);
+ for (var j = 0; j < mults.length; j++) {
+ scale = base_scale * mults[j];
+ console.log("i/j/scale: " + i + "/" + j + "/" + scale);
+ low_val = Math.floor(minV / scale) * scale;
+ high_val = Math.ceil(maxV / scale) * scale;
+ nTicks = (high_val - low_val) / scale;
+ var spacing = this.height_ / nTicks;
+ // wish I could break out of both loops at once...
+ if (spacing > this.attrs_.pixelsPerYLabel) break;
+ }
+ if (spacing > this.attrs_.pixelsPerYLabel) break;
}
+ console.log("scale: " + scale);
+ console.log("low_val: " + low_val);
+ console.log("high_val: " + high_val);
// Construct labels for the ticks
var ticks = [];
- for (var i = 0; i <= nTicks; i++) {
- var tickV = minV + i * (maxV - minV) / nTicks;
+ for (var i = 0; i < nTicks; i++) {
+ var tickV = low_val + i * scale;
var label = this.round_(tickV, 2);
if (this.labelsKMB_) {
var k = 1000;
// Add some padding and round up to an integer to be human-friendly.
maxY *= 1.1;
if (maxY <= 0.0) maxY = 1.0;
- else {
- var scale = Math.pow(10, Math.floor(Math.log(maxY) / Math.log(10.0)));
- maxY = scale * Math.ceil(maxY / scale);
- }
this.addYTicks_(0, maxY);
}
}
}
} 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
*/
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);
};
/**
};
/**
+ * 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
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;
if (attrs.valueRange) {
this.valueRange_ = attrs.valueRange;
}
- if (attrs.minTickSize) {
- this.minTickSize_ = attrs.minTickSize;
- }
+ MochiKit.Base.update(this.attrs_, attrs);
if (typeof(attrs.labels) != 'undefined') {
this.labels_ = attrs.labels;
this.labelsFromCSV_ = (attrs.labels == null);
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 || {});
+}