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