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