1 // Copyright 2006 Dan Vanderkam (danvdk@gmail.com)
2 // All Rights Reserved.
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)
12 <div id="graphdiv" style="width:800px; height:500px;"></div>
13 <script type="text/javascript">
14 new DateGraph(document.getElementById("graphdiv"),
16 ["Series 1", "Series 2"],
20 The CSV file is of the form
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.
28 If the 'errorBars' option is set in the constructor, the input should be of
31 YYYYMMDD,A1,sigmaA1,B1,sigmaB1,...
32 YYYYMMDD,A2,sigmaA2,B2,sigmaB2,...
34 If the 'fractions' option is set, the input should be of the form:
36 YYYYMMDD,A1/B1,A2/B2,...
37 YYYYMMDD,A1/B1,A2/B2,...
39 And error bars will be calculated automatically using a binomial distribution.
41 For further documentation and examples, see http://www/~danvk/dg/
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.
55 DateGraph
= function(div
, file
, labels
, attrs
) {
56 if (arguments
.length
> 0)
57 this.__init__(div
, file
, labels
, attrs
);
60 DateGraph
.NAME
= "DateGraph";
61 DateGraph
.VERSION
= "1.1";
62 DateGraph
.__repr__
= function() {
63 return "[" + this.NAME
+ " " + this.VERSION
+ "]";
65 DateGraph
.toString
= function() {
66 return this.__repr__();
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;
76 // Default attribute values.
77 DateGraph
.DEFAULT_ATTRS
= {
78 highlightCircleSize
: 3,
82 // TODO(danvk): move defaults from createStatusMessage_ here.
85 // TODO(danvk): default padding
89 * Initializes the DateGraph. This creates a new DIV and constructs the PlotKit
90 * and interaction <canvas> inside of it. See the constructor for details
92 * @param {String | Function} file Source data
93 * @param {Array.<String>} labels Names of the data series
94 * @param {Object} attrs Miscellaneous other options
97 DateGraph
.prototype.__init__
= function(div
, file
, labels
, attrs
) {
98 // Copy the important bits into the object
99 // TODO(danvk): most of these should just stay in the attrs_ dictionary.
101 this.labels_
= labels
;
103 this.rollPeriod_
= attrs
.rollPeriod
|| DateGraph
.DEFAULT_ROLL_PERIOD
;
104 this.previousVerticalX_
= -1;
105 this.width_
= parseInt(div
.style
.width
, 10);
106 this.height_
= parseInt(div
.style
.height
, 10);
107 this.errorBars_
= attrs
.errorBars
|| false;
108 this.fractions_
= attrs
.fractions
|| false;
109 this.strokeWidth_
= attrs
.strokeWidth
|| DateGraph
.DEFAULT_STROKE_WIDTH
;
110 this.dateWindow_
= attrs
.dateWindow
|| null;
111 this.valueRange_
= attrs
.valueRange
|| null;
112 this.labelsSeparateLines
= attrs
.labelsSeparateLines
|| false;
113 this.labelsDiv_
= attrs
.labelsDiv
|| null;
114 this.labelsKMB_
= attrs
.labelsKMB
|| false;
115 this.minTickSize_
= attrs
.minTickSize
|| 0;
116 this.xValueParser_
= attrs
.xValueParser
|| DateGraph
.prototype.dateParser
;
117 this.xValueFormatter_
= attrs
.xValueFormatter
||
118 DateGraph
.prototype.dateString_
;
119 this.xTicker_
= attrs
.xTicker
|| DateGraph
.prototype.dateTicker
;
120 this.sigma_
= attrs
.sigma
|| 2.0;
121 this.wilsonInterval_
= attrs
.wilsonInterval
|| true;
122 this.customBars_
= attrs
.customBars
|| false;
124 this.attrs_
= DateGraph
.DEFAULT_ATTRS
;
125 MochiKit
.Base
.update(this.attrs_
, attrs
);
127 if (typeof this.attrs_
.pixelsPerXLabel
== 'undefined') {
128 this.attrs_
.pixelsPerXLabel
= 60;
131 // Make a note of whether labels will be pulled from the CSV file.
132 this.labelsFromCSV_
= (this.labels_
== null);
133 if (this.labels_
== null)
136 // Prototype of the callback is "void clickCallback(event, date)"
137 this.clickCallback_
= attrs
.clickCallback
|| null;
139 // Prototype of zoom callback is "void dragCallback(minDate, maxDate)"
140 this.zoomCallback_
= attrs
.zoomCallback
|| null;
142 // Create the containing DIV and other interactive elements
143 this.createInterface_();
145 // Create the PlotKit grapher
146 this.layoutOptions_
= { 'errorBars': (this.errorBars_
|| this.customBars_
),
147 'xOriginIsZero': false };
148 MochiKit
.Base
.update(this.layoutOptions_
, attrs
);
149 this.setColors_(attrs
);
151 this.layout_
= new DateGraphLayout(this.layoutOptions_
);
153 this.renderOptions_
= { colorScheme
: this.colors_
,
155 strokeWidth
: this.strokeWidth_
,
156 axisLabelFontSize
: 14,
157 axisLineWidth
: DateGraph
.AXIS_LINE_WIDTH
};
158 MochiKit
.Base
.update(this.renderOptions_
, attrs
);
159 this.plotter_
= new DateGraphCanvasRenderer(this.hidden_
, this.layout_
,
160 this.renderOptions_
);
162 this.createStatusMessage_();
163 this.createRollInterface_();
164 this.createDragInterface_();
166 // connect(window, 'onload', this, function(e) { this.start_(); });
171 * Returns the current rolling period, as set by the user or an option.
172 * @return {Number} The number of days in the rolling window
174 DateGraph
.prototype.rollPeriod
= function() {
175 return this.rollPeriod_
;
179 * Generates interface elements for the DateGraph: a containing div, a div to
180 * display the current point, and a textbox to adjust the rolling average
184 DateGraph
.prototype.createInterface_
= function() {
185 // Create the all-enclosing graph div
186 var enclosing
= this.maindiv_
;
188 this.graphDiv
= MochiKit
.DOM
.DIV( { style
: { 'width': this.width_
+ "px",
189 'height': this.height_
+ "px"
191 appendChildNodes(enclosing
, this.graphDiv
);
193 // Create the canvas to store
194 var canvas
= MochiKit
.DOM
.CANVAS
;
195 this.canvas_
= canvas( { style
: { 'position': 'absolute' },
197 height
: this.height_
});
198 appendChildNodes(this.graphDiv
, this.canvas_
);
200 this.hidden_
= this.createPlotKitCanvas_(this.canvas_
);
201 connect(this.hidden_
, 'onmousemove', this, function(e
) { this.mouseMove_(e
) });
202 connect(this.hidden_
, 'onmouseout', this, function(e
) { this.mouseOut_(e
) });
206 * Creates the canvas containing the PlotKit graph. Only plotkit ever draws on
207 * this particular canvas. All DateGraph work is done on this.canvas_.
208 * @param {Object} canvas The DateGraph canvas to over which to overlay the plot
209 * @return {Object} The newly-created canvas
212 DateGraph
.prototype.createPlotKitCanvas_
= function(canvas
) {
213 var h
= document
.createElement("canvas");
214 h
.style
.position
= "absolute";
215 h
.style
.top
= canvas
.style
.top
;
216 h
.style
.left
= canvas
.style
.left
;
217 h
.width
= this.width_
;
218 h
.height
= this.height_
;
219 MochiKit
.DOM
.appendChildNodes(this.graphDiv
, h
);
224 * Generate a set of distinct colors for the data series. This is done with a
225 * color wheel. Saturation/Value are customizable, and the hue is
226 * equally-spaced around the color wheel. If a custom set of colors is
227 * specified, that is used instead.
228 * @param {Object} attrs Various attributes, e.g. saturation and value
231 DateGraph
.prototype.setColors_
= function(attrs
) {
232 var num
= this.labels_
.length
;
235 var sat
= attrs
.colorSaturation
|| 1.0;
236 var val
= attrs
.colorValue
|| 0.5;
237 for (var i
= 1; i
<= num
; i
++) {
238 var hue
= (1.0*i
/(1+num
));
239 this.colors_
.push( MochiKit
.Color
.Color
.fromHSV(hue
, sat
, val
) );
242 for (var i
= 0; i
< num
; i
++) {
243 var colorStr
= attrs
.colors
[i
% attrs
.colors
.length
];
244 this.colors_
.push( MochiKit
.Color
.Color
.fromString(colorStr
) );
250 * Create the div that contains information on the selected point(s)
251 * This goes in the top right of the canvas, unless an external div has already
255 DateGraph
.prototype.createStatusMessage_
= function(){
256 if (!this.labelsDiv_
) {
257 var divWidth
= this.attrs_
.labelsDivWidth
;
258 var messagestyle
= { "style": {
259 "position": "absolute",
262 "width": divWidth
+ "px",
264 "left": this.width_
- divWidth
+ "px",
265 "background": "white",
267 "overflow": "hidden"}};
268 MochiKit
.Base
.update(messagestyle
["style"], this.attrs_
.labelsDivStyles
);
269 this.labelsDiv_
= MochiKit
.DOM
.DIV(messagestyle
);
270 MochiKit
.DOM
.appendChildNodes(this.graphDiv
, this.labelsDiv_
);
275 * Create the text box to adjust the averaging period
276 * @return {Object} The newly-created text box
279 DateGraph
.prototype.createRollInterface_
= function() {
280 var padding
= this.plotter_
.options
.padding
;
281 if (typeof this.attrs_
.showRoller
== 'undefined') {
282 this.attrs_
.showRoller
= false;
284 var display
= this.attrs_
.showRoller
? "block" : "none";
285 var textAttr
= { "type": "text",
287 "value": this.rollPeriod_
,
288 "style": { "position": "absolute",
290 "top": (this.height_
- 25 - padding
.bottom
) + "px",
291 "left": (padding
.left
+1) + "px",
294 var roller
= MochiKit
.DOM
.INPUT(textAttr
);
295 var pa
= this.graphDiv
;
296 MochiKit
.DOM
.appendChildNodes(pa
, roller
);
297 connect(roller
, 'onchange', this,
298 function() { this.adjustRoll(roller
.value
); });
303 * Set up all the mouse handlers needed to capture dragging behavior for zoom
304 * events. Uses MochiKit.Signal to attach all the event handlers.
307 DateGraph
.prototype.createDragInterface_
= function() {
310 // Tracks whether the mouse is down right now
311 var mouseDown
= false;
312 var dragStartX
= null;
313 var dragStartY
= null;
318 // Utility function to convert page-wide coordinates to canvas coords
321 var getX
= function(e
) { return e
.mouse().page
.x
- px
};
322 var getY
= function(e
) { return e
.mouse().page
.y
- py
};
324 // Draw zoom rectangles when the mouse is down and the user moves around
325 connect(this.hidden_
, 'onmousemove', function(event
) {
327 dragEndX
= getX(event
);
328 dragEndY
= getY(event
);
330 self
.drawZoomRect_(dragStartX
, dragEndX
, prevEndX
);
335 // Track the beginning of drag events
336 connect(this.hidden_
, 'onmousedown', function(event
) {
338 px
= PlotKit
.Base
.findPosX(self
.canvas_
);
339 py
= PlotKit
.Base
.findPosY(self
.canvas_
);
340 dragStartX
= getX(event
);
341 dragStartY
= getY(event
);
344 // If the user releases the mouse button during a drag, but not over the
345 // canvas, then it doesn't count as a zooming action.
346 connect(document
, 'onmouseup', this, function(event
) {
354 // Temporarily cancel the dragging event when the mouse leaves the graph
355 connect(this.hidden_
, 'onmouseout', this, function(event
) {
362 // If the mouse is released on the canvas during a drag event, then it's a
363 // zoom. Only do the zoom if it's over a large enough area (>= 10 pixels)
364 connect(this.hidden_
, 'onmouseup', this, function(event
) {
367 dragEndX
= getX(event
);
368 dragEndY
= getY(event
);
369 var regionWidth
= Math
.abs(dragEndX
- dragStartX
);
370 var regionHeight
= Math
.abs(dragEndY
- dragStartY
);
372 if (regionWidth
< 2 && regionHeight
< 2 &&
373 self
.clickCallback_
!= null &&
374 self
.lastx_
!= undefined
) {
375 self
.clickCallback_(event
, new Date(self
.lastx_
));
378 if (regionWidth
>= 10) {
379 self
.doZoom_(Math
.min(dragStartX
, dragEndX
),
380 Math
.max(dragStartX
, dragEndX
));
382 self
.canvas_
.getContext("2d").clearRect(0, 0,
384 self
.canvas_
.height
);
392 // Double-clicking zooms back out
393 connect(this.hidden_
, 'ondblclick', this, function(event
) {
394 self
.dateWindow_
= null;
395 self
.drawGraph_(self
.rawData_
);
396 var minDate
= self
.rawData_
[0][0];
397 var maxDate
= self
.rawData_
[self
.rawData_
.length
- 1][0];
398 if (self
.zoomCallback_
) {
399 self
.zoomCallback_(minDate
, maxDate
);
405 * Draw a gray zoom rectangle over the desired area of the canvas. Also clears
406 * up any previous zoom rectangles that were drawn. This could be optimized to
407 * avoid extra redrawing, but it's tricky to avoid interactions with the status
409 * @param {Number} startX The X position where the drag started, in canvas
411 * @param {Number} endX The current X position of the drag, in canvas coords.
412 * @param {Number} prevEndX The value of endX on the previous call to this
413 * function. Used to avoid excess redrawing
416 DateGraph
.prototype.drawZoomRect_
= function(startX
, endX
, prevEndX
) {
417 var ctx
= this.canvas_
.getContext("2d");
419 // Clean up from the previous rect if necessary
421 ctx
.clearRect(Math
.min(startX
, prevEndX
), 0,
422 Math
.abs(startX
- prevEndX
), this.height_
);
425 // Draw a light-grey rectangle to show the new viewing area
426 if (endX
&& startX
) {
427 ctx
.fillStyle
= "rgba(128,128,128,0.33)";
428 ctx
.fillRect(Math
.min(startX
, endX
), 0,
429 Math
.abs(endX
- startX
), this.height_
);
434 * Zoom to something containing [lowX, highX]. These are pixel coordinates
435 * in the canvas. The exact zoom window may be slightly larger if there are no
436 * data points near lowX or highX. This function redraws the graph.
437 * @param {Number} lowX The leftmost pixel value that should be visible.
438 * @param {Number} highX The rightmost pixel value that should be visible.
441 DateGraph
.prototype.doZoom_
= function(lowX
, highX
) {
442 // Find the earliest and latest dates contained in this canvasx range.
443 var points
= this.layout_
.points
;
446 // Find the nearest [minDate, maxDate] that contains [lowX, highX]
447 for (var i
= 0; i
< points
.length
; i
++) {
448 var cx
= points
[i
].canvasx
;
449 var x
= points
[i
].xval
;
450 if (cx
< lowX
&& (minDate
== null || x
> minDate
)) minDate
= x
;
451 if (cx
> highX
&& (maxDate
== null || x
< maxDate
)) maxDate
= x
;
453 // Use the extremes if either is missing
454 if (minDate
== null) minDate
= points
[0].xval
;
455 if (maxDate
== null) maxDate
= points
[points
.length
-1].xval
;
457 this.dateWindow_
= [minDate
, maxDate
];
458 this.drawGraph_(this.rawData_
);
459 if (this.zoomCallback_
) {
460 this.zoomCallback_(minDate
, maxDate
);
465 * When the mouse moves in the canvas, display information about a nearby data
466 * point and draw dots over those points in the data series. This function
467 * takes care of cleanup of previously-drawn dots.
468 * @param {Object} event The mousemove event from the browser.
471 DateGraph
.prototype.mouseMove_
= function(event
) {
472 var canvasx
= event
.mouse().page
.x
- PlotKit
.Base
.findPosX(this.hidden_
);
473 var points
= this.layout_
.points
;
478 // Loop through all the points and find the date nearest to our current
480 var minDist
= 1e+100;
482 for (var i
= 0; i
< points
.length
; i
++) {
483 var dist
= Math
.abs(points
[i
].canvasx
- canvasx
);
484 if (dist
> minDist
) break;
488 if (idx
>= 0) lastx
= points
[idx
].xval
;
489 // Check that you can really highlight the last day's data
490 if (canvasx
> points
[points
.length
-1].canvasx
)
491 lastx
= points
[points
.length
-1].xval
;
493 // Extract the points we've selected
495 for (var i
= 0; i
< points
.length
; i
++) {
496 if (points
[i
].xval
== lastx
) {
497 selPoints
.push(points
[i
]);
501 // Clear the previously drawn vertical, if there is one
502 var circleSize
= this.attrs_
.highlightCircleSize
;
503 var ctx
= this.canvas_
.getContext("2d");
504 if (this.previousVerticalX_
>= 0) {
505 var px
= this.previousVerticalX_
;
506 ctx
.clearRect(px
- circleSize
- 1, 0, 2 * circleSize
+ 2, this.height_
);
509 if (selPoints
.length
> 0) {
510 var canvasx
= selPoints
[0].canvasx
;
512 // Set the status message to indicate the selected point(s)
513 var replace
= this.xValueFormatter_(lastx
) + ":";
514 var clen
= this.colors_
.length
;
515 for (var i
= 0; i
< selPoints
.length
; i
++) {
516 if (this.labelsSeparateLines
) {
519 var point
= selPoints
[i
];
520 replace
+= " <b><font color='" + this.colors_
[i
%clen
].toHexString() + "'>"
521 + point
.name
+ "</font></b>:"
522 + this.round_(point
.yval
, 2);
524 this.labelsDiv_
.innerHTML
= replace
;
526 // Save last x position for callbacks.
529 // Draw colored circles over the center of each selected point
531 for (var i
= 0; i
< selPoints
.length
; i
++) {
533 ctx
.fillStyle
= this.colors_
[i
%clen
].toRGBString();
534 ctx
.arc(canvasx
, selPoints
[i
%clen
].canvasy
, circleSize
, 0, 360, false);
539 this.previousVerticalX_
= canvasx
;
544 * The mouse has left the canvas. Clear out whatever artifacts remain
545 * @param {Object} event the mouseout event from the browser.
548 DateGraph
.prototype.mouseOut_
= function(event
) {
549 // Get rid of the overlay data
550 var ctx
= this.canvas_
.getContext("2d");
551 ctx
.clearRect(0, 0, this.width_
, this.height_
);
552 this.labelsDiv_
.innerHTML
= "";
555 DateGraph
.zeropad
= function(x
) {
556 if (x
< 10) return "0" + x
; else return "" + x
;
560 * Return a string version of the hours, minutes and seconds portion of a date.
561 * @param {Number} date The JavaScript date (ms since epoch)
562 * @return {String} A time of the form "HH:MM:SS"
565 DateGraph
.prototype.hmsString_
= function(date
) {
566 var zeropad
= DateGraph
.zeropad
;
567 var d
= new Date(date
);
568 if (d
.getSeconds()) {
569 return zeropad(d
.getHours()) + ":" +
570 zeropad(d
.getMinutes()) + ":" +
571 zeropad(d
.getSeconds());
572 } else if (d
.getMinutes()) {
573 return zeropad(d
.getHours()) + ":" + zeropad(d
.getMinutes());
575 return zeropad(d
.getHours());
580 * Convert a JS date (millis since epoch) to YYYY/MM/DD
581 * @param {Number} date The JavaScript date (ms since epoch)
582 * @return {String} A date of the form "YYYY/MM/DD"
585 DateGraph
.prototype.dateString_
= function(date
) {
586 var zeropad
= DateGraph
.zeropad
;
587 var d
= new Date(date
);
590 var year
= "" + d
.getFullYear();
591 // Get a 0 padded month string
592 var month
= zeropad(d
.getMonth() + 1); //months are 0-offset, sigh
593 // Get a 0 padded day string
594 var day
= zeropad(d
.getDate());
597 var frac
= d
.getHours() * 3600 + d
.getMinutes() * 60 + d
.getSeconds();
598 if (frac
) ret
= " " + this.hmsString_(date
);
600 return year
+ "/" + month + "/" + day
+ ret
;
604 * Round a number to the specified number of digits past the decimal point.
605 * @param {Number} num The number to round
606 * @param {Number} places The number of decimals to which to round
607 * @return {Number} The rounded number
610 DateGraph
.prototype.round_
= function(num
, places
) {
611 var shift
= Math
.pow(10, places
);
612 return Math
.round(num
* shift
)/shift
;
616 * Fires when there's data available to be graphed.
617 * @param {String} data Raw CSV data to be plotted
620 DateGraph
.prototype.loadedEvent_
= function(data
) {
621 this.rawData_
= this.parseCSV_(data
);
622 this.drawGraph_(this.rawData_
);
625 DateGraph
.prototype.months
= ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
626 "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
627 DateGraph
.prototype.quarters
= ["Jan", "Apr", "Jul", "Oct"];
630 * Add ticks on the x-axis representing years, months, quarters, weeks, or days
633 DateGraph
.prototype.addXTicks_
= function() {
634 // Determine the correct ticks scale on the x-axis: quarterly, monthly, ...
635 var startDate
, endDate
;
636 if (this.dateWindow_
) {
637 startDate
= this.dateWindow_
[0];
638 endDate
= this.dateWindow_
[1];
640 startDate
= this.rawData_
[0][0];
641 endDate
= this.rawData_
[this.rawData_
.length
- 1][0];
644 var xTicks
= this.xTicker_(startDate
, endDate
);
645 this.layout_
.updateOptions({xTicks
: xTicks
});
648 // Time granularity enumeration
649 DateGraph
.SECONDLY
= 0;
650 DateGraph
.MINUTELY
= 1;
651 DateGraph
.HOURLY
= 2;
653 DateGraph
.WEEKLY
= 4;
654 DateGraph
.MONTHLY
= 5;
655 DateGraph
.QUARTERLY
= 6;
656 DateGraph
.BIANNUAL
= 7;
657 DateGraph
.ANNUAL
= 8;
658 DateGraph
.DECADAL
= 9;
659 DateGraph
.NUM_GRANULARITIES
= 10;
661 DateGraph
.SHORT_SPACINGS
= [];
662 DateGraph
.SHORT_SPACINGS
[DateGraph
.SECONDLY
] = 1000 * 1;
663 DateGraph
.SHORT_SPACINGS
[DateGraph
.MINUTELY
] = 1000 * 60;
664 DateGraph
.SHORT_SPACINGS
[DateGraph
.HOURLY
] = 1000 * 3600;
665 DateGraph
.SHORT_SPACINGS
[DateGraph
.DAILY
] = 1000 * 86400;
666 DateGraph
.SHORT_SPACINGS
[DateGraph
.WEEKLY
] = 1000 * 604800;
670 // If we used this time granularity, how many ticks would there be?
671 // This is only an approximation, but it's generally good enough.
673 DateGraph
.prototype.NumXTicks
= function(start_time
, end_time
, granularity
) {
674 if (granularity
< DateGraph
.MONTHLY
) {
675 // Generate one tick mark for every fixed interval of time.
676 var spacing
= DateGraph
.SHORT_SPACINGS
[granularity
];
677 return Math
.floor(0.5 + 1.0 * (end_time
- start_time
) / spacing
);
679 var year_mod
= 1; // e.g. to only print one point every 10 years.
681 if (granularity
== DateGraph
.QUARTERLY
) num_months
= 3;
682 if (granularity
== DateGraph
.BIANNUAL
) num_months
= 2;
683 if (granularity
== DateGraph
.ANNUAL
) num_months
= 1;
684 if (granularity
== DateGraph
.DECADAL
) { num_months
= 1; year_mod
= 10; }
686 var msInYear
= 365.2524 * 24 * 3600 * 1000;
687 var num_years
= 1.0 * (end_time
- start_time
) / msInYear
;
688 return Math
.floor(0.5 + 1.0 * num_years
* num_months
/ year_mod
);
694 // Construct an x-axis of nicely-formatted times on meaningful boundaries
695 // (e.g. 'Jan 09' rather than 'Jan 22, 2009').
697 // Returns an array containing {v: millis, label: label} dictionaries.
699 DateGraph
.prototype.GetXAxis
= function(start_time
, end_time
, granularity
) {
701 if (granularity
< DateGraph
.MONTHLY
) {
702 // Generate one tick mark for every fixed interval of time.
703 var spacing
= DateGraph
.SHORT_SPACINGS
[granularity
];
704 var format
= '%d%b'; // e.g. "1 Jan"
705 for (var t
= start_time
; t
<= end_time
; t
+= spacing
) {
707 var frac
= d
.getHours() * 3600 + d
.getMinutes() * 60 + d
.getSeconds();
708 if (frac
== 0 || granularity
>= DateGraph
.DAILY
) {
709 // the extra hour covers DST problems.
710 ticks
.push({ v
:t
, label
: new Date(t
+ 3600*1000).strftime(format
) });
712 ticks
.push({ v
:t
, label
: this.hmsString_(t
) });
716 // Display a tick mark on the first of a set of months of each year.
717 // Years get a tick mark iff y % year_mod == 0. This is useful for
718 // displaying a tick mark once every 10 years, say, on long time scales.
720 var year_mod
= 1; // e.g. to only print one point every 10 years.
722 // TODO(danvk): use CachingRoundTime where appropriate to get boundaries.
723 if (granularity
== DateGraph
.MONTHLY
) {
724 months
= [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ];
725 } else if (granularity
== DateGraph
.QUARTERLY
) {
726 months
= [ 0, 3, 6, 9 ];
727 } else if (granularity
== DateGraph
.BIANNUAL
) {
729 } else if (granularity
== DateGraph
.ANNUAL
) {
731 } else if (granularity
== DateGraph
.DECADAL
) {
736 var start_year
= new Date(start_time
).getFullYear();
737 var end_year
= new Date(end_time
).getFullYear();
738 var zeropad
= DateGraph
.zeropad
;
739 for (var i
= start_year
; i
<= end_year
; i
++) {
740 if (i
% year_mod
!= 0) continue;
741 for (var j
= 0; j
< months
.length
; j
++) {
742 var date_str
= i
+ "/" + zeropad(1 + months[j]) + "/01";
743 var t
= Date
.parse(date_str
);
744 if (t
< start_time
|| t
> end_time
) continue;
745 ticks
.push({ v
:t
, label
: new Date(t
).strftime('%b %y') });
755 * Add ticks to the x-axis based on a date range.
756 * @param {Number} startDate Start of the date window (millis since epoch)
757 * @param {Number} endDate End of the date window (millis since epoch)
758 * @return {Array.<Object>} Array of {label, value} tuples.
761 DateGraph
.prototype.dateTicker
= function(startDate
, endDate
) {
763 for (var i
= 0; i
< DateGraph
.NUM_GRANULARITIES
; i
++) {
764 var num_ticks
= this.NumXTicks(startDate
, endDate
, i
);
765 if (this.width_
/ num_ticks
>= this.attrs_
.pixelsPerXLabel
) {
772 return this.GetXAxis(startDate
, endDate
, chosen
);
774 // TODO(danvk): signal error.
779 * Add ticks when the x axis has numbers on it (instead of dates)
780 * @param {Number} startDate Start of the date window (millis since epoch)
781 * @param {Number} endDate End of the date window (millis since epoch)
782 * @return {Array.<Object>} Array of {label, value} tuples.
785 DateGraph
.prototype.numericTicks
= function(minV
, maxV
) {
790 scale
= Math
.pow( 10, Math
.floor(Math
.log(maxV
)/Math
.log(10.0)) );
793 // Add a smallish number of ticks at human-friendly points
794 var nTicks
= (maxV
- minV
) / scale
;
795 while (2 * nTicks
< 20) {
798 if ((maxV
- minV
) / nTicks
< this.minTickSize_
) {
799 nTicks
= this.round_((maxV
- minV
) / this.minTickSize_
, 1);
802 // Construct labels for the ticks
804 for (var i
= 0; i
<= nTicks
; i
++) {
805 var tickV
= minV
+ i
* (maxV
- minV
) / nTicks
;
806 var label
= this.round_(tickV
, 2);
807 if (this.labelsKMB_
) {
809 if (tickV
>= k
*k
*k
) {
810 label
= this.round_(tickV
/(k
*k
*k
), 1) + "B";
811 } else if (tickV
>= k
*k
) {
812 label
= this.round_(tickV
/(k
*k
), 1) + "M";
813 } else if (tickV
>= k
) {
814 label
= this.round_(tickV
/k
, 1) + "K";
817 ticks
.push( {label
: label
, v
: tickV
} );
823 * Adds appropriate ticks on the y-axis
824 * @param {Number} minY The minimum Y value in the data set
825 * @param {Number} maxY The maximum Y value in the data set
828 DateGraph
.prototype.addYTicks_
= function(minY
, maxY
) {
829 // Set the number of ticks so that the labels are human-friendly.
830 var ticks
= this.numericTicks(minY
, maxY
);
831 this.layout_
.updateOptions( { yAxis
: [minY
, maxY
],
836 * Update the graph with new data. Data is in the format
837 * [ [date1, val1, val2, ...], [date2, val1, val2, ...] if errorBars=false
838 * or, if errorBars=true,
839 * [ [date1, [val1,stddev1], [val2,stddev2], ...], [date2, ...], ...]
840 * @param {Array.<Object>} data The data (see above)
843 DateGraph
.prototype.drawGraph_
= function(data
) {
845 this.layout_
.removeAllDatasets();
846 // Loop over all fields in the dataset
847 for (var i
= 1; i
< data
[0].length
; i
++) {
849 for (var j
= 0; j
< data
.length
; j
++) {
850 var date
= data
[j
][0];
851 series
[j
] = [date
, data
[j
][i
]];
853 series
= this.rollingAverage(series
, this.rollPeriod_
);
855 // Prune down to the desired range, if necessary (for zooming)
856 var bars
= this.errorBars_
|| this.customBars_
;
857 if (this.dateWindow_
) {
858 var low
= this.dateWindow_
[0];
859 var high
= this.dateWindow_
[1];
861 for (var k
= 0; k
< series
.length
; k
++) {
862 if (series
[k
][0] >= low
&& series
[k
][0] <= high
) {
863 pruned
.push(series
[k
]);
864 var y
= bars
? series
[k
][1][0] : series
[k
][1];
865 if (maxY
== null || y
> maxY
) maxY
= y
;
870 for (var j
= 0; j
< series
.length
; j
++) {
871 var y
= bars
? series
[j
][1][0] : series
[j
][1];
872 if (maxY
== null || y
> maxY
) {
873 maxY
= bars
? y
+ series
[j
][1][1] : y
;
880 for (var j
=0; j
<series
.length
; j
++)
881 vals
[j
] = [series
[j
][0],
882 series
[j
][1][0], series
[j
][1][1], series
[j
][1][2]];
883 this.layout_
.addDataset(this.labels_
[i
- 1], vals
);
885 this.layout_
.addDataset(this.labels_
[i
- 1], series
);
889 // Use some heuristics to come up with a good maxY value, unless it's been
890 // set explicitly by the user.
891 if (this.valueRange_
!= null) {
892 this.addYTicks_(this.valueRange_
[0], this.valueRange_
[1]);
894 // Add some padding and round up to an integer to be human-friendly.
896 if (maxY
<= 0.0) maxY
= 1.0;
898 var scale
= Math
.pow(10, Math
.floor(Math
.log(maxY
) / Math
.log(10.0)));
899 maxY
= scale
* Math
.ceil(maxY
/ scale
);
901 this.addYTicks_(0, maxY
);
906 // Tell PlotKit to use this new data and render itself
907 this.layout_
.evaluateWithError();
908 this.plotter_
.clear();
909 this.plotter_
.render();
910 this.canvas_
.getContext('2d').clearRect(0, 0,
911 this.canvas_
.width
, this.canvas_
.height
);
915 * Calculates the rolling average of a data set.
916 * If originalData is [label, val], rolls the average of those.
917 * If originalData is [label, [, it's interpreted as [value, stddev]
918 * and the roll is returned in the same form, with appropriately reduced
919 * stddev for each value.
920 * Note that this is where fractional input (i.e. '5/10') is converted into
922 * @param {Array} originalData The data in the appropriate format (see above)
923 * @param {Number} rollPeriod The number of days over which to average the data
925 DateGraph
.prototype.rollingAverage
= function(originalData
, rollPeriod
) {
926 if (originalData
.length
< 2)
928 var rollPeriod
= Math
.min(rollPeriod
, originalData
.length
- 1);
929 var rollingData
= [];
930 var sigma
= this.sigma_
;
932 if (this.fractions_
) {
934 var den
= 0; // numerator/denominator
936 for (var i
= 0; i
< originalData
.length
; i
++) {
937 num
+= originalData
[i
][1][0];
938 den
+= originalData
[i
][1][1];
939 if (i
- rollPeriod
>= 0) {
940 num
-= originalData
[i
- rollPeriod
][1][0];
941 den
-= originalData
[i
- rollPeriod
][1][1];
944 var date
= originalData
[i
][0];
945 var value
= den
? num
/ den
: 0.0;
946 if (this.errorBars_
) {
947 if (this.wilsonInterval_
) {
948 // For more details on this confidence interval, see:
949 // http://en.wikipedia.org/wiki
/Binomial_confidence_interval
951 var p
= value
< 0 ? 0 : value
, n
= den
;
952 var pm
= sigma
* Math
.sqrt(p
*(1-p
)/n + sigma*sigma/(4*n
*n
));
953 var denom
= 1 + sigma
* sigma
/ den
;
954 var low
= (p
+ sigma
* sigma
/ (2 * den) - pm) / denom
;
955 var high
= (p
+ sigma
* sigma
/ (2 * den) + pm) / denom
;
956 rollingData
[i
] = [date
,
957 [p
* mult
, (p
- low
) * mult
, (high
- p
) * mult
]];
959 rollingData
[i
] = [date
, [0, 0, 0]];
962 var stddev
= den
? sigma
* Math
.sqrt(value
* (1 - value
) / den
) : 1.0;
963 rollingData
[i
] = [date
, [mult
* value
, mult
* stddev
, mult
* stddev
]];
966 rollingData
[i
] = [date
, mult
* value
];
969 } else if (this.customBars_
) {
974 for (var i
= 0; i
< originalData
.length
; i
++) {
975 var data
= originalData
[i
][1];
977 rollingData
[i
] = [originalData
[i
][0], [y
, y
- data
[0], data
[2] - y
]];
983 if (i
- rollPeriod
>= 0) {
984 var prev
= originalData
[i
- rollPeriod
];
990 rollingData
[i
] = [originalData
[i
][0], [ 1.0 * mid
/ count
,
991 1.0 * (mid
- low
) / count
,
992 1.0 * (high
- mid
) / count
]];
995 // Calculate the rolling average for the first rollPeriod - 1 points where
996 // there is not enough data to roll over the full number of days
997 var num_init_points
= Math
.min(rollPeriod
- 1, originalData
.length
- 2);
998 if (!this.errorBars_
){
999 for (var i
= 0; i
< num_init_points
; i
++) {
1001 for (var j
= 0; j
< i
+ 1; j
++)
1002 sum
+= originalData
[j
][1];
1003 rollingData
[i
] = [originalData
[i
][0], sum
/ (i
+ 1)];
1005 // Calculate the rolling average for the remaining points
1006 for (var i
= Math
.min(rollPeriod
- 1, originalData
.length
- 2);
1007 i
< originalData
.length
;
1010 for (var j
= i
- rollPeriod
+ 1; j
< i
+ 1; j
++)
1011 sum
+= originalData
[j
][1];
1012 rollingData
[i
] = [originalData
[i
][0], sum
/ rollPeriod
];
1015 for (var i
= 0; i
< num_init_points
; i
++) {
1018 for (var j
= 0; j
< i
+ 1; j
++) {
1019 sum
+= originalData
[j
][1][0];
1020 variance
+= Math
.pow(originalData
[j
][1][1], 2);
1022 var stddev
= Math
.sqrt(variance
)/(i
+1);
1023 rollingData
[i
] = [originalData
[i
][0],
1024 [sum
/(i
+1), sigma
* stddev
, sigma
* stddev
]];
1026 // Calculate the rolling average for the remaining points
1027 for (var i
= Math
.min(rollPeriod
- 1, originalData
.length
- 2);
1028 i
< originalData
.length
;
1032 for (var j
= i
- rollPeriod
+ 1; j
< i
+ 1; j
++) {
1033 sum
+= originalData
[j
][1][0];
1034 variance
+= Math
.pow(originalData
[j
][1][1], 2);
1036 var stddev
= Math
.sqrt(variance
) / rollPeriod
;
1037 rollingData
[i
] = [originalData
[i
][0],
1038 [sum
/ rollPeriod
, sigma
* stddev
, sigma
* stddev
]];
1047 * Parses a date, returning the number of milliseconds since epoch. This can be
1048 * passed in as an xValueParser in the DateGraph constructor.
1049 * @param {String} A date in YYYYMMDD format.
1050 * @return {Number} Milliseconds since epoch.
1053 DateGraph
.prototype.dateParser
= function(dateStr
) {
1055 if (dateStr
.length
== 10 && dateStr
.search("-") != -1) { // e.g. '2009-07-12'
1056 dateStrSlashed
= dateStr
.replace("-", "/", "g");
1057 while (dateStrSlashed
.search("-") != -1) {
1058 dateStrSlashed
= dateStrSlashed
.replace("-", "/");
1060 return Date
.parse(dateStrSlashed
);
1061 } else if (dateStr
.length
== 8) { // e.g. '20090712'
1062 dateStrSlashed
= dateStr
.substr(0,4) + "/" + dateStr
.substr(4,2)
1063 + "/" + dateStr
.substr(6,2);
1064 return Date
.parse(dateStrSlashed
);
1066 // Any format that Date.parse will accept, e.g. "2009/07/12" or
1067 // "2009/07/12 12:34:56"
1068 return Date
.parse(dateStr
);
1073 * Parses a string in a special csv format. We expect a csv file where each
1074 * line is a date point, and the first field in each line is the date string.
1075 * We also expect that all remaining fields represent series.
1076 * if this.errorBars_ is set, then interpret the fields as:
1077 * date, series1, stddev1, series2, stddev2, ...
1078 * @param {Array.<Object>} data See above.
1081 DateGraph
.prototype.parseCSV_
= function(data
) {
1083 var lines
= data
.split("\n");
1084 var start
= this.labelsFromCSV_
? 1 : 0;
1085 if (this.labelsFromCSV_
) {
1086 var labels
= lines
[0].split(",");
1087 labels
.shift(); // a "date" parameter is assumed.
1088 this.labels_
= labels
;
1089 // regenerate automatic colors.
1090 this.setColors_(this.attrs_
);
1091 this.renderOptions_
.colorScheme
= this.colors_
;
1092 MochiKit
.Base
.update(this.plotter_
.options
, this.renderOptions_
);
1093 MochiKit
.Base
.update(this.layoutOptions_
, this.attrs_
);
1096 for (var i
= start
; i
< lines
.length
; i
++) {
1097 var line
= lines
[i
];
1098 if (line
.length
== 0) continue; // skip blank lines
1099 var inFields
= line
.split(',');
1100 if (inFields
.length
< 2)
1104 fields
[0] = this.xValueParser_(inFields
[0]);
1106 // If fractions are expected, parse the numbers as "A/B
"
1107 if (this.fractions_) {
1108 for (var j = 1; j < inFields.length; j++) {
1109 // TODO(danvk): figure out an appropriate way to flag parse errors.
1110 var vals = inFields[j].split("/");
1111 fields[j] = [parseFloat(vals[0]), parseFloat(vals[1])];
1113 } else if (this.errorBars_) {
1114 // If there are error bars, values are (value, stddev) pairs
1115 for (var j = 1; j < inFields.length; j += 2)
1116 fields[(j + 1) / 2] = [parseFloat(inFields[j]),
1117 parseFloat(inFields[j + 1])];
1118 } else if (this.customBars_) {
1119 // Bars are a low;center;high tuple
1120 for (var j = 1; j < inFields.length; j++) {
1121 var vals = inFields[j].split(";");
1122 fields[j] = [ parseFloat(vals[0]),
1123 parseFloat(vals[1]),
1124 parseFloat(vals[2]) ];
1127 // Values are just numbers
1128 for (var j = 1; j < inFields.length; j++)
1129 fields[j] = parseFloat(inFields[j]);
1137 * Parses a DataTable object from gviz.
1138 * The data is expected to have a first column that is either a date or a
1139 * number. All subsequent columns must be numbers. If there is a clear mismatch
1140 * between this.xValueParser_ and the type of the first column, it will be
1141 * fixed. Returned value is in the same format as return value of parseCSV_.
1142 * @param {Array.<Object>} data See above.
1145 DateGraph.prototype.parseDataTable_ = function(data) {
1146 var cols = data.getNumberOfColumns();
1147 var rows = data.getNumberOfRows();
1149 // Read column labels
1151 for (var i = 0; i < cols; i++) {
1152 labels.push(data.getColumnLabel(i));
1154 labels.shift(); // the x-axis parameter is assumed and unnamed.
1155 this.labels_ = labels;
1156 // regenerate automatic colors.
1157 this.setColors_(this.attrs_);
1158 this.renderOptions_.colorScheme = this.colors_;
1159 MochiKit.Base.update(this.plotter_.options, this.renderOptions_);
1160 MochiKit.Base.update(this.layoutOptions_, this.attrs_);
1162 var indepType = data.getColumnType(0);
1163 if (indepType != 'date' && indepType != 'number') {
1164 // TODO(danvk): standardize error reporting.
1165 alert("only
'date' and
'number' types are supported
for column
1" +
1166 "of DataTable
input (Got
'" + indepType + "')");
1171 for (var i = 0; i < rows; i++) {
1173 if (indepType == 'date') {
1174 row.push(data.getValue(i, 0).getTime());
1176 row.push(data.getValue(i, 0));
1178 for (var j = 1; j < cols; j++) {
1179 row.push(data.getValue(i, j));
1187 * Get the CSV data. If it's in a function, call that function. If it's in a
1188 * file, do an XMLHttpRequest to get it.
1191 DateGraph.prototype.start_ = function() {
1192 if (typeof this.file_ == 'function') {
1193 // Stubbed out to allow this to run off a filesystem
1194 this.loadedEvent_(this.file_());
1195 } else if (typeof this.file_ == 'object' &&
1196 typeof this.file_.getColumnRange == 'function') {
1197 // must be a DataTable from gviz.
1198 this.rawData_ = this.parseDataTable_(this.file_);
1199 this.drawGraph_(this.rawData_);
1201 var req = new XMLHttpRequest();
1203 req.onreadystatechange = function () {
1204 if (req.readyState == 4) {
1205 if (req.status == 200) {
1206 caller.loadedEvent_(req.responseText);
1211 req.open("GET
", this.file_, true);
1217 * Changes various properties of the graph. These can include:
1219 * <li>file: changes the source data for the graph</li>
1220 * <li>errorBars: changes whether the data contains stddev</li>
1222 * @param {Object} attrs The new properties and values
1224 DateGraph.prototype.updateOptions = function(attrs) {
1225 if (attrs.errorBars) {
1226 this.errorBars_ = attrs.errorBars;
1228 if (attrs.customBars) {
1229 this.customBars_ = attrs.customBars;
1231 if (attrs.strokeWidth) {
1232 this.strokeWidth_ = attrs.strokeWidth;
1234 if (attrs.rollPeriod) {
1235 this.rollPeriod_ = attrs.rollPeriod;
1237 if (attrs.dateWindow) {
1238 this.dateWindow_ = attrs.dateWindow;
1240 if (attrs.valueRange) {
1241 this.valueRange_ = attrs.valueRange;
1243 if (attrs.minTickSize) {
1244 this.minTickSize_ = attrs.minTickSize;
1246 if (typeof(attrs.labels) != 'undefined') {
1247 this.labels_ = attrs.labels;
1248 this.labelsFromCSV_ = (attrs.labels == null);
1250 this.layout_.updateOptions({ 'errorBars': this.errorBars_ });
1251 if (attrs['file'] && attrs['file'] != this.file_) {
1252 this.file_ = attrs['file'];
1255 this.drawGraph_(this.rawData_);
1260 * Adjusts the number of days in the rolling average. Updates the graph to
1261 * reflect the new averaging period.
1262 * @param {Number} length Number of days over which to average the data.
1264 DateGraph.prototype.adjustRoll = function(length) {
1265 this.rollPeriod_ = length;
1266 this.drawGraph_(this.rawData_);
1271 * A wrapper around DateGraph that implements the gviz API.
1272 * @param {Object} container The DOM object the visualization should live in.
1274 DateGraph.GVizChart = function(container) {
1275 this.container = container;
1278 DateGraph.GVizChart.prototype.draw = function(data, options) {
1279 this.container.innerHTML = '';
1280 this.date_graph = new DateGraph(this.container, data, null, options || {});