add very basic support for gviz DataTable input
[dygraphs.git] / dygraph.js
CommitLineData
6a1aa64f
DV
1// Copyright 2006 Dan Vanderkam (danvdk@gmail.com)
2// All Rights Reserved.
3
4/**
5 * @fileoverview Creates an interactive, zoomable graph based on a CSV file or
6 * string. DateGraph can handle multiple series with or without error bars. The
7 * date/value ranges will be automatically set. DateGraph uses the
8 * <canvas> tag, so it only works in FF1.5+.
9 * @author danvdk@gmail.com (Dan Vanderkam)
10
11 Usage:
12 <div id="graphdiv" style="width:800px; height:500px;"></div>
13 <script type="text/javascript">
14 new DateGraph(document.getElementById("graphdiv"),
15 "datafile.csv",
16 ["Series 1", "Series 2"],
17 { }); // options
18 </script>
19
20 The CSV file is of the form
21
22 YYYYMMDD,A1,B1,C1
23 YYYYMMDD,A2,B2,C2
24
25 If null is passed as the third parameter (series names), then the first line
26 of the CSV file is assumed to contain names for each series.
27
28 If the 'errorBars' option is set in the constructor, the input should be of
29 the form
30
31 YYYYMMDD,A1,sigmaA1,B1,sigmaB1,...
32 YYYYMMDD,A2,sigmaA2,B2,sigmaB2,...
33
34 If the 'fractions' option is set, the input should be of the form:
35
36 YYYYMMDD,A1/B1,A2/B2,...
37 YYYYMMDD,A1/B1,A2/B2,...
38
39 And error bars will be calculated automatically using a binomial distribution.
40
41 For further documentation and examples, see http://www/~danvk/dg/
42
43 */
44
45/**
46 * An interactive, zoomable graph
47 * @param {String | Function} file A file containing CSV data or a function that
48 * returns this data. The expected format for each line is
49 * YYYYMMDD,val1,val2,... or, if attrs.errorBars is set,
50 * YYYYMMDD,val1,stddev1,val2,stddev2,...
51 * @param {Array.<String>} labels Labels for the data series
52 * @param {Object} attrs Various other attributes, e.g. errorBars determines
53 * whether the input data contains error ranges.
54 */
55DateGraph = function(div, file, labels, attrs) {
56 if (arguments.length > 0)
57 this.__init__(div, file, labels, attrs);
58};
59
60DateGraph.NAME = "DateGraph";
61DateGraph.VERSION = "1.1";
62DateGraph.__repr__ = function() {
63 return "[" + this.NAME + " " + this.VERSION + "]";
64};
65DateGraph.toString = function() {
66 return this.__repr__();
67};
68
69// Various default values
70DateGraph.DEFAULT_ROLL_PERIOD = 1;
71DateGraph.DEFAULT_WIDTH = 480;
72DateGraph.DEFAULT_HEIGHT = 320;
73DateGraph.DEFAULT_STROKE_WIDTH = 1.0;
74DateGraph.AXIS_LINE_WIDTH = 0.3;
75
76/**
77 * Initializes the DateGraph. This creates a new DIV and constructs the PlotKit
78 * and interaction &lt;canvas&gt; inside of it. See the constructor for details
79 * on the parameters.
80 * @param {String | Function} file Source data
81 * @param {Array.<String>} labels Names of the data series
82 * @param {Object} attrs Miscellaneous other options
83 * @private
84 */
85DateGraph.prototype.__init__ = function(div, file, labels, attrs) {
86 // Copy the important bits into the object
87 this.maindiv_ = div;
88 this.labels_ = labels;
89 this.file_ = file;
90 this.rollPeriod_ = attrs.rollPeriod || DateGraph.DEFAULT_ROLL_PERIOD;
91 this.previousVerticalX_ = -1;
92 this.width_ = parseInt(div.style.width, 10);
93 this.height_ = parseInt(div.style.height, 10);
94 this.errorBars_ = attrs.errorBars || false;
95 this.fractions_ = attrs.fractions || false;
96 this.strokeWidth_ = attrs.strokeWidth || DateGraph.DEFAULT_STROKE_WIDTH;
97 this.dateWindow_ = attrs.dateWindow || null;
98 this.valueRange_ = attrs.valueRange || null;
99 this.labelsSeparateLines = attrs.labelsSeparateLines || false;
100 this.labelsDiv_ = attrs.labelsDiv || null;
101 this.labelsKMB_ = attrs.labelsKMB || false;
102 this.minTickSize_ = attrs.minTickSize || 0;
103 this.xValueParser_ = attrs.xValueParser || DateGraph.prototype.dateParser;
104 this.xValueFormatter_ = attrs.xValueFormatter ||
105 DateGraph.prototype.dateString_;
106 this.xTicker_ = attrs.xTicker || DateGraph.prototype.dateTicker;
107 this.sigma_ = attrs.sigma || 2.0;
108 this.wilsonInterval_ = attrs.wilsonInterval || true;
109 this.customBars_ = attrs.customBars || false;
110 this.attrs_ = attrs;
111
112 // Make a note of whether labels will be pulled from the CSV file.
113 this.labelsFromCSV_ = (this.labels_ == null);
114 if (this.labels_ == null)
115 this.labels_ = [];
116
117 // Prototype of the callback is "void clickCallback(event, date)"
118 this.clickCallback_ = attrs.clickCallback || null;
119
120 // Prototype of zoom callback is "void dragCallback(minDate, maxDate)"
121 this.zoomCallback_ = attrs.zoomCallback || null;
122
123 // Create the containing DIV and other interactive elements
124 this.createInterface_();
125
126 // Create the PlotKit grapher
127 this.layoutOptions_ = { 'errorBars': (this.errorBars_ || this.customBars_),
128 'xOriginIsZero': false };
129 MochiKit.Base.update(this.layoutOptions_, attrs);
130 this.setColors_(attrs);
131
132 this.layout_ = new DateGraphLayout(this.layoutOptions_);
133
134 this.renderOptions_ = { colorScheme: this.colors_,
135 strokeColor: null,
136 strokeWidth: this.strokeWidth_,
137 axisLabelFontSize: 14,
138 axisLineWidth: DateGraph.AXIS_LINE_WIDTH };
139 MochiKit.Base.update(this.renderOptions_, attrs);
140 this.plotter_ = new DateGraphCanvasRenderer(this.hidden_, this.layout_,
141 this.renderOptions_);
142
143 this.createStatusMessage_();
144 this.createRollInterface_();
145 this.createDragInterface_();
146
738fc797
DV
147 // connect(window, 'onload', this, function(e) { this.start_(); });
148 this.start_();
6a1aa64f
DV
149};
150
151/**
152 * Returns the current rolling period, as set by the user or an option.
153 * @return {Number} The number of days in the rolling window
154 */
155DateGraph.prototype.rollPeriod = function() {
156 return this.rollPeriod_;
157}
158
159/**
160 * Generates interface elements for the DateGraph: a containing div, a div to
161 * display the current point, and a textbox to adjust the rolling average
162 * period.
163 * @private
164 */
165DateGraph.prototype.createInterface_ = function() {
166 // Create the all-enclosing graph div
167 var enclosing = this.maindiv_;
168
169 this.graphDiv = MochiKit.DOM.DIV( { style: { 'width': this.width_ + "px",
170 'height': this.height_ + "px"
171 }});
172 appendChildNodes(enclosing, this.graphDiv);
173
174 // Create the canvas to store
175 var canvas = MochiKit.DOM.CANVAS;
176 this.canvas_ = canvas( { style: { 'position': 'absolute' },
177 width: this.width_,
178 height: this.height_});
179 appendChildNodes(this.graphDiv, this.canvas_);
180
181 this.hidden_ = this.createPlotKitCanvas_(this.canvas_);
182 connect(this.hidden_, 'onmousemove', this, function(e) { this.mouseMove_(e) });
183 connect(this.hidden_, 'onmouseout', this, function(e) { this.mouseOut_(e) });
184}
185
186/**
187 * Creates the canvas containing the PlotKit graph. Only plotkit ever draws on
188 * this particular canvas. All DateGraph work is done on this.canvas_.
189 * @param {Object} canvas The DateGraph canvas to over which to overlay the plot
190 * @return {Object} The newly-created canvas
191 * @private
192 */
193DateGraph.prototype.createPlotKitCanvas_ = function(canvas) {
194 var h = document.createElement("canvas");
195 h.style.position = "absolute";
196 h.style.top = canvas.style.top;
197 h.style.left = canvas.style.left;
198 h.width = this.width_;
199 h.height = this.height_;
200 MochiKit.DOM.appendChildNodes(this.graphDiv, h);
201 return h;
202};
203
204/**
205 * Generate a set of distinct colors for the data series. This is done with a
206 * color wheel. Saturation/Value are customizable, and the hue is
207 * equally-spaced around the color wheel. If a custom set of colors is
208 * specified, that is used instead.
209 * @param {Object} attrs Various attributes, e.g. saturation and value
210 * @private
211 */
212DateGraph.prototype.setColors_ = function(attrs) {
213 var num = this.labels_.length;
214 this.colors_ = [];
215 if (!attrs.colors) {
216 var sat = attrs.colorSaturation || 1.0;
217 var val = attrs.colorValue || 0.5;
218 for (var i = 1; i <= num; i++) {
219 var hue = (1.0*i/(1+num));
220 this.colors_.push( MochiKit.Color.Color.fromHSV(hue, sat, val) );
221 }
222 } else {
223 for (var i = 0; i < num; i++) {
224 var colorStr = attrs.colors[i % attrs.colors.length];
225 this.colors_.push( MochiKit.Color.Color.fromString(colorStr) );
226 }
227 }
228}
229
230/**
231 * Create the div that contains information on the selected point(s)
232 * This goes in the top right of the canvas, unless an external div has already
233 * been specified.
234 * @private
235 */
236DateGraph.prototype.createStatusMessage_ = function(){
237 if (!this.labelsDiv_) {
238 var divWidth = 250;
239 var messagestyle = { "style": {
240 "position": "absolute",
241 "fontSize": "14px",
242 "zIndex": 10,
243 "width": divWidth + "px",
244 "top": "0px",
245 "left": this.width_ - divWidth + "px",
246 "background": "white",
247 "textAlign": "left",
248 "overflow": "hidden"}};
249 this.labelsDiv_ = MochiKit.DOM.DIV(messagestyle);
250 MochiKit.DOM.appendChildNodes(this.graphDiv, this.labelsDiv_);
251 }
252};
253
254/**
255 * Create the text box to adjust the averaging period
256 * @return {Object} The newly-created text box
257 * @private
258 */
259DateGraph.prototype.createRollInterface_ = function() {
260 var padding = this.plotter_.options.padding;
738fc797
DV
261 if (typeof this.attrs_.showRoller == 'undefined') {
262 this.attrs_.showRoller = false;
263 }
264 var display = this.attrs_.showRoller ? "block" : "none";
6a1aa64f
DV
265 var textAttr = { "type": "text",
266 "size": "2",
267 "value": this.rollPeriod_,
268 "style": { "position": "absolute",
269 "zIndex": 10,
270 "top": (this.height_ - 25 - padding.bottom) + "px",
738fc797
DV
271 "left": (padding.left+1) + "px",
272 "display": display }
6a1aa64f
DV
273 };
274 var roller = MochiKit.DOM.INPUT(textAttr);
275 var pa = this.graphDiv;
276 MochiKit.DOM.appendChildNodes(pa, roller);
277 connect(roller, 'onchange', this,
278 function() { this.adjustRoll(roller.value); });
279 return roller;
280}
281
282/**
283 * Set up all the mouse handlers needed to capture dragging behavior for zoom
284 * events. Uses MochiKit.Signal to attach all the event handlers.
285 * @private
286 */
287DateGraph.prototype.createDragInterface_ = function() {
288 var self = this;
289
290 // Tracks whether the mouse is down right now
291 var mouseDown = false;
292 var dragStartX = null;
293 var dragStartY = null;
294 var dragEndX = null;
295 var dragEndY = null;
296 var prevEndX = null;
297
298 // Utility function to convert page-wide coordinates to canvas coords
67e650dc
DV
299 var px = 0;
300 var py = 0;
6a1aa64f
DV
301 var getX = function(e) { return e.mouse().page.x - px };
302 var getY = function(e) { return e.mouse().page.y - py };
303
304 // Draw zoom rectangles when the mouse is down and the user moves around
305 connect(this.hidden_, 'onmousemove', function(event) {
306 if (mouseDown) {
307 dragEndX = getX(event);
308 dragEndY = getY(event);
309
310 self.drawZoomRect_(dragStartX, dragEndX, prevEndX);
311 prevEndX = dragEndX;
312 }
313 });
314
315 // Track the beginning of drag events
316 connect(this.hidden_, 'onmousedown', function(event) {
317 mouseDown = true;
67e650dc
DV
318 px = PlotKit.Base.findPosX(self.canvas_);
319 py = PlotKit.Base.findPosY(self.canvas_);
6a1aa64f
DV
320 dragStartX = getX(event);
321 dragStartY = getY(event);
322 });
323
324 // If the user releases the mouse button during a drag, but not over the
325 // canvas, then it doesn't count as a zooming action.
326 connect(document, 'onmouseup', this, function(event) {
327 if (mouseDown) {
328 mouseDown = false;
329 dragStartX = null;
330 dragStartY = null;
331 }
332 });
333
334 // Temporarily cancel the dragging event when the mouse leaves the graph
335 connect(this.hidden_, 'onmouseout', this, function(event) {
336 if (mouseDown) {
337 dragEndX = null;
338 dragEndY = null;
339 }
340 });
341
342 // If the mouse is released on the canvas during a drag event, then it's a
343 // zoom. Only do the zoom if it's over a large enough area (>= 10 pixels)
344 connect(this.hidden_, 'onmouseup', this, function(event) {
345 if (mouseDown) {
346 mouseDown = false;
347 dragEndX = getX(event);
348 dragEndY = getY(event);
349 var regionWidth = Math.abs(dragEndX - dragStartX);
350 var regionHeight = Math.abs(dragEndY - dragStartY);
351
352 if (regionWidth < 2 && regionHeight < 2 &&
353 self.clickCallback_ != null &&
354 self.lastx_ != undefined) {
355 self.clickCallback_(event, new Date(self.lastx_));
356 }
357
358 if (regionWidth >= 10) {
359 self.doZoom_(Math.min(dragStartX, dragEndX),
360 Math.max(dragStartX, dragEndX));
361 } else {
362 self.canvas_.getContext("2d").clearRect(0, 0,
363 self.canvas_.width,
364 self.canvas_.height);
365 }
366
367 dragStartX = null;
368 dragStartY = null;
369 }
370 });
371
372 // Double-clicking zooms back out
373 connect(this.hidden_, 'ondblclick', this, function(event) {
374 self.dateWindow_ = null;
375 self.drawGraph_(self.rawData_);
376 var minDate = self.rawData_[0][0];
377 var maxDate = self.rawData_[self.rawData_.length - 1][0];
67e650dc
DV
378 if (self.zoomCallback_) {
379 self.zoomCallback_(minDate, maxDate);
380 }
6a1aa64f
DV
381 });
382};
383
384/**
385 * Draw a gray zoom rectangle over the desired area of the canvas. Also clears
386 * up any previous zoom rectangles that were drawn. This could be optimized to
387 * avoid extra redrawing, but it's tricky to avoid interactions with the status
388 * dots.
389 * @param {Number} startX The X position where the drag started, in canvas
390 * coordinates.
391 * @param {Number} endX The current X position of the drag, in canvas coords.
392 * @param {Number} prevEndX The value of endX on the previous call to this
393 * function. Used to avoid excess redrawing
394 * @private
395 */
396DateGraph.prototype.drawZoomRect_ = function(startX, endX, prevEndX) {
397 var ctx = this.canvas_.getContext("2d");
398
399 // Clean up from the previous rect if necessary
400 if (prevEndX) {
401 ctx.clearRect(Math.min(startX, prevEndX), 0,
402 Math.abs(startX - prevEndX), this.height_);
403 }
404
405 // Draw a light-grey rectangle to show the new viewing area
406 if (endX && startX) {
407 ctx.fillStyle = "rgba(128,128,128,0.33)";
408 ctx.fillRect(Math.min(startX, endX), 0,
409 Math.abs(endX - startX), this.height_);
410 }
411};
412
413/**
414 * Zoom to something containing [lowX, highX]. These are pixel coordinates
415 * in the canvas. The exact zoom window may be slightly larger if there are no
416 * data points near lowX or highX. This function redraws the graph.
417 * @param {Number} lowX The leftmost pixel value that should be visible.
418 * @param {Number} highX The rightmost pixel value that should be visible.
419 * @private
420 */
421DateGraph.prototype.doZoom_ = function(lowX, highX) {
422 // Find the earliest and latest dates contained in this canvasx range.
423 var points = this.layout_.points;
424 var minDate = null;
425 var maxDate = null;
426 // Find the nearest [minDate, maxDate] that contains [lowX, highX]
427 for (var i = 0; i < points.length; i++) {
428 var cx = points[i].canvasx;
429 var x = points[i].xval;
430 if (cx < lowX && (minDate == null || x > minDate)) minDate = x;
431 if (cx > highX && (maxDate == null || x < maxDate)) maxDate = x;
432 }
433 // Use the extremes if either is missing
434 if (minDate == null) minDate = points[0].xval;
435 if (maxDate == null) maxDate = points[points.length-1].xval;
436
437 this.dateWindow_ = [minDate, maxDate];
438 this.drawGraph_(this.rawData_);
67e650dc
DV
439 if (this.zoomCallback_) {
440 this.zoomCallback_(minDate, maxDate);
441 }
6a1aa64f
DV
442};
443
444/**
445 * When the mouse moves in the canvas, display information about a nearby data
446 * point and draw dots over those points in the data series. This function
447 * takes care of cleanup of previously-drawn dots.
448 * @param {Object} event The mousemove event from the browser.
449 * @private
450 */
451DateGraph.prototype.mouseMove_ = function(event) {
452 var canvasx = event.mouse().page.x - PlotKit.Base.findPosX(this.hidden_);
453 var points = this.layout_.points;
454
455 var lastx = -1;
456 var lasty = -1;
457
458 // Loop through all the points and find the date nearest to our current
459 // location.
460 var minDist = 1e+100;
461 var idx = -1;
462 for (var i = 0; i < points.length; i++) {
463 var dist = Math.abs(points[i].canvasx - canvasx);
464 if (dist > minDist) break;
465 minDist = dist;
466 idx = i;
467 }
468 if (idx >= 0) lastx = points[idx].xval;
469 // Check that you can really highlight the last day's data
470 if (canvasx > points[points.length-1].canvasx)
471 lastx = points[points.length-1].xval;
472
473 // Extract the points we've selected
474 var selPoints = [];
475 for (var i = 0; i < points.length; i++) {
476 if (points[i].xval == lastx) {
477 selPoints.push(points[i]);
478 }
479 }
480
481 // Clear the previously drawn vertical, if there is one
482 var circleSize = 3;
483 var ctx = this.canvas_.getContext("2d");
484 if (this.previousVerticalX_ >= 0) {
485 var px = this.previousVerticalX_;
486 ctx.clearRect(px - circleSize - 1, 0, 2 * circleSize + 2, this.height_);
487 }
488
489 if (selPoints.length > 0) {
490 var canvasx = selPoints[0].canvasx;
491
492 // Set the status message to indicate the selected point(s)
493 var replace = this.xValueFormatter_(lastx) + ":";
494 var clen = this.colors_.length;
495 for (var i = 0; i < selPoints.length; i++) {
496 if (this.labelsSeparateLines) {
497 replace += "<br/>";
498 }
499 var point = selPoints[i];
500 replace += " <b><font color='" + this.colors_[i%clen].toHexString() + "'>"
501 + point.name + "</font></b>:"
502 + this.round_(point.yval, 2);
503 }
504 this.labelsDiv_.innerHTML = replace;
505
506 // Save last x position for callbacks.
507 this.lastx_ = lastx;
508
509 // Draw colored circles over the center of each selected point
510 ctx.save()
511 for (var i = 0; i < selPoints.length; i++) {
512 ctx.beginPath();
513 ctx.fillStyle = this.colors_[i%clen].toRGBString();
514 ctx.arc(canvasx, selPoints[i%clen].canvasy, circleSize, 0, 360, false);
515 ctx.fill();
516 }
517 ctx.restore();
518
519 this.previousVerticalX_ = canvasx;
520 }
521};
522
523/**
524 * The mouse has left the canvas. Clear out whatever artifacts remain
525 * @param {Object} event the mouseout event from the browser.
526 * @private
527 */
528DateGraph.prototype.mouseOut_ = function(event) {
529 // Get rid of the overlay data
530 var ctx = this.canvas_.getContext("2d");
531 ctx.clearRect(0, 0, this.width_, this.height_);
532 this.labelsDiv_.innerHTML = "";
533};
534
535/**
6b8e33dd
DV
536 * Return a string version of the hours, minutes and seconds portion of a date.
537 * @param {Number} date The JavaScript date (ms since epoch)
538 * @return {String} A time of the form "HH:MM:SS"
539 * @private
540 */
541DateGraph.prototype.hmsString_ = function(date) {
542 var zeropad = function(x) {
543 if (x < 10) return "0" + x; else return "" + x;
544 };
545 var d = new Date(date);
546 if (d.getSeconds()) {
547 return zeropad(d.getHours()) + ":" +
548 zeropad(d.getMinutes()) + ":" +
549 zeropad(d.getSeconds());
550 } else if (d.getMinutes()) {
551 return zeropad(d.getHours()) + ":" + zeropad(d.getMinutes());
552 } else {
553 return zeropad(d.getHours());
554 }
555}
556
557/**
6a1aa64f
DV
558 * Convert a JS date (millis since epoch) to YYYY/MM/DD
559 * @param {Number} date The JavaScript date (ms since epoch)
560 * @return {String} A date of the form "YYYY/MM/DD"
561 * @private
562 */
563DateGraph.prototype.dateString_ = function(date) {
6b8e33dd
DV
564 var zeropad = function(x) {
565 if (x < 10) return "0" + x; else return "" + x;
566 };
6a1aa64f
DV
567 var d = new Date(date);
568
569 // Get the year:
570 var year = "" + d.getFullYear();
571 // Get a 0 padded month string
6b8e33dd 572 var month = zeropad(d.getMonth() + 1); //months are 0-offset, sigh
6a1aa64f 573 // Get a 0 padded day string
6b8e33dd 574 var day = zeropad(d.getDate());
6a1aa64f 575
6b8e33dd
DV
576 var ret = "";
577 var frac = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
578 if (frac) ret = " " + this.hmsString_(date);
579
580 return year + "/" + month + "/" + day + ret;
6a1aa64f
DV
581};
582
583/**
584 * Round a number to the specified number of digits past the decimal point.
585 * @param {Number} num The number to round
586 * @param {Number} places The number of decimals to which to round
587 * @return {Number} The rounded number
588 * @private
589 */
590DateGraph.prototype.round_ = function(num, places) {
591 var shift = Math.pow(10, places);
592 return Math.round(num * shift)/shift;
593};
594
595/**
596 * Fires when there's data available to be graphed.
597 * @param {String} data Raw CSV data to be plotted
598 * @private
599 */
600DateGraph.prototype.loadedEvent_ = function(data) {
601 this.rawData_ = this.parseCSV_(data);
602 this.drawGraph_(this.rawData_);
603};
604
605DateGraph.prototype.months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
606 "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
607DateGraph.prototype.quarters = ["Jan", "Apr", "Jul", "Oct"];
608
609/**
610 * Add ticks on the x-axis representing years, months, quarters, weeks, or days
611 * @private
612 */
613DateGraph.prototype.addXTicks_ = function() {
614 // Determine the correct ticks scale on the x-axis: quarterly, monthly, ...
615 var startDate, endDate;
616 if (this.dateWindow_) {
617 startDate = this.dateWindow_[0];
618 endDate = this.dateWindow_[1];
619 } else {
620 startDate = this.rawData_[0][0];
621 endDate = this.rawData_[this.rawData_.length - 1][0];
622 }
623
624 var xTicks = this.xTicker_(startDate, endDate);
625 this.layout_.updateOptions({xTicks: xTicks});
626}
627
628/**
629 * Add ticks to the x-axis based on a date range.
630 * @param {Number} startDate Start of the date window (millis since epoch)
631 * @param {Number} endDate End of the date window (millis since epoch)
632 * @return {Array.<Object>} Array of {label, value} tuples.
633 * @public
634 */
635DateGraph.prototype.dateTicker = function(startDate, endDate) {
636 var ONE_DAY = 24*60*60*1000;
637 startDate = startDate / ONE_DAY;
638 endDate = endDate / ONE_DAY;
639 var dateSpan = endDate - startDate;
640
641 var scale = [];
642 var isMonthly = false;
643 var yearMod = 1;
644 if (dateSpan > 30 * 366) { // decadal
645 isMonthly = true;
646 scale = ["Jan"];
647 yearMod = 10;
648 } else if (dateSpan > 4*366) { // annual
649 scale = ["Jan"];
650 isMonthly = true;
651 } else if (dateSpan > 366) { // quarterly
652 scale = this.quarters;
653 isMonthly = true;
654 } else if (dateSpan > 40) { // monthly
655 scale = this.months;
656 isMonthly = true;
657 } else if (dateSpan > 10) { // weekly
658 for (var week = startDate - 14; week < endDate + 14; week += 7) {
659 scale.push(week * ONE_DAY);
660 }
2769de62 661 } else if (dateSpan > 1) { // daily
6a1aa64f
DV
662 for (var day = startDate - 14; day < endDate + 14; day += 1) {
663 scale.push(day * ONE_DAY);
664 }
2769de62 665 } else { // hourly
6b8e33dd 666 for (var hour = Math.floor(startDate - 1) * 24;
2769de62
DV
667 hour < (endDate + 1) * 24; hour += 1) {
668 scale.push(hour * 60*60*1000);
669 }
6a1aa64f
DV
670 }
671
672 var xTicks = [];
673
674 if (isMonthly) {
675 var startYear = 1900 + (new Date(startDate* ONE_DAY)).getYear();
676 var endYear = 1900 + (new Date(endDate * ONE_DAY)).getYear();
677 for (var i = startYear; i <= endYear; i++) {
678 if (i % yearMod != 0) continue;
679 for (var j = 0; j < scale.length; j++ ) {
680 var date = Date.parse(scale[j] + " 1, " + i);
681 xTicks.push( {label: scale[j] + "'" + ("" + i).substr(2,2), v: date } );
682 }
683 }
684 } else {
685 for (var i = 0; i < scale.length; i++) {
6b8e33dd
DV
686 // TODO(danvk): this is _gross_. Unify all this with dateString_.
687 var d = new Date(scale[i]);
688 var frac = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
689 var label;
690 if (frac == 0) {
691 var year = d.getFullYear().toString();
692 var label = this.months[d.getMonth()] + d.getDate();
693 label += "'" + year.substr(year.length - 2, 2);
694 } else {
695 label = this.hmsString_(d);
696 }
697 xTicks.push( {label: label, v: d} );
6a1aa64f
DV
698 }
699 }
700 return xTicks;
701};
702
703/**
704 * Add ticks when the x axis has numbers on it (instead of dates)
705 * @param {Number} startDate Start of the date window (millis since epoch)
706 * @param {Number} endDate End of the date window (millis since epoch)
707 * @return {Array.<Object>} Array of {label, value} tuples.
708 * @public
709 */
710DateGraph.prototype.numericTicks = function(minV, maxV) {
711 var scale;
712 if (maxV <= 0.0) {
713 scale = 1.0;
714 } else {
715 scale = Math.pow( 10, Math.floor(Math.log(maxV)/Math.log(10.0)) );
716 }
717
718 // Add a smallish number of ticks at human-friendly points
719 var nTicks = (maxV - minV) / scale;
720 while (2 * nTicks < 20) {
721 nTicks *= 2;
722 }
723 if ((maxV - minV) / nTicks < this.minTickSize_) {
724 nTicks = this.round_((maxV - minV) / this.minTickSize_, 1);
725 }
726
727 // Construct labels for the ticks
728 var ticks = [];
729 for (var i = 0; i <= nTicks; i++) {
730 var tickV = minV + i * (maxV - minV) / nTicks;
731 var label = this.round_(tickV, 2);
732 if (this.labelsKMB_) {
733 var k = 1000;
734 if (tickV >= k*k*k) {
735 label = this.round_(tickV/(k*k*k), 1) + "B";
736 } else if (tickV >= k*k) {
737 label = this.round_(tickV/(k*k), 1) + "M";
738 } else if (tickV >= k) {
739 label = this.round_(tickV/k, 1) + "K";
740 }
741 }
742 ticks.push( {label: label, v: tickV} );
743 }
744 return ticks;
745};
746
747/**
748 * Adds appropriate ticks on the y-axis
749 * @param {Number} minY The minimum Y value in the data set
750 * @param {Number} maxY The maximum Y value in the data set
751 * @private
752 */
753DateGraph.prototype.addYTicks_ = function(minY, maxY) {
754 // Set the number of ticks so that the labels are human-friendly.
755 var ticks = this.numericTicks(minY, maxY);
756 this.layout_.updateOptions( { yAxis: [minY, maxY],
757 yTicks: ticks } );
758};
759
760/**
761 * Update the graph with new data. Data is in the format
762 * [ [date1, val1, val2, ...], [date2, val1, val2, ...] if errorBars=false
763 * or, if errorBars=true,
764 * [ [date1, [val1,stddev1], [val2,stddev2], ...], [date2, ...], ...]
765 * @param {Array.<Object>} data The data (see above)
766 * @private
767 */
768DateGraph.prototype.drawGraph_ = function(data) {
769 var maxY = null;
770 this.layout_.removeAllDatasets();
771 // Loop over all fields in the dataset
772 for (var i = 1; i < data[0].length; i++) {
773 var series = [];
774 for (var j = 0; j < data.length; j++) {
775 var date = data[j][0];
776 series[j] = [date, data[j][i]];
777 }
778 series = this.rollingAverage(series, this.rollPeriod_);
779
780 // Prune down to the desired range, if necessary (for zooming)
781 var bars = this.errorBars_ || this.customBars_;
782 if (this.dateWindow_) {
783 var low = this.dateWindow_[0];
784 var high= this.dateWindow_[1];
785 var pruned = [];
786 for (var k = 0; k < series.length; k++) {
787 if (series[k][0] >= low && series[k][0] <= high) {
788 pruned.push(series[k]);
789 var y = bars ? series[k][1][0] : series[k][1];
790 if (maxY == null || y > maxY) maxY = y;
791 }
792 }
793 series = pruned;
794 } else {
795 for (var j = 0; j < series.length; j++) {
796 var y = bars ? series[j][1][0] : series[j][1];
797 if (maxY == null || y > maxY) {
798 maxY = bars ? y + series[j][1][1] : y;
799 }
800 }
801 }
802
803 if (bars) {
804 var vals = [];
805 for (var j=0; j<series.length; j++)
806 vals[j] = [series[j][0],
807 series[j][1][0], series[j][1][1], series[j][1][2]];
808 this.layout_.addDataset(this.labels_[i - 1], vals);
809 } else {
810 this.layout_.addDataset(this.labels_[i - 1], series);
811 }
812 }
813
814 // Use some heuristics to come up with a good maxY value, unless it's been
815 // set explicitly by the user.
816 if (this.valueRange_ != null) {
817 this.addYTicks_(this.valueRange_[0], this.valueRange_[1]);
818 } else {
819 // Add some padding and round up to an integer to be human-friendly.
820 maxY *= 1.1;
821 if (maxY <= 0.0) maxY = 1.0;
822 else {
823 var scale = Math.pow(10, Math.floor(Math.log(maxY) / Math.log(10.0)));
824 maxY = scale * Math.ceil(maxY / scale);
825 }
826 this.addYTicks_(0, maxY);
827 }
828
829 this.addXTicks_();
830
831 // Tell PlotKit to use this new data and render itself
832 this.layout_.evaluateWithError();
833 this.plotter_.clear();
834 this.plotter_.render();
835 this.canvas_.getContext('2d').clearRect(0, 0,
836 this.canvas_.width, this.canvas_.height);
837};
838
839/**
840 * Calculates the rolling average of a data set.
841 * If originalData is [label, val], rolls the average of those.
842 * If originalData is [label, [, it's interpreted as [value, stddev]
843 * and the roll is returned in the same form, with appropriately reduced
844 * stddev for each value.
845 * Note that this is where fractional input (i.e. '5/10') is converted into
846 * decimal values.
847 * @param {Array} originalData The data in the appropriate format (see above)
848 * @param {Number} rollPeriod The number of days over which to average the data
849 */
850DateGraph.prototype.rollingAverage = function(originalData, rollPeriod) {
851 if (originalData.length < 2)
852 return originalData;
853 var rollPeriod = Math.min(rollPeriod, originalData.length - 1);
854 var rollingData = [];
855 var sigma = this.sigma_;
856
857 if (this.fractions_) {
858 var num = 0;
859 var den = 0; // numerator/denominator
860 var mult = 100.0;
861 for (var i = 0; i < originalData.length; i++) {
862 num += originalData[i][1][0];
863 den += originalData[i][1][1];
864 if (i - rollPeriod >= 0) {
865 num -= originalData[i - rollPeriod][1][0];
866 den -= originalData[i - rollPeriod][1][1];
867 }
868
869 var date = originalData[i][0];
870 var value = den ? num / den : 0.0;
871 if (this.errorBars_) {
872 if (this.wilsonInterval_) {
873 // For more details on this confidence interval, see:
874 // http://en.wikipedia.org/wiki/Binomial_confidence_interval
875 if (den) {
876 var p = value < 0 ? 0 : value, n = den;
877 var pm = sigma * Math.sqrt(p*(1-p)/n + sigma*sigma/(4*n*n));
878 var denom = 1 + sigma * sigma / den;
879 var low = (p + sigma * sigma / (2 * den) - pm) / denom;
880 var high = (p + sigma * sigma / (2 * den) + pm) / denom;
881 rollingData[i] = [date,
882 [p * mult, (p - low) * mult, (high - p) * mult]];
883 } else {
884 rollingData[i] = [date, [0, 0, 0]];
885 }
886 } else {
887 var stddev = den ? sigma * Math.sqrt(value * (1 - value) / den) : 1.0;
888 rollingData[i] = [date, [mult * value, mult * stddev, mult * stddev]];
889 }
890 } else {
891 rollingData[i] = [date, mult * value];
892 }
893 }
894 } else if (this.customBars_) {
f6885d6a
DV
895 var low = 0;
896 var mid = 0;
897 var high = 0;
898 var count = 0;
6a1aa64f
DV
899 for (var i = 0; i < originalData.length; i++) {
900 var data = originalData[i][1];
901 var y = data[1];
902 rollingData[i] = [originalData[i][0], [y, y - data[0], data[2] - y]];
f6885d6a
DV
903
904 low += data[0];
905 mid += y;
906 high += data[2];
907 count += 1;
908 if (i - rollPeriod >= 0) {
909 var prev = originalData[i - rollPeriod];
910 low -= prev[1][0];
911 mid -= prev[1][1];
912 high -= prev[1][2];
913 count -= 1;
914 }
915 rollingData[i] = [originalData[i][0], [ 1.0 * mid / count,
916 1.0 * (mid - low) / count,
917 1.0 * (high - mid) / count ]];
2769de62 918 }
6a1aa64f
DV
919 } else {
920 // Calculate the rolling average for the first rollPeriod - 1 points where
921 // there is not enough data to roll over the full number of days
922 var num_init_points = Math.min(rollPeriod - 1, originalData.length - 2);
923 if (!this.errorBars_){
924 for (var i = 0; i < num_init_points; i++) {
925 var sum = 0;
926 for (var j = 0; j < i + 1; j++)
927 sum += originalData[j][1];
928 rollingData[i] = [originalData[i][0], sum / (i + 1)];
929 }
930 // Calculate the rolling average for the remaining points
931 for (var i = Math.min(rollPeriod - 1, originalData.length - 2);
932 i < originalData.length;
933 i++) {
934 var sum = 0;
935 for (var j = i - rollPeriod + 1; j < i + 1; j++)
936 sum += originalData[j][1];
937 rollingData[i] = [originalData[i][0], sum / rollPeriod];
938 }
939 } else {
940 for (var i = 0; i < num_init_points; i++) {
941 var sum = 0;
942 var variance = 0;
943 for (var j = 0; j < i + 1; j++) {
944 sum += originalData[j][1][0];
945 variance += Math.pow(originalData[j][1][1], 2);
946 }
947 var stddev = Math.sqrt(variance)/(i+1);
948 rollingData[i] = [originalData[i][0],
949 [sum/(i+1), sigma * stddev, sigma * stddev]];
950 }
951 // Calculate the rolling average for the remaining points
952 for (var i = Math.min(rollPeriod - 1, originalData.length - 2);
953 i < originalData.length;
954 i++) {
955 var sum = 0;
956 var variance = 0;
957 for (var j = i - rollPeriod + 1; j < i + 1; j++) {
958 sum += originalData[j][1][0];
959 variance += Math.pow(originalData[j][1][1], 2);
960 }
961 var stddev = Math.sqrt(variance) / rollPeriod;
962 rollingData[i] = [originalData[i][0],
963 [sum / rollPeriod, sigma * stddev, sigma * stddev]];
964 }
965 }
966 }
967
968 return rollingData;
969};
970
971/**
972 * Parses a date, returning the number of milliseconds since epoch. This can be
973 * passed in as an xValueParser in the DateGraph constructor.
974 * @param {String} A date in YYYYMMDD format.
975 * @return {Number} Milliseconds since epoch.
976 * @public
977 */
978DateGraph.prototype.dateParser = function(dateStr) {
979 var dateStrSlashed;
2769de62 980 if (dateStr.length == 10 && dateStr.search("-") != -1) { // e.g. '2009-07-12'
6a1aa64f 981 dateStrSlashed = dateStr.replace("-", "/", "g");
353a0294
DV
982 while (dateStrSlashed.search("-") != -1) {
983 dateStrSlashed = dateStrSlashed.replace("-", "/");
984 }
2769de62
DV
985 return Date.parse(dateStrSlashed);
986 } else if (dateStr.length == 8) { // e.g. '20090712'
6a1aa64f
DV
987 dateStrSlashed = dateStr.substr(0,4) + "/" + dateStr.substr(4,2)
988 + "/" + dateStr.substr(6,2);
2769de62
DV
989 return Date.parse(dateStrSlashed);
990 } else {
991 // Any format that Date.parse will accept, e.g. "2009/07/12" or
992 // "2009/07/12 12:34:56"
993 return Date.parse(dateStr);
6a1aa64f 994 }
6a1aa64f
DV
995};
996
997/**
998 * Parses a string in a special csv format. We expect a csv file where each
999 * line is a date point, and the first field in each line is the date string.
1000 * We also expect that all remaining fields represent series.
1001 * if this.errorBars_ is set, then interpret the fields as:
1002 * date, series1, stddev1, series2, stddev2, ...
1003 * @param {Array.<Object>} data See above.
1004 * @private
1005 */
1006DateGraph.prototype.parseCSV_ = function(data) {
1007 var ret = [];
1008 var lines = data.split("\n");
1009 var start = this.labelsFromCSV_ ? 1 : 0;
1010 if (this.labelsFromCSV_) {
1011 var labels = lines[0].split(",");
1012 labels.shift(); // a "date" parameter is assumed.
1013 this.labels_ = labels;
1014 // regenerate automatic colors.
1015 this.setColors_(this.attrs_);
1016 this.renderOptions_.colorScheme = this.colors_;
1017 MochiKit.Base.update(this.plotter_.options, this.renderOptions_);
1018 MochiKit.Base.update(this.layoutOptions_, this.attrs_);
1019 }
1020
1021 for (var i = start; i < lines.length; i++) {
1022 var line = lines[i];
1023 if (line.length == 0) continue; // skip blank lines
1024 var inFields = line.split(',');
1025 if (inFields.length < 2)
1026 continue;
1027
1028 var fields = [];
1029 fields[0] = this.xValueParser_(inFields[0]);
1030
1031 // If fractions are expected, parse the numbers as "A/B"
1032 if (this.fractions_) {
1033 for (var j = 1; j < inFields.length; j++) {
1034 // TODO(danvk): figure out an appropriate way to flag parse errors.
1035 var vals = inFields[j].split("/");
1036 fields[j] = [parseFloat(vals[0]), parseFloat(vals[1])];
1037 }
1038 } else if (this.errorBars_) {
1039 // If there are error bars, values are (value, stddev) pairs
1040 for (var j = 1; j < inFields.length; j += 2)
1041 fields[(j + 1) / 2] = [parseFloat(inFields[j]),
1042 parseFloat(inFields[j + 1])];
1043 } else if (this.customBars_) {
1044 // Bars are a low;center;high tuple
1045 for (var j = 1; j < inFields.length; j++) {
1046 var vals = inFields[j].split(";");
1047 fields[j] = [ parseFloat(vals[0]),
1048 parseFloat(vals[1]),
1049 parseFloat(vals[2]) ];
1050 }
1051 } else {
1052 // Values are just numbers
1053 for (var j = 1; j < inFields.length; j++)
1054 fields[j] = parseFloat(inFields[j]);
1055 }
1056 ret.push(fields);
1057 }
1058 return ret;
1059};
1060
1061/**
79420a1e
DV
1062 * Parses a DataTable object from gviz.
1063 * The data is expected to have a first column that is either a date or a
1064 * number. All subsequent columns must be numbers. If there is a clear mismatch
1065 * between this.xValueParser_ and the type of the first column, it will be
1066 * fixed. Returned value is in the same format as return value of parseCSV_.
1067 * @param {Array.<Object>} data See above.
1068 * @private
1069 */
1070DateGraph.prototype.parseDataTable_ = function(data) {
1071 var cols = data.getNumberOfColumns();
1072 var rows = data.getNumberOfRows();
1073
1074 // Read column labels
1075 var labels = [];
1076 for (var i = 0; i < cols; i++) {
1077 labels.push(data.getColumnLabel(i));
1078 }
1079 labels.shift(); // a "date" parameter is assumed.
1080 this.labels_ = labels;
1081 // regenerate automatic colors.
1082 this.setColors_(this.attrs_);
1083 this.renderOptions_.colorScheme = this.colors_;
1084 MochiKit.Base.update(this.plotter_.options, this.renderOptions_);
1085 MochiKit.Base.update(this.layoutOptions_, this.attrs_);
1086
1087 // Assume column 1 is a date type for now.
1088 if (data.getColumnType(0) != 'date') {
1089 alert("only date type is support for column 1 of DataTable input.");
1090 return null;
1091 }
1092
1093 var ret = [];
1094 for (var i = 0; i < rows; i++) {
1095 var row = [];
1096 row.push(data.getValue(i, 0).getTime());
1097 for (var j = 1; j < cols; j++) {
1098 row.push(data.getValue(i, j));
1099 }
1100 ret.push(row);
1101 }
1102 return ret;
1103}
1104
1105/**
6a1aa64f
DV
1106 * Get the CSV data. If it's in a function, call that function. If it's in a
1107 * file, do an XMLHttpRequest to get it.
1108 * @private
1109 */
1110DateGraph.prototype.start_ = function() {
1111 if (typeof this.file_ == 'function') {
1112 // Stubbed out to allow this to run off a filesystem
1113 this.loadedEvent_(this.file_());
79420a1e
DV
1114 } else if (typeof this.file_ == 'object' &&
1115 typeof this.file_.getColumnRange == 'function') {
1116 // must be a DataTable from gviz.
1117 this.rawData_ = this.parseDataTable_(this.file_);
1118 this.drawGraph_(this.rawData_);
6a1aa64f
DV
1119 } else {
1120 var req = new XMLHttpRequest();
1121 var caller = this;
1122 req.onreadystatechange = function () {
1123 if (req.readyState == 4) {
1124 if (req.status == 200) {
1125 caller.loadedEvent_(req.responseText);
1126 }
1127 }
1128 };
1129
1130 req.open("GET", this.file_, true);
1131 req.send(null);
1132 }
1133};
1134
1135/**
1136 * Changes various properties of the graph. These can include:
1137 * <ul>
1138 * <li>file: changes the source data for the graph</li>
1139 * <li>errorBars: changes whether the data contains stddev</li>
1140 * </ul>
1141 * @param {Object} attrs The new properties and values
1142 */
1143DateGraph.prototype.updateOptions = function(attrs) {
1144 if (attrs.errorBars) {
1145 this.errorBars_ = attrs.errorBars;
1146 }
1147 if (attrs.customBars) {
1148 this.customBars_ = attrs.customBars;
1149 }
1150 if (attrs.strokeWidth) {
1151 this.strokeWidth_ = attrs.strokeWidth;
1152 }
1153 if (attrs.rollPeriod) {
1154 this.rollPeriod_ = attrs.rollPeriod;
1155 }
1156 if (attrs.dateWindow) {
1157 this.dateWindow_ = attrs.dateWindow;
1158 }
1159 if (attrs.valueRange) {
1160 this.valueRange_ = attrs.valueRange;
1161 }
1162 if (attrs.minTickSize) {
1163 this.minTickSize_ = attrs.minTickSize;
1164 }
1165 if (typeof(attrs.labels) != 'undefined') {
1166 this.labels_ = attrs.labels;
1167 this.labelsFromCSV_ = (attrs.labels == null);
1168 }
1169 this.layout_.updateOptions({ 'errorBars': this.errorBars_ });
1170 if (attrs['file'] && attrs['file'] != this.file_) {
1171 this.file_ = attrs['file'];
1172 this.start_();
1173 } else {
1174 this.drawGraph_(this.rawData_);
1175 }
1176};
1177
1178/**
1179 * Adjusts the number of days in the rolling average. Updates the graph to
1180 * reflect the new averaging period.
1181 * @param {Number} length Number of days over which to average the data.
1182 */
1183DateGraph.prototype.adjustRoll = function(length) {
1184 this.rollPeriod_ = length;
1185 this.drawGraph_(this.rawData_);
1186};