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;
77 * Initializes the DateGraph. This creates a new DIV and constructs the PlotKit
78 * and interaction <canvas> inside of it. See the constructor for details
80 * @param {String | Function} file Source data
81 * @param {Array.<String>} labels Names of the data series
82 * @param {Object} attrs Miscellaneous other options
85 DateGraph
.prototype.__init__
= function(div
, file
, labels
, attrs
) {
86 // Copy the important bits into the object
87 // TODO(danvk): most of these should just stay in the attrs_ dictionary.
89 this.labels_
= labels
;
91 this.rollPeriod_
= attrs
.rollPeriod
|| DateGraph
.DEFAULT_ROLL_PERIOD
;
92 this.previousVerticalX_
= -1;
93 this.width_
= parseInt(div
.style
.width
, 10);
94 this.height_
= parseInt(div
.style
.height
, 10);
95 this.errorBars_
= attrs
.errorBars
|| false;
96 this.fractions_
= attrs
.fractions
|| false;
97 this.strokeWidth_
= attrs
.strokeWidth
|| DateGraph
.DEFAULT_STROKE_WIDTH
;
98 this.dateWindow_
= attrs
.dateWindow
|| null;
99 this.valueRange_
= attrs
.valueRange
|| null;
100 this.labelsSeparateLines
= attrs
.labelsSeparateLines
|| false;
101 this.labelsDiv_
= attrs
.labelsDiv
|| null;
102 this.labelsKMB_
= attrs
.labelsKMB
|| false;
103 this.minTickSize_
= attrs
.minTickSize
|| 0;
104 this.xValueParser_
= attrs
.xValueParser
|| DateGraph
.prototype.dateParser
;
105 this.xValueFormatter_
= attrs
.xValueFormatter
||
106 DateGraph
.prototype.dateString_
;
107 this.xTicker_
= attrs
.xTicker
|| DateGraph
.prototype.dateTicker
;
108 this.sigma_
= attrs
.sigma
|| 2.0;
109 this.wilsonInterval_
= attrs
.wilsonInterval
|| true;
110 this.customBars_
= attrs
.customBars
|| false;
113 if (typeof this.attrs_
.pixelsPerXLabel
== 'undefined') {
114 this.attrs_
.pixelsPerXLabel
= 60;
117 // Make a note of whether labels will be pulled from the CSV file.
118 this.labelsFromCSV_
= (this.labels_
== null);
119 if (this.labels_
== null)
122 // Prototype of the callback is "void clickCallback(event, date)"
123 this.clickCallback_
= attrs
.clickCallback
|| null;
125 // Prototype of zoom callback is "void dragCallback(minDate, maxDate)"
126 this.zoomCallback_
= attrs
.zoomCallback
|| null;
128 // Create the containing DIV and other interactive elements
129 this.createInterface_();
131 // Create the PlotKit grapher
132 this.layoutOptions_
= { 'errorBars': (this.errorBars_
|| this.customBars_
),
133 'xOriginIsZero': false };
134 MochiKit
.Base
.update(this.layoutOptions_
, attrs
);
135 this.setColors_(attrs
);
137 this.layout_
= new DateGraphLayout(this.layoutOptions_
);
139 this.renderOptions_
= { colorScheme
: this.colors_
,
141 strokeWidth
: this.strokeWidth_
,
142 axisLabelFontSize
: 14,
143 axisLineWidth
: DateGraph
.AXIS_LINE_WIDTH
};
144 MochiKit
.Base
.update(this.renderOptions_
, attrs
);
145 this.plotter_
= new DateGraphCanvasRenderer(this.hidden_
, this.layout_
,
146 this.renderOptions_
);
148 this.createStatusMessage_();
149 this.createRollInterface_();
150 this.createDragInterface_();
152 // connect(window, 'onload', this, function(e) { this.start_(); });
157 * Returns the current rolling period, as set by the user or an option.
158 * @return {Number} The number of days in the rolling window
160 DateGraph
.prototype.rollPeriod
= function() {
161 return this.rollPeriod_
;
165 * Generates interface elements for the DateGraph: a containing div, a div to
166 * display the current point, and a textbox to adjust the rolling average
170 DateGraph
.prototype.createInterface_
= function() {
171 // Create the all-enclosing graph div
172 var enclosing
= this.maindiv_
;
174 this.graphDiv
= MochiKit
.DOM
.DIV( { style
: { 'width': this.width_
+ "px",
175 'height': this.height_
+ "px"
177 appendChildNodes(enclosing
, this.graphDiv
);
179 // Create the canvas to store
180 var canvas
= MochiKit
.DOM
.CANVAS
;
181 this.canvas_
= canvas( { style
: { 'position': 'absolute' },
183 height
: this.height_
});
184 appendChildNodes(this.graphDiv
, this.canvas_
);
186 this.hidden_
= this.createPlotKitCanvas_(this.canvas_
);
187 connect(this.hidden_
, 'onmousemove', this, function(e
) { this.mouseMove_(e
) });
188 connect(this.hidden_
, 'onmouseout', this, function(e
) { this.mouseOut_(e
) });
192 * Creates the canvas containing the PlotKit graph. Only plotkit ever draws on
193 * this particular canvas. All DateGraph work is done on this.canvas_.
194 * @param {Object} canvas The DateGraph canvas to over which to overlay the plot
195 * @return {Object} The newly-created canvas
198 DateGraph
.prototype.createPlotKitCanvas_
= function(canvas
) {
199 var h
= document
.createElement("canvas");
200 h
.style
.position
= "absolute";
201 h
.style
.top
= canvas
.style
.top
;
202 h
.style
.left
= canvas
.style
.left
;
203 h
.width
= this.width_
;
204 h
.height
= this.height_
;
205 MochiKit
.DOM
.appendChildNodes(this.graphDiv
, h
);
210 * Generate a set of distinct colors for the data series. This is done with a
211 * color wheel. Saturation/Value are customizable, and the hue is
212 * equally-spaced around the color wheel. If a custom set of colors is
213 * specified, that is used instead.
214 * @param {Object} attrs Various attributes, e.g. saturation and value
217 DateGraph
.prototype.setColors_
= function(attrs
) {
218 var num
= this.labels_
.length
;
221 var sat
= attrs
.colorSaturation
|| 1.0;
222 var val
= attrs
.colorValue
|| 0.5;
223 for (var i
= 1; i
<= num
; i
++) {
224 var hue
= (1.0*i
/(1+num
));
225 this.colors_
.push( MochiKit
.Color
.Color
.fromHSV(hue
, sat
, val
) );
228 for (var i
= 0; i
< num
; i
++) {
229 var colorStr
= attrs
.colors
[i
% attrs
.colors
.length
];
230 this.colors_
.push( MochiKit
.Color
.Color
.fromString(colorStr
) );
236 * Create the div that contains information on the selected point(s)
237 * This goes in the top right of the canvas, unless an external div has already
241 DateGraph
.prototype.createStatusMessage_
= function(){
242 if (!this.labelsDiv_
) {
244 var messagestyle
= { "style": {
245 "position": "absolute",
248 "width": divWidth
+ "px",
250 "left": this.width_
- divWidth
+ "px",
251 "background": "white",
253 "overflow": "hidden"}};
254 this.labelsDiv_
= MochiKit
.DOM
.DIV(messagestyle
);
255 MochiKit
.DOM
.appendChildNodes(this.graphDiv
, this.labelsDiv_
);
260 * Create the text box to adjust the averaging period
261 * @return {Object} The newly-created text box
264 DateGraph
.prototype.createRollInterface_
= function() {
265 var padding
= this.plotter_
.options
.padding
;
266 if (typeof this.attrs_
.showRoller
== 'undefined') {
267 this.attrs_
.showRoller
= false;
269 var display
= this.attrs_
.showRoller
? "block" : "none";
270 var textAttr
= { "type": "text",
272 "value": this.rollPeriod_
,
273 "style": { "position": "absolute",
275 "top": (this.height_
- 25 - padding
.bottom
) + "px",
276 "left": (padding
.left
+1) + "px",
279 var roller
= MochiKit
.DOM
.INPUT(textAttr
);
280 var pa
= this.graphDiv
;
281 MochiKit
.DOM
.appendChildNodes(pa
, roller
);
282 connect(roller
, 'onchange', this,
283 function() { this.adjustRoll(roller
.value
); });
288 * Set up all the mouse handlers needed to capture dragging behavior for zoom
289 * events. Uses MochiKit.Signal to attach all the event handlers.
292 DateGraph
.prototype.createDragInterface_
= function() {
295 // Tracks whether the mouse is down right now
296 var mouseDown
= false;
297 var dragStartX
= null;
298 var dragStartY
= null;
303 // Utility function to convert page-wide coordinates to canvas coords
306 var getX
= function(e
) { return e
.mouse().page
.x
- px
};
307 var getY
= function(e
) { return e
.mouse().page
.y
- py
};
309 // Draw zoom rectangles when the mouse is down and the user moves around
310 connect(this.hidden_
, 'onmousemove', function(event
) {
312 dragEndX
= getX(event
);
313 dragEndY
= getY(event
);
315 self
.drawZoomRect_(dragStartX
, dragEndX
, prevEndX
);
320 // Track the beginning of drag events
321 connect(this.hidden_
, 'onmousedown', function(event
) {
323 px
= PlotKit
.Base
.findPosX(self
.canvas_
);
324 py
= PlotKit
.Base
.findPosY(self
.canvas_
);
325 dragStartX
= getX(event
);
326 dragStartY
= getY(event
);
329 // If the user releases the mouse button during a drag, but not over the
330 // canvas, then it doesn't count as a zooming action.
331 connect(document
, 'onmouseup', this, function(event
) {
339 // Temporarily cancel the dragging event when the mouse leaves the graph
340 connect(this.hidden_
, 'onmouseout', this, function(event
) {
347 // If the mouse is released on the canvas during a drag event, then it's a
348 // zoom. Only do the zoom if it's over a large enough area (>= 10 pixels)
349 connect(this.hidden_
, 'onmouseup', this, function(event
) {
352 dragEndX
= getX(event
);
353 dragEndY
= getY(event
);
354 var regionWidth
= Math
.abs(dragEndX
- dragStartX
);
355 var regionHeight
= Math
.abs(dragEndY
- dragStartY
);
357 if (regionWidth
< 2 && regionHeight
< 2 &&
358 self
.clickCallback_
!= null &&
359 self
.lastx_
!= undefined
) {
360 self
.clickCallback_(event
, new Date(self
.lastx_
));
363 if (regionWidth
>= 10) {
364 self
.doZoom_(Math
.min(dragStartX
, dragEndX
),
365 Math
.max(dragStartX
, dragEndX
));
367 self
.canvas_
.getContext("2d").clearRect(0, 0,
369 self
.canvas_
.height
);
377 // Double-clicking zooms back out
378 connect(this.hidden_
, 'ondblclick', this, function(event
) {
379 self
.dateWindow_
= null;
380 self
.drawGraph_(self
.rawData_
);
381 var minDate
= self
.rawData_
[0][0];
382 var maxDate
= self
.rawData_
[self
.rawData_
.length
- 1][0];
383 if (self
.zoomCallback_
) {
384 self
.zoomCallback_(minDate
, maxDate
);
390 * Draw a gray zoom rectangle over the desired area of the canvas. Also clears
391 * up any previous zoom rectangles that were drawn. This could be optimized to
392 * avoid extra redrawing, but it's tricky to avoid interactions with the status
394 * @param {Number} startX The X position where the drag started, in canvas
396 * @param {Number} endX The current X position of the drag, in canvas coords.
397 * @param {Number} prevEndX The value of endX on the previous call to this
398 * function. Used to avoid excess redrawing
401 DateGraph
.prototype.drawZoomRect_
= function(startX
, endX
, prevEndX
) {
402 var ctx
= this.canvas_
.getContext("2d");
404 // Clean up from the previous rect if necessary
406 ctx
.clearRect(Math
.min(startX
, prevEndX
), 0,
407 Math
.abs(startX
- prevEndX
), this.height_
);
410 // Draw a light-grey rectangle to show the new viewing area
411 if (endX
&& startX
) {
412 ctx
.fillStyle
= "rgba(128,128,128,0.33)";
413 ctx
.fillRect(Math
.min(startX
, endX
), 0,
414 Math
.abs(endX
- startX
), this.height_
);
419 * Zoom to something containing [lowX, highX]. These are pixel coordinates
420 * in the canvas. The exact zoom window may be slightly larger if there are no
421 * data points near lowX or highX. This function redraws the graph.
422 * @param {Number} lowX The leftmost pixel value that should be visible.
423 * @param {Number} highX The rightmost pixel value that should be visible.
426 DateGraph
.prototype.doZoom_
= function(lowX
, highX
) {
427 // Find the earliest and latest dates contained in this canvasx range.
428 var points
= this.layout_
.points
;
431 // Find the nearest [minDate, maxDate] that contains [lowX, highX]
432 for (var i
= 0; i
< points
.length
; i
++) {
433 var cx
= points
[i
].canvasx
;
434 var x
= points
[i
].xval
;
435 if (cx
< lowX
&& (minDate
== null || x
> minDate
)) minDate
= x
;
436 if (cx
> highX
&& (maxDate
== null || x
< maxDate
)) maxDate
= x
;
438 // Use the extremes if either is missing
439 if (minDate
== null) minDate
= points
[0].xval
;
440 if (maxDate
== null) maxDate
= points
[points
.length
-1].xval
;
442 this.dateWindow_
= [minDate
, maxDate
];
443 this.drawGraph_(this.rawData_
);
444 if (this.zoomCallback_
) {
445 this.zoomCallback_(minDate
, maxDate
);
450 * When the mouse moves in the canvas, display information about a nearby data
451 * point and draw dots over those points in the data series. This function
452 * takes care of cleanup of previously-drawn dots.
453 * @param {Object} event The mousemove event from the browser.
456 DateGraph
.prototype.mouseMove_
= function(event
) {
457 var canvasx
= event
.mouse().page
.x
- PlotKit
.Base
.findPosX(this.hidden_
);
458 var points
= this.layout_
.points
;
463 // Loop through all the points and find the date nearest to our current
465 var minDist
= 1e+100;
467 for (var i
= 0; i
< points
.length
; i
++) {
468 var dist
= Math
.abs(points
[i
].canvasx
- canvasx
);
469 if (dist
> minDist
) break;
473 if (idx
>= 0) lastx
= points
[idx
].xval
;
474 // Check that you can really highlight the last day's data
475 if (canvasx
> points
[points
.length
-1].canvasx
)
476 lastx
= points
[points
.length
-1].xval
;
478 // Extract the points we've selected
480 for (var i
= 0; i
< points
.length
; i
++) {
481 if (points
[i
].xval
== lastx
) {
482 selPoints
.push(points
[i
]);
486 // Clear the previously drawn vertical, if there is one
488 var ctx
= this.canvas_
.getContext("2d");
489 if (this.previousVerticalX_
>= 0) {
490 var px
= this.previousVerticalX_
;
491 ctx
.clearRect(px
- circleSize
- 1, 0, 2 * circleSize
+ 2, this.height_
);
494 if (selPoints
.length
> 0) {
495 var canvasx
= selPoints
[0].canvasx
;
497 // Set the status message to indicate the selected point(s)
498 var replace
= this.xValueFormatter_(lastx
) + ":";
499 var clen
= this.colors_
.length
;
500 for (var i
= 0; i
< selPoints
.length
; i
++) {
501 if (this.labelsSeparateLines
) {
504 var point
= selPoints
[i
];
505 replace
+= " <b><font color='" + this.colors_
[i
%clen
].toHexString() + "'>"
506 + point
.name
+ "</font></b>:"
507 + this.round_(point
.yval
, 2);
509 this.labelsDiv_
.innerHTML
= replace
;
511 // Save last x position for callbacks.
514 // Draw colored circles over the center of each selected point
516 for (var i
= 0; i
< selPoints
.length
; i
++) {
518 ctx
.fillStyle
= this.colors_
[i
%clen
].toRGBString();
519 ctx
.arc(canvasx
, selPoints
[i
%clen
].canvasy
, circleSize
, 0, 360, false);
524 this.previousVerticalX_
= canvasx
;
529 * The mouse has left the canvas. Clear out whatever artifacts remain
530 * @param {Object} event the mouseout event from the browser.
533 DateGraph
.prototype.mouseOut_
= function(event
) {
534 // Get rid of the overlay data
535 var ctx
= this.canvas_
.getContext("2d");
536 ctx
.clearRect(0, 0, this.width_
, this.height_
);
537 this.labelsDiv_
.innerHTML
= "";
540 DateGraph
.zeropad
= function(x
) {
541 if (x
< 10) return "0" + x
; else return "" + x
;
545 * Return a string version of the hours, minutes and seconds portion of a date.
546 * @param {Number} date The JavaScript date (ms since epoch)
547 * @return {String} A time of the form "HH:MM:SS"
550 DateGraph
.prototype.hmsString_
= function(date
) {
551 var zeropad
= DateGraph
.zeropad
;
552 var d
= new Date(date
);
553 if (d
.getSeconds()) {
554 return zeropad(d
.getHours()) + ":" +
555 zeropad(d
.getMinutes()) + ":" +
556 zeropad(d
.getSeconds());
557 } else if (d
.getMinutes()) {
558 return zeropad(d
.getHours()) + ":" + zeropad(d
.getMinutes());
560 return zeropad(d
.getHours());
565 * Convert a JS date (millis since epoch) to YYYY/MM/DD
566 * @param {Number} date The JavaScript date (ms since epoch)
567 * @return {String} A date of the form "YYYY/MM/DD"
570 DateGraph
.prototype.dateString_
= function(date
) {
571 var zeropad
= DateGraph
.zeropad
;
572 var d
= new Date(date
);
575 var year
= "" + d
.getFullYear();
576 // Get a 0 padded month string
577 var month
= zeropad(d
.getMonth() + 1); //months are 0-offset, sigh
578 // Get a 0 padded day string
579 var day
= zeropad(d
.getDate());
582 var frac
= d
.getHours() * 3600 + d
.getMinutes() * 60 + d
.getSeconds();
583 if (frac
) ret
= " " + this.hmsString_(date
);
585 return year
+ "/" + month + "/" + day
+ ret
;
589 * Round a number to the specified number of digits past the decimal point.
590 * @param {Number} num The number to round
591 * @param {Number} places The number of decimals to which to round
592 * @return {Number} The rounded number
595 DateGraph
.prototype.round_
= function(num
, places
) {
596 var shift
= Math
.pow(10, places
);
597 return Math
.round(num
* shift
)/shift
;
601 * Fires when there's data available to be graphed.
602 * @param {String} data Raw CSV data to be plotted
605 DateGraph
.prototype.loadedEvent_
= function(data
) {
606 this.rawData_
= this.parseCSV_(data
);
607 this.drawGraph_(this.rawData_
);
610 DateGraph
.prototype.months
= ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
611 "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
612 DateGraph
.prototype.quarters
= ["Jan", "Apr", "Jul", "Oct"];
615 * Add ticks on the x-axis representing years, months, quarters, weeks, or days
618 DateGraph
.prototype.addXTicks_
= function() {
619 // Determine the correct ticks scale on the x-axis: quarterly, monthly, ...
620 var startDate
, endDate
;
621 if (this.dateWindow_
) {
622 startDate
= this.dateWindow_
[0];
623 endDate
= this.dateWindow_
[1];
625 startDate
= this.rawData_
[0][0];
626 endDate
= this.rawData_
[this.rawData_
.length
- 1][0];
629 var xTicks
= this.xTicker_(startDate
, endDate
);
630 this.layout_
.updateOptions({xTicks
: xTicks
});
633 // Time granularity enumeration
634 DateGraph
.SECONDLY
= 0;
635 DateGraph
.MINUTELY
= 1;
636 DateGraph
.HOURLY
= 2;
638 DateGraph
.WEEKLY
= 4;
639 DateGraph
.MONTHLY
= 5;
640 DateGraph
.QUARTERLY
= 6;
641 DateGraph
.BIANNUAL
= 7;
642 DateGraph
.ANNUAL
= 8;
643 DateGraph
.DECADAL
= 9;
644 DateGraph
.NUM_GRANULARITIES
= 10;
646 DateGraph
.SHORT_SPACINGS
= [];
647 DateGraph
.SHORT_SPACINGS
[DateGraph
.SECONDLY
] = 1000 * 1;
648 DateGraph
.SHORT_SPACINGS
[DateGraph
.MINUTELY
] = 1000 * 60;
649 DateGraph
.SHORT_SPACINGS
[DateGraph
.HOURLY
] = 1000 * 3600;
650 DateGraph
.SHORT_SPACINGS
[DateGraph
.DAILY
] = 1000 * 86400;
651 DateGraph
.SHORT_SPACINGS
[DateGraph
.WEEKLY
] = 1000 * 604800;
655 // If we used this time granularity, how many ticks would there be?
656 // This is only an approximation, but it's generally good enough.
658 DateGraph
.prototype.NumXTicks
= function(start_time
, end_time
, granularity
) {
659 if (granularity
< DateGraph
.MONTHLY
) {
660 // Generate one tick mark for every fixed interval of time.
661 var spacing
= DateGraph
.SHORT_SPACINGS
[granularity
];
662 return Math
.floor(0.5 + 1.0 * (end_time
- start_time
) / spacing
);
664 var year_mod
= 1; // e.g. to only print one point every 10 years.
666 if (granularity
== DateGraph
.QUARTERLY
) num_months
= 3;
667 if (granularity
== DateGraph
.BIANNUAL
) num_months
= 2;
668 if (granularity
== DateGraph
.ANNUAL
) num_months
= 1;
669 if (granularity
== DateGraph
.DECADAL
) { num_months
= 1; year_mod
= 10; }
671 var msInYear
= 365.2524 * 24 * 3600 * 1000;
672 var num_years
= 1.0 * (end_time
- start_time
) / msInYear
;
673 return Math
.floor(0.5 + 1.0 * num_years
* num_months
/ year_mod
);
679 // Construct an x-axis of nicely-formatted times on meaningful boundaries
680 // (e.g. 'Jan 09' rather than 'Jan 22, 2009').
682 // Returns an array containing {v: millis, label: label} dictionaries.
684 DateGraph
.prototype.GetXAxis
= function(start_time
, end_time
, granularity
) {
686 if (granularity
< DateGraph
.MONTHLY
) {
687 // Generate one tick mark for every fixed interval of time.
688 var spacing
= DateGraph
.SHORT_SPACINGS
[granularity
];
689 var format
= '%d%b'; // e.g. "1 Jan"
690 for (var t
= start_time
; t
<= end_time
; t
+= spacing
) {
692 var frac
= d
.getHours() * 3600 + d
.getMinutes() * 60 + d
.getSeconds();
693 if (frac
== 0 || granularity
>= DateGraph
.DAILY
) {
694 // the extra hour covers DST problems.
695 ticks
.push({ v
:t
, label
: new Date(t
+ 3600*1000).strftime(format
) });
697 ticks
.push({ v
:t
, label
: this.hmsString_(t
) });
701 // Display a tick mark on the first of a set of months of each year.
702 // Years get a tick mark iff y % year_mod == 0. This is useful for
703 // displaying a tick mark once every 10 years, say, on long time scales.
705 var year_mod
= 1; // e.g. to only print one point every 10 years.
707 // TODO(danvk): use CachingRoundTime where appropriate to get boundaries.
708 if (granularity
== DateGraph
.MONTHLY
) {
709 months
= [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ];
710 } else if (granularity
== DateGraph
.QUARTERLY
) {
711 months
= [ 0, 3, 6, 9 ];
712 } else if (granularity
== DateGraph
.BIANNUAL
) {
714 } else if (granularity
== DateGraph
.ANNUAL
) {
716 } else if (granularity
== DateGraph
.DECADAL
) {
721 var start_year
= new Date(start_time
).getFullYear();
722 var end_year
= new Date(end_time
).getFullYear();
723 var zeropad
= DateGraph
.zeropad
;
724 for (var i
= start_year
; i
<= end_year
; i
++) {
725 if (i
% year_mod
!= 0) continue;
726 for (var j
= 0; j
< months
.length
; j
++) {
727 var date_str
= i
+ "/" + zeropad(1 + months[j]) + "/01";
728 var t
= Date
.parse(date_str
);
729 if (t
< start_time
|| t
> end_time
) continue;
730 ticks
.push({ v
:t
, label
: new Date(t
).strftime('%b %y') });
740 * Add ticks to the x-axis based on a date range.
741 * @param {Number} startDate Start of the date window (millis since epoch)
742 * @param {Number} endDate End of the date window (millis since epoch)
743 * @return {Array.<Object>} Array of {label, value} tuples.
746 DateGraph
.prototype.dateTicker
= function(startDate
, endDate
) {
748 for (var i
= 0; i
< DateGraph
.NUM_GRANULARITIES
; i
++) {
749 var num_ticks
= this.NumXTicks(startDate
, endDate
, i
);
750 if (this.width_
/ num_ticks
>= this.attrs_
.pixelsPerXLabel
) {
757 return this.GetXAxis(startDate
, endDate
, chosen
);
759 // TODO(danvk): signal error.
764 * Add ticks when the x axis has numbers on it (instead of dates)
765 * @param {Number} startDate Start of the date window (millis since epoch)
766 * @param {Number} endDate End of the date window (millis since epoch)
767 * @return {Array.<Object>} Array of {label, value} tuples.
770 DateGraph
.prototype.numericTicks
= function(minV
, maxV
) {
775 scale
= Math
.pow( 10, Math
.floor(Math
.log(maxV
)/Math
.log(10.0)) );
778 // Add a smallish number of ticks at human-friendly points
779 var nTicks
= (maxV
- minV
) / scale
;
780 while (2 * nTicks
< 20) {
783 if ((maxV
- minV
) / nTicks
< this.minTickSize_
) {
784 nTicks
= this.round_((maxV
- minV
) / this.minTickSize_
, 1);
787 // Construct labels for the ticks
789 for (var i
= 0; i
<= nTicks
; i
++) {
790 var tickV
= minV
+ i
* (maxV
- minV
) / nTicks
;
791 var label
= this.round_(tickV
, 2);
792 if (this.labelsKMB_
) {
794 if (tickV
>= k
*k
*k
) {
795 label
= this.round_(tickV
/(k
*k
*k
), 1) + "B";
796 } else if (tickV
>= k
*k
) {
797 label
= this.round_(tickV
/(k
*k
), 1) + "M";
798 } else if (tickV
>= k
) {
799 label
= this.round_(tickV
/k
, 1) + "K";
802 ticks
.push( {label
: label
, v
: tickV
} );
808 * Adds appropriate ticks on the y-axis
809 * @param {Number} minY The minimum Y value in the data set
810 * @param {Number} maxY The maximum Y value in the data set
813 DateGraph
.prototype.addYTicks_
= function(minY
, maxY
) {
814 // Set the number of ticks so that the labels are human-friendly.
815 var ticks
= this.numericTicks(minY
, maxY
);
816 this.layout_
.updateOptions( { yAxis
: [minY
, maxY
],
821 * Update the graph with new data. Data is in the format
822 * [ [date1, val1, val2, ...], [date2, val1, val2, ...] if errorBars=false
823 * or, if errorBars=true,
824 * [ [date1, [val1,stddev1], [val2,stddev2], ...], [date2, ...], ...]
825 * @param {Array.<Object>} data The data (see above)
828 DateGraph
.prototype.drawGraph_
= function(data
) {
830 this.layout_
.removeAllDatasets();
831 // Loop over all fields in the dataset
832 for (var i
= 1; i
< data
[0].length
; i
++) {
834 for (var j
= 0; j
< data
.length
; j
++) {
835 var date
= data
[j
][0];
836 series
[j
] = [date
, data
[j
][i
]];
838 series
= this.rollingAverage(series
, this.rollPeriod_
);
840 // Prune down to the desired range, if necessary (for zooming)
841 var bars
= this.errorBars_
|| this.customBars_
;
842 if (this.dateWindow_
) {
843 var low
= this.dateWindow_
[0];
844 var high
= this.dateWindow_
[1];
846 for (var k
= 0; k
< series
.length
; k
++) {
847 if (series
[k
][0] >= low
&& series
[k
][0] <= high
) {
848 pruned
.push(series
[k
]);
849 var y
= bars
? series
[k
][1][0] : series
[k
][1];
850 if (maxY
== null || y
> maxY
) maxY
= y
;
855 for (var j
= 0; j
< series
.length
; j
++) {
856 var y
= bars
? series
[j
][1][0] : series
[j
][1];
857 if (maxY
== null || y
> maxY
) {
858 maxY
= bars
? y
+ series
[j
][1][1] : y
;
865 for (var j
=0; j
<series
.length
; j
++)
866 vals
[j
] = [series
[j
][0],
867 series
[j
][1][0], series
[j
][1][1], series
[j
][1][2]];
868 this.layout_
.addDataset(this.labels_
[i
- 1], vals
);
870 this.layout_
.addDataset(this.labels_
[i
- 1], series
);
874 // Use some heuristics to come up with a good maxY value, unless it's been
875 // set explicitly by the user.
876 if (this.valueRange_
!= null) {
877 this.addYTicks_(this.valueRange_
[0], this.valueRange_
[1]);
879 // Add some padding and round up to an integer to be human-friendly.
881 if (maxY
<= 0.0) maxY
= 1.0;
883 var scale
= Math
.pow(10, Math
.floor(Math
.log(maxY
) / Math
.log(10.0)));
884 maxY
= scale
* Math
.ceil(maxY
/ scale
);
886 this.addYTicks_(0, maxY
);
891 // Tell PlotKit to use this new data and render itself
892 this.layout_
.evaluateWithError();
893 this.plotter_
.clear();
894 this.plotter_
.render();
895 this.canvas_
.getContext('2d').clearRect(0, 0,
896 this.canvas_
.width
, this.canvas_
.height
);
900 * Calculates the rolling average of a data set.
901 * If originalData is [label, val], rolls the average of those.
902 * If originalData is [label, [, it's interpreted as [value, stddev]
903 * and the roll is returned in the same form, with appropriately reduced
904 * stddev for each value.
905 * Note that this is where fractional input (i.e. '5/10') is converted into
907 * @param {Array} originalData The data in the appropriate format (see above)
908 * @param {Number} rollPeriod The number of days over which to average the data
910 DateGraph
.prototype.rollingAverage
= function(originalData
, rollPeriod
) {
911 if (originalData
.length
< 2)
913 var rollPeriod
= Math
.min(rollPeriod
, originalData
.length
- 1);
914 var rollingData
= [];
915 var sigma
= this.sigma_
;
917 if (this.fractions_
) {
919 var den
= 0; // numerator/denominator
921 for (var i
= 0; i
< originalData
.length
; i
++) {
922 num
+= originalData
[i
][1][0];
923 den
+= originalData
[i
][1][1];
924 if (i
- rollPeriod
>= 0) {
925 num
-= originalData
[i
- rollPeriod
][1][0];
926 den
-= originalData
[i
- rollPeriod
][1][1];
929 var date
= originalData
[i
][0];
930 var value
= den
? num
/ den
: 0.0;
931 if (this.errorBars_
) {
932 if (this.wilsonInterval_
) {
933 // For more details on this confidence interval, see:
934 // http://en.wikipedia.org/wiki
/Binomial_confidence_interval
936 var p
= value
< 0 ? 0 : value
, n
= den
;
937 var pm
= sigma
* Math
.sqrt(p
*(1-p
)/n + sigma*sigma/(4*n
*n
));
938 var denom
= 1 + sigma
* sigma
/ den
;
939 var low
= (p
+ sigma
* sigma
/ (2 * den) - pm) / denom
;
940 var high
= (p
+ sigma
* sigma
/ (2 * den) + pm) / denom
;
941 rollingData
[i
] = [date
,
942 [p
* mult
, (p
- low
) * mult
, (high
- p
) * mult
]];
944 rollingData
[i
] = [date
, [0, 0, 0]];
947 var stddev
= den
? sigma
* Math
.sqrt(value
* (1 - value
) / den
) : 1.0;
948 rollingData
[i
] = [date
, [mult
* value
, mult
* stddev
, mult
* stddev
]];
951 rollingData
[i
] = [date
, mult
* value
];
954 } else if (this.customBars_
) {
959 for (var i
= 0; i
< originalData
.length
; i
++) {
960 var data
= originalData
[i
][1];
962 rollingData
[i
] = [originalData
[i
][0], [y
, y
- data
[0], data
[2] - y
]];
968 if (i
- rollPeriod
>= 0) {
969 var prev
= originalData
[i
- rollPeriod
];
975 rollingData
[i
] = [originalData
[i
][0], [ 1.0 * mid
/ count
,
976 1.0 * (mid
- low
) / count
,
977 1.0 * (high
- mid
) / count
]];
980 // Calculate the rolling average for the first rollPeriod - 1 points where
981 // there is not enough data to roll over the full number of days
982 var num_init_points
= Math
.min(rollPeriod
- 1, originalData
.length
- 2);
983 if (!this.errorBars_
){
984 for (var i
= 0; i
< num_init_points
; i
++) {
986 for (var j
= 0; j
< i
+ 1; j
++)
987 sum
+= originalData
[j
][1];
988 rollingData
[i
] = [originalData
[i
][0], sum
/ (i
+ 1)];
990 // Calculate the rolling average for the remaining points
991 for (var i
= Math
.min(rollPeriod
- 1, originalData
.length
- 2);
992 i
< originalData
.length
;
995 for (var j
= i
- rollPeriod
+ 1; j
< i
+ 1; j
++)
996 sum
+= originalData
[j
][1];
997 rollingData
[i
] = [originalData
[i
][0], sum
/ rollPeriod
];
1000 for (var i
= 0; i
< num_init_points
; i
++) {
1003 for (var j
= 0; j
< i
+ 1; j
++) {
1004 sum
+= originalData
[j
][1][0];
1005 variance
+= Math
.pow(originalData
[j
][1][1], 2);
1007 var stddev
= Math
.sqrt(variance
)/(i
+1);
1008 rollingData
[i
] = [originalData
[i
][0],
1009 [sum
/(i
+1), sigma
* stddev
, sigma
* stddev
]];
1011 // Calculate the rolling average for the remaining points
1012 for (var i
= Math
.min(rollPeriod
- 1, originalData
.length
- 2);
1013 i
< originalData
.length
;
1017 for (var j
= i
- rollPeriod
+ 1; j
< i
+ 1; j
++) {
1018 sum
+= originalData
[j
][1][0];
1019 variance
+= Math
.pow(originalData
[j
][1][1], 2);
1021 var stddev
= Math
.sqrt(variance
) / rollPeriod
;
1022 rollingData
[i
] = [originalData
[i
][0],
1023 [sum
/ rollPeriod
, sigma
* stddev
, sigma
* stddev
]];
1032 * Parses a date, returning the number of milliseconds since epoch. This can be
1033 * passed in as an xValueParser in the DateGraph constructor.
1034 * @param {String} A date in YYYYMMDD format.
1035 * @return {Number} Milliseconds since epoch.
1038 DateGraph
.prototype.dateParser
= function(dateStr
) {
1040 if (dateStr
.length
== 10 && dateStr
.search("-") != -1) { // e.g. '2009-07-12'
1041 dateStrSlashed
= dateStr
.replace("-", "/", "g");
1042 while (dateStrSlashed
.search("-") != -1) {
1043 dateStrSlashed
= dateStrSlashed
.replace("-", "/");
1045 return Date
.parse(dateStrSlashed
);
1046 } else if (dateStr
.length
== 8) { // e.g. '20090712'
1047 dateStrSlashed
= dateStr
.substr(0,4) + "/" + dateStr
.substr(4,2)
1048 + "/" + dateStr
.substr(6,2);
1049 return Date
.parse(dateStrSlashed
);
1051 // Any format that Date.parse will accept, e.g. "2009/07/12" or
1052 // "2009/07/12 12:34:56"
1053 return Date
.parse(dateStr
);
1058 * Parses a string in a special csv format. We expect a csv file where each
1059 * line is a date point, and the first field in each line is the date string.
1060 * We also expect that all remaining fields represent series.
1061 * if this.errorBars_ is set, then interpret the fields as:
1062 * date, series1, stddev1, series2, stddev2, ...
1063 * @param {Array.<Object>} data See above.
1066 DateGraph
.prototype.parseCSV_
= function(data
) {
1068 var lines
= data
.split("\n");
1069 var start
= this.labelsFromCSV_
? 1 : 0;
1070 if (this.labelsFromCSV_
) {
1071 var labels
= lines
[0].split(",");
1072 labels
.shift(); // a "date" parameter is assumed.
1073 this.labels_
= labels
;
1074 // regenerate automatic colors.
1075 this.setColors_(this.attrs_
);
1076 this.renderOptions_
.colorScheme
= this.colors_
;
1077 MochiKit
.Base
.update(this.plotter_
.options
, this.renderOptions_
);
1078 MochiKit
.Base
.update(this.layoutOptions_
, this.attrs_
);
1081 for (var i
= start
; i
< lines
.length
; i
++) {
1082 var line
= lines
[i
];
1083 if (line
.length
== 0) continue; // skip blank lines
1084 var inFields
= line
.split(',');
1085 if (inFields
.length
< 2)
1089 fields
[0] = this.xValueParser_(inFields
[0]);
1091 // If fractions are expected, parse the numbers as "A/B
"
1092 if (this.fractions_) {
1093 for (var j = 1; j < inFields.length; j++) {
1094 // TODO(danvk): figure out an appropriate way to flag parse errors.
1095 var vals = inFields[j].split("/");
1096 fields[j] = [parseFloat(vals[0]), parseFloat(vals[1])];
1098 } else if (this.errorBars_) {
1099 // If there are error bars, values are (value, stddev) pairs
1100 for (var j = 1; j < inFields.length; j += 2)
1101 fields[(j + 1) / 2] = [parseFloat(inFields[j]),
1102 parseFloat(inFields[j + 1])];
1103 } else if (this.customBars_) {
1104 // Bars are a low;center;high tuple
1105 for (var j = 1; j < inFields.length; j++) {
1106 var vals = inFields[j].split(";");
1107 fields[j] = [ parseFloat(vals[0]),
1108 parseFloat(vals[1]),
1109 parseFloat(vals[2]) ];
1112 // Values are just numbers
1113 for (var j = 1; j < inFields.length; j++)
1114 fields[j] = parseFloat(inFields[j]);
1122 * Parses a DataTable object from gviz.
1123 * The data is expected to have a first column that is either a date or a
1124 * number. All subsequent columns must be numbers. If there is a clear mismatch
1125 * between this.xValueParser_ and the type of the first column, it will be
1126 * fixed. Returned value is in the same format as return value of parseCSV_.
1127 * @param {Array.<Object>} data See above.
1130 DateGraph.prototype.parseDataTable_ = function(data) {
1131 var cols = data.getNumberOfColumns();
1132 var rows = data.getNumberOfRows();
1134 // Read column labels
1136 for (var i = 0; i < cols; i++) {
1137 labels.push(data.getColumnLabel(i));
1139 labels.shift(); // the x-axis parameter is assumed and unnamed.
1140 this.labels_ = labels;
1141 // regenerate automatic colors.
1142 this.setColors_(this.attrs_);
1143 this.renderOptions_.colorScheme = this.colors_;
1144 MochiKit.Base.update(this.plotter_.options, this.renderOptions_);
1145 MochiKit.Base.update(this.layoutOptions_, this.attrs_);
1147 var indepType = data.getColumnType(0);
1148 if (indepType != 'date' && indepType != 'number') {
1149 // TODO(danvk): standardize error reporting.
1150 alert("only
'date' and
'number' types are supported
for column
1" +
1151 "of DataTable
input (Got
'" + indepType + "')");
1156 for (var i = 0; i < rows; i++) {
1158 if (indepType == 'date') {
1159 row.push(data.getValue(i, 0).getTime());
1161 row.push(data.getValue(i, 0));
1163 for (var j = 1; j < cols; j++) {
1164 row.push(data.getValue(i, j));
1172 * Get the CSV data. If it's in a function, call that function. If it's in a
1173 * file, do an XMLHttpRequest to get it.
1176 DateGraph.prototype.start_ = function() {
1177 if (typeof this.file_ == 'function') {
1178 // Stubbed out to allow this to run off a filesystem
1179 this.loadedEvent_(this.file_());
1180 } else if (typeof this.file_ == 'object' &&
1181 typeof this.file_.getColumnRange == 'function') {
1182 // must be a DataTable from gviz.
1183 this.rawData_ = this.parseDataTable_(this.file_);
1184 this.drawGraph_(this.rawData_);
1186 var req = new XMLHttpRequest();
1188 req.onreadystatechange = function () {
1189 if (req.readyState == 4) {
1190 if (req.status == 200) {
1191 caller.loadedEvent_(req.responseText);
1196 req.open("GET
", this.file_, true);
1202 * Changes various properties of the graph. These can include:
1204 * <li>file: changes the source data for the graph</li>
1205 * <li>errorBars: changes whether the data contains stddev</li>
1207 * @param {Object} attrs The new properties and values
1209 DateGraph.prototype.updateOptions = function(attrs) {
1210 if (attrs.errorBars) {
1211 this.errorBars_ = attrs.errorBars;
1213 if (attrs.customBars) {
1214 this.customBars_ = attrs.customBars;
1216 if (attrs.strokeWidth) {
1217 this.strokeWidth_ = attrs.strokeWidth;
1219 if (attrs.rollPeriod) {
1220 this.rollPeriod_ = attrs.rollPeriod;
1222 if (attrs.dateWindow) {
1223 this.dateWindow_ = attrs.dateWindow;
1225 if (attrs.valueRange) {
1226 this.valueRange_ = attrs.valueRange;
1228 if (attrs.minTickSize) {
1229 this.minTickSize_ = attrs.minTickSize;
1231 if (typeof(attrs.labels) != 'undefined') {
1232 this.labels_ = attrs.labels;
1233 this.labelsFromCSV_ = (attrs.labels == null);
1235 this.layout_.updateOptions({ 'errorBars': this.errorBars_ });
1236 if (attrs['file'] && attrs['file'] != this.file_) {
1237 this.file_ = attrs['file'];
1240 this.drawGraph_(this.rawData_);
1245 * Adjusts the number of days in the rolling average. Updates the graph to
1246 * reflect the new averaging period.
1247 * @param {Number} length Number of days over which to average the data.
1249 DateGraph.prototype.adjustRoll = function(length) {
1250 this.rollPeriod_ = length;
1251 this.drawGraph_(this.rawData_);
1256 * A wrapper around DateGraph that implements the gviz API.
1257 * @param {Object} container The DOM object the visualization should live in.
1259 DateGraph.GVizChart = function(container) {
1260 this.container = container;
1263 DateGraph.GVizChart.prototype.draw = function(data, options) {
1264 this.container.innerHTML = '';
1265 this.date_graph = new DateGraph(this.container, data, null, options || {});