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,
83 // TODO(danvk): move defaults from createStatusMessage_ here.
86 // TODO(danvk): default padding
90 * Initializes the DateGraph. This creates a new DIV and constructs the PlotKit
91 * and interaction <canvas> inside of it. See the constructor for details
93 * @param {String | Function} file Source data
94 * @param {Array.<String>} labels Names of the data series
95 * @param {Object} attrs Miscellaneous other options
98 DateGraph
.prototype.__init__
= function(div
, file
, labels
, attrs
) {
99 // Copy the important bits into the object
100 // TODO(danvk): most of these should just stay in the attrs_ dictionary.
102 this.labels_
= labels
;
104 this.rollPeriod_
= attrs
.rollPeriod
|| DateGraph
.DEFAULT_ROLL_PERIOD
;
105 this.previousVerticalX_
= -1;
106 this.width_
= parseInt(div
.style
.width
, 10);
107 this.height_
= parseInt(div
.style
.height
, 10);
108 this.errorBars_
= attrs
.errorBars
|| false;
109 this.fractions_
= attrs
.fractions
|| false;
110 this.strokeWidth_
= attrs
.strokeWidth
|| DateGraph
.DEFAULT_STROKE_WIDTH
;
111 this.dateWindow_
= attrs
.dateWindow
|| null;
112 this.valueRange_
= attrs
.valueRange
|| null;
113 this.labelsSeparateLines
= attrs
.labelsSeparateLines
|| false;
114 this.labelsDiv_
= attrs
.labelsDiv
|| null;
115 this.labelsKMB_
= attrs
.labelsKMB
|| false;
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;
125 MochiKit
.Base
.update(this.attrs_
, DateGraph
.DEFAULT_ATTRS
);
126 MochiKit
.Base
.update(this.attrs_
, attrs
);
128 if (typeof this.attrs_
.pixelsPerXLabel
== 'undefined') {
129 this.attrs_
.pixelsPerXLabel
= 60;
132 // Make a note of whether labels will be pulled from the CSV file.
133 this.labelsFromCSV_
= (this.labels_
== null);
134 if (this.labels_
== null)
137 // Prototype of the callback is "void clickCallback(event, date)"
138 this.clickCallback_
= attrs
.clickCallback
|| null;
140 // Prototype of zoom callback is "void dragCallback(minDate, maxDate)"
141 this.zoomCallback_
= attrs
.zoomCallback
|| null;
143 // Create the containing DIV and other interactive elements
144 this.createInterface_();
146 // Create the PlotKit grapher
147 this.layoutOptions_
= { 'errorBars': (this.errorBars_
|| this.customBars_
),
148 'xOriginIsZero': false };
149 MochiKit
.Base
.update(this.layoutOptions_
, attrs
);
150 this.setColors_(attrs
);
152 this.layout_
= new DateGraphLayout(this.layoutOptions_
);
154 this.renderOptions_
= { colorScheme
: this.colors_
,
156 strokeWidth
: this.strokeWidth_
,
157 axisLabelFontSize
: 14,
158 axisLineWidth
: DateGraph
.AXIS_LINE_WIDTH
};
159 MochiKit
.Base
.update(this.renderOptions_
, attrs
);
160 this.plotter_
= new DateGraphCanvasRenderer(this.hidden_
, this.layout_
,
161 this.renderOptions_
);
163 this.createStatusMessage_();
164 this.createRollInterface_();
165 this.createDragInterface_();
167 // connect(window, 'onload', this, function(e) { this.start_(); });
172 * Returns the current rolling period, as set by the user or an option.
173 * @return {Number} The number of days in the rolling window
175 DateGraph
.prototype.rollPeriod
= function() {
176 return this.rollPeriod_
;
180 * Generates interface elements for the DateGraph: a containing div, a div to
181 * display the current point, and a textbox to adjust the rolling average
185 DateGraph
.prototype.createInterface_
= function() {
186 // Create the all-enclosing graph div
187 var enclosing
= this.maindiv_
;
189 this.graphDiv
= MochiKit
.DOM
.DIV( { style
: { 'width': this.width_
+ "px",
190 'height': this.height_
+ "px"
192 appendChildNodes(enclosing
, this.graphDiv
);
194 // Create the canvas to store
195 var canvas
= MochiKit
.DOM
.CANVAS
;
196 this.canvas_
= canvas( { style
: { 'position': 'absolute' },
198 height
: this.height_
});
199 appendChildNodes(this.graphDiv
, this.canvas_
);
201 this.hidden_
= this.createPlotKitCanvas_(this.canvas_
);
202 connect(this.hidden_
, 'onmousemove', this, function(e
) { this.mouseMove_(e
) });
203 connect(this.hidden_
, 'onmouseout', this, function(e
) { this.mouseOut_(e
) });
207 * Creates the canvas containing the PlotKit graph. Only plotkit ever draws on
208 * this particular canvas. All DateGraph work is done on this.canvas_.
209 * @param {Object} canvas The DateGraph canvas to over which to overlay the plot
210 * @return {Object} The newly-created canvas
213 DateGraph
.prototype.createPlotKitCanvas_
= function(canvas
) {
214 var h
= document
.createElement("canvas");
215 h
.style
.position
= "absolute";
216 h
.style
.top
= canvas
.style
.top
;
217 h
.style
.left
= canvas
.style
.left
;
218 h
.width
= this.width_
;
219 h
.height
= this.height_
;
220 MochiKit
.DOM
.appendChildNodes(this.graphDiv
, h
);
225 * Generate a set of distinct colors for the data series. This is done with a
226 * color wheel. Saturation/Value are customizable, and the hue is
227 * equally-spaced around the color wheel. If a custom set of colors is
228 * specified, that is used instead.
229 * @param {Object} attrs Various attributes, e.g. saturation and value
232 DateGraph
.prototype.setColors_
= function(attrs
) {
233 var num
= this.labels_
.length
;
236 var sat
= attrs
.colorSaturation
|| 1.0;
237 var val
= attrs
.colorValue
|| 0.5;
238 for (var i
= 1; i
<= num
; i
++) {
239 var hue
= (1.0*i
/(1+num
));
240 this.colors_
.push( MochiKit
.Color
.Color
.fromHSV(hue
, sat
, val
) );
243 for (var i
= 0; i
< num
; i
++) {
244 var colorStr
= attrs
.colors
[i
% attrs
.colors
.length
];
245 this.colors_
.push( MochiKit
.Color
.Color
.fromString(colorStr
) );
251 * Create the div that contains information on the selected point(s)
252 * This goes in the top right of the canvas, unless an external div has already
256 DateGraph
.prototype.createStatusMessage_
= function(){
257 if (!this.labelsDiv_
) {
258 var divWidth
= this.attrs_
.labelsDivWidth
;
259 var messagestyle
= { "style": {
260 "position": "absolute",
263 "width": divWidth
+ "px",
265 "left": this.width_
- divWidth
+ "px",
266 "background": "white",
268 "overflow": "hidden"}};
269 MochiKit
.Base
.update(messagestyle
["style"], this.attrs_
.labelsDivStyles
);
270 this.labelsDiv_
= MochiKit
.DOM
.DIV(messagestyle
);
271 MochiKit
.DOM
.appendChildNodes(this.graphDiv
, this.labelsDiv_
);
276 * Create the text box to adjust the averaging period
277 * @return {Object} The newly-created text box
280 DateGraph
.prototype.createRollInterface_
= function() {
281 var padding
= this.plotter_
.options
.padding
;
282 if (typeof this.attrs_
.showRoller
== 'undefined') {
283 this.attrs_
.showRoller
= false;
285 var display
= this.attrs_
.showRoller
? "block" : "none";
286 var textAttr
= { "type": "text",
288 "value": this.rollPeriod_
,
289 "style": { "position": "absolute",
291 "top": (this.height_
- 25 - padding
.bottom
) + "px",
292 "left": (padding
.left
+1) + "px",
295 var roller
= MochiKit
.DOM
.INPUT(textAttr
);
296 var pa
= this.graphDiv
;
297 MochiKit
.DOM
.appendChildNodes(pa
, roller
);
298 connect(roller
, 'onchange', this,
299 function() { this.adjustRoll(roller
.value
); });
304 * Set up all the mouse handlers needed to capture dragging behavior for zoom
305 * events. Uses MochiKit.Signal to attach all the event handlers.
308 DateGraph
.prototype.createDragInterface_
= function() {
311 // Tracks whether the mouse is down right now
312 var mouseDown
= false;
313 var dragStartX
= null;
314 var dragStartY
= null;
319 // Utility function to convert page-wide coordinates to canvas coords
322 var getX
= function(e
) { return e
.mouse().page
.x
- px
};
323 var getY
= function(e
) { return e
.mouse().page
.y
- py
};
325 // Draw zoom rectangles when the mouse is down and the user moves around
326 connect(this.hidden_
, 'onmousemove', function(event
) {
328 dragEndX
= getX(event
);
329 dragEndY
= getY(event
);
331 self
.drawZoomRect_(dragStartX
, dragEndX
, prevEndX
);
336 // Track the beginning of drag events
337 connect(this.hidden_
, 'onmousedown', function(event
) {
339 px
= PlotKit
.Base
.findPosX(self
.canvas_
);
340 py
= PlotKit
.Base
.findPosY(self
.canvas_
);
341 dragStartX
= getX(event
);
342 dragStartY
= getY(event
);
345 // If the user releases the mouse button during a drag, but not over the
346 // canvas, then it doesn't count as a zooming action.
347 connect(document
, 'onmouseup', this, function(event
) {
355 // Temporarily cancel the dragging event when the mouse leaves the graph
356 connect(this.hidden_
, 'onmouseout', this, function(event
) {
363 // If the mouse is released on the canvas during a drag event, then it's a
364 // zoom. Only do the zoom if it's over a large enough area (>= 10 pixels)
365 connect(this.hidden_
, 'onmouseup', this, function(event
) {
368 dragEndX
= getX(event
);
369 dragEndY
= getY(event
);
370 var regionWidth
= Math
.abs(dragEndX
- dragStartX
);
371 var regionHeight
= Math
.abs(dragEndY
- dragStartY
);
373 if (regionWidth
< 2 && regionHeight
< 2 &&
374 self
.clickCallback_
!= null &&
375 self
.lastx_
!= undefined
) {
376 self
.clickCallback_(event
, new Date(self
.lastx_
));
379 if (regionWidth
>= 10) {
380 self
.doZoom_(Math
.min(dragStartX
, dragEndX
),
381 Math
.max(dragStartX
, dragEndX
));
383 self
.canvas_
.getContext("2d").clearRect(0, 0,
385 self
.canvas_
.height
);
393 // Double-clicking zooms back out
394 connect(this.hidden_
, 'ondblclick', this, function(event
) {
395 self
.dateWindow_
= null;
396 self
.drawGraph_(self
.rawData_
);
397 var minDate
= self
.rawData_
[0][0];
398 var maxDate
= self
.rawData_
[self
.rawData_
.length
- 1][0];
399 if (self
.zoomCallback_
) {
400 self
.zoomCallback_(minDate
, maxDate
);
406 * Draw a gray zoom rectangle over the desired area of the canvas. Also clears
407 * up any previous zoom rectangles that were drawn. This could be optimized to
408 * avoid extra redrawing, but it's tricky to avoid interactions with the status
410 * @param {Number} startX The X position where the drag started, in canvas
412 * @param {Number} endX The current X position of the drag, in canvas coords.
413 * @param {Number} prevEndX The value of endX on the previous call to this
414 * function. Used to avoid excess redrawing
417 DateGraph
.prototype.drawZoomRect_
= function(startX
, endX
, prevEndX
) {
418 var ctx
= this.canvas_
.getContext("2d");
420 // Clean up from the previous rect if necessary
422 ctx
.clearRect(Math
.min(startX
, prevEndX
), 0,
423 Math
.abs(startX
- prevEndX
), this.height_
);
426 // Draw a light-grey rectangle to show the new viewing area
427 if (endX
&& startX
) {
428 ctx
.fillStyle
= "rgba(128,128,128,0.33)";
429 ctx
.fillRect(Math
.min(startX
, endX
), 0,
430 Math
.abs(endX
- startX
), this.height_
);
435 * Zoom to something containing [lowX, highX]. These are pixel coordinates
436 * in the canvas. The exact zoom window may be slightly larger if there are no
437 * data points near lowX or highX. This function redraws the graph.
438 * @param {Number} lowX The leftmost pixel value that should be visible.
439 * @param {Number} highX The rightmost pixel value that should be visible.
442 DateGraph
.prototype.doZoom_
= function(lowX
, highX
) {
443 // Find the earliest and latest dates contained in this canvasx range.
444 var points
= this.layout_
.points
;
447 // Find the nearest [minDate, maxDate] that contains [lowX, highX]
448 for (var i
= 0; i
< points
.length
; i
++) {
449 var cx
= points
[i
].canvasx
;
450 var x
= points
[i
].xval
;
451 if (cx
< lowX
&& (minDate
== null || x
> minDate
)) minDate
= x
;
452 if (cx
> highX
&& (maxDate
== null || x
< maxDate
)) maxDate
= x
;
454 // Use the extremes if either is missing
455 if (minDate
== null) minDate
= points
[0].xval
;
456 if (maxDate
== null) maxDate
= points
[points
.length
-1].xval
;
458 this.dateWindow_
= [minDate
, maxDate
];
459 this.drawGraph_(this.rawData_
);
460 if (this.zoomCallback_
) {
461 this.zoomCallback_(minDate
, maxDate
);
466 * When the mouse moves in the canvas, display information about a nearby data
467 * point and draw dots over those points in the data series. This function
468 * takes care of cleanup of previously-drawn dots.
469 * @param {Object} event The mousemove event from the browser.
472 DateGraph
.prototype.mouseMove_
= function(event
) {
473 var canvasx
= event
.mouse().page
.x
- PlotKit
.Base
.findPosX(this.hidden_
);
474 var points
= this.layout_
.points
;
479 // Loop through all the points and find the date nearest to our current
481 var minDist
= 1e+100;
483 for (var i
= 0; i
< points
.length
; i
++) {
484 var dist
= Math
.abs(points
[i
].canvasx
- canvasx
);
485 if (dist
> minDist
) break;
489 if (idx
>= 0) lastx
= points
[idx
].xval
;
490 // Check that you can really highlight the last day's data
491 if (canvasx
> points
[points
.length
-1].canvasx
)
492 lastx
= points
[points
.length
-1].xval
;
494 // Extract the points we've selected
496 for (var i
= 0; i
< points
.length
; i
++) {
497 if (points
[i
].xval
== lastx
) {
498 selPoints
.push(points
[i
]);
502 // Clear the previously drawn vertical, if there is one
503 var circleSize
= this.attrs_
.highlightCircleSize
;
504 var ctx
= this.canvas_
.getContext("2d");
505 if (this.previousVerticalX_
>= 0) {
506 var px
= this.previousVerticalX_
;
507 ctx
.clearRect(px
- circleSize
- 1, 0, 2 * circleSize
+ 2, this.height_
);
510 if (selPoints
.length
> 0) {
511 var canvasx
= selPoints
[0].canvasx
;
513 // Set the status message to indicate the selected point(s)
514 var replace
= this.xValueFormatter_(lastx
) + ":";
515 var clen
= this.colors_
.length
;
516 for (var i
= 0; i
< selPoints
.length
; i
++) {
517 if (this.labelsSeparateLines
) {
520 var point
= selPoints
[i
];
521 replace
+= " <b><font color='" + this.colors_
[i
%clen
].toHexString() + "'>"
522 + point
.name
+ "</font></b>:"
523 + this.round_(point
.yval
, 2);
525 this.labelsDiv_
.innerHTML
= replace
;
527 // Save last x position for callbacks.
530 // Draw colored circles over the center of each selected point
532 for (var i
= 0; i
< selPoints
.length
; i
++) {
534 ctx
.fillStyle
= this.colors_
[i
%clen
].toRGBString();
535 ctx
.arc(canvasx
, selPoints
[i
%clen
].canvasy
, circleSize
, 0, 360, false);
540 this.previousVerticalX_
= canvasx
;
545 * The mouse has left the canvas. Clear out whatever artifacts remain
546 * @param {Object} event the mouseout event from the browser.
549 DateGraph
.prototype.mouseOut_
= function(event
) {
550 // Get rid of the overlay data
551 var ctx
= this.canvas_
.getContext("2d");
552 ctx
.clearRect(0, 0, this.width_
, this.height_
);
553 this.labelsDiv_
.innerHTML
= "";
556 DateGraph
.zeropad
= function(x
) {
557 if (x
< 10) return "0" + x
; else return "" + x
;
561 * Return a string version of the hours, minutes and seconds portion of a date.
562 * @param {Number} date The JavaScript date (ms since epoch)
563 * @return {String} A time of the form "HH:MM:SS"
566 DateGraph
.prototype.hmsString_
= function(date
) {
567 var zeropad
= DateGraph
.zeropad
;
568 var d
= new Date(date
);
569 if (d
.getSeconds()) {
570 return zeropad(d
.getHours()) + ":" +
571 zeropad(d
.getMinutes()) + ":" +
572 zeropad(d
.getSeconds());
573 } else if (d
.getMinutes()) {
574 return zeropad(d
.getHours()) + ":" + zeropad(d
.getMinutes());
576 return zeropad(d
.getHours());
581 * Convert a JS date (millis since epoch) to YYYY/MM/DD
582 * @param {Number} date The JavaScript date (ms since epoch)
583 * @return {String} A date of the form "YYYY/MM/DD"
586 DateGraph
.prototype.dateString_
= function(date
) {
587 var zeropad
= DateGraph
.zeropad
;
588 var d
= new Date(date
);
591 var year
= "" + d
.getFullYear();
592 // Get a 0 padded month string
593 var month
= zeropad(d
.getMonth() + 1); //months are 0-offset, sigh
594 // Get a 0 padded day string
595 var day
= zeropad(d
.getDate());
598 var frac
= d
.getHours() * 3600 + d
.getMinutes() * 60 + d
.getSeconds();
599 if (frac
) ret
= " " + this.hmsString_(date
);
601 return year
+ "/" + month + "/" + day
+ ret
;
605 * Round a number to the specified number of digits past the decimal point.
606 * @param {Number} num The number to round
607 * @param {Number} places The number of decimals to which to round
608 * @return {Number} The rounded number
611 DateGraph
.prototype.round_
= function(num
, places
) {
612 var shift
= Math
.pow(10, places
);
613 return Math
.round(num
* shift
)/shift
;
617 * Fires when there's data available to be graphed.
618 * @param {String} data Raw CSV data to be plotted
621 DateGraph
.prototype.loadedEvent_
= function(data
) {
622 this.rawData_
= this.parseCSV_(data
);
623 this.drawGraph_(this.rawData_
);
626 DateGraph
.prototype.months
= ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
627 "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
628 DateGraph
.prototype.quarters
= ["Jan", "Apr", "Jul", "Oct"];
631 * Add ticks on the x-axis representing years, months, quarters, weeks, or days
634 DateGraph
.prototype.addXTicks_
= function() {
635 // Determine the correct ticks scale on the x-axis: quarterly, monthly, ...
636 var startDate
, endDate
;
637 if (this.dateWindow_
) {
638 startDate
= this.dateWindow_
[0];
639 endDate
= this.dateWindow_
[1];
641 startDate
= this.rawData_
[0][0];
642 endDate
= this.rawData_
[this.rawData_
.length
- 1][0];
645 var xTicks
= this.xTicker_(startDate
, endDate
);
646 this.layout_
.updateOptions({xTicks
: xTicks
});
649 // Time granularity enumeration
650 DateGraph
.SECONDLY
= 0;
651 DateGraph
.TEN_SECONDLY
= 1;
652 DateGraph
.THIRTY_SECONDLY
= 2;
653 DateGraph
.MINUTELY
= 3;
654 DateGraph
.TEN_MINUTELY
= 4;
655 DateGraph
.THIRTY_MINUTELY
= 5;
656 DateGraph
.HOURLY
= 6;
657 DateGraph
.SIX_HOURLY
= 7;
659 DateGraph
.WEEKLY
= 9;
660 DateGraph
.MONTHLY
= 10;
661 DateGraph
.QUARTERLY
= 11;
662 DateGraph
.BIANNUAL
= 12;
663 DateGraph
.ANNUAL
= 13;
664 DateGraph
.DECADAL
= 14;
665 DateGraph
.NUM_GRANULARITIES
= 15;
667 DateGraph
.SHORT_SPACINGS
= [];
668 DateGraph
.SHORT_SPACINGS
[DateGraph
.SECONDLY
] = 1000 * 1;
669 DateGraph
.SHORT_SPACINGS
[DateGraph
.TEN_SECONDLY
] = 1000 * 10;
670 DateGraph
.SHORT_SPACINGS
[DateGraph
.THIRTY_SECONDLY
] = 1000 * 30;
671 DateGraph
.SHORT_SPACINGS
[DateGraph
.MINUTELY
] = 1000 * 60;
672 DateGraph
.SHORT_SPACINGS
[DateGraph
.TEN_MINUTELY
] = 1000 * 60 * 10;
673 DateGraph
.SHORT_SPACINGS
[DateGraph
.THIRTY_MINUTELY
] = 1000 * 60 * 30;
674 DateGraph
.SHORT_SPACINGS
[DateGraph
.HOURLY
] = 1000 * 3600;
675 DateGraph
.SHORT_SPACINGS
[DateGraph
.HOURLY
] = 1000 * 3600 * 6;
676 DateGraph
.SHORT_SPACINGS
[DateGraph
.DAILY
] = 1000 * 86400;
677 DateGraph
.SHORT_SPACINGS
[DateGraph
.WEEKLY
] = 1000 * 604800;
681 // If we used this time granularity, how many ticks would there be?
682 // This is only an approximation, but it's generally good enough.
684 DateGraph
.prototype.NumXTicks
= function(start_time
, end_time
, granularity
) {
685 if (granularity
< DateGraph
.MONTHLY
) {
686 // Generate one tick mark for every fixed interval of time.
687 var spacing
= DateGraph
.SHORT_SPACINGS
[granularity
];
688 return Math
.floor(0.5 + 1.0 * (end_time
- start_time
) / spacing
);
690 var year_mod
= 1; // e.g. to only print one point every 10 years.
692 if (granularity
== DateGraph
.QUARTERLY
) num_months
= 3;
693 if (granularity
== DateGraph
.BIANNUAL
) num_months
= 2;
694 if (granularity
== DateGraph
.ANNUAL
) num_months
= 1;
695 if (granularity
== DateGraph
.DECADAL
) { num_months
= 1; year_mod
= 10; }
697 var msInYear
= 365.2524 * 24 * 3600 * 1000;
698 var num_years
= 1.0 * (end_time
- start_time
) / msInYear
;
699 return Math
.floor(0.5 + 1.0 * num_years
* num_months
/ year_mod
);
705 // Construct an x-axis of nicely-formatted times on meaningful boundaries
706 // (e.g. 'Jan 09' rather than 'Jan 22, 2009').
708 // Returns an array containing {v: millis, label: label} dictionaries.
710 DateGraph
.prototype.GetXAxis
= function(start_time
, end_time
, granularity
) {
712 if (granularity
< DateGraph
.MONTHLY
) {
713 // Generate one tick mark for every fixed interval of time.
714 var spacing
= DateGraph
.SHORT_SPACINGS
[granularity
];
715 var format
= '%d%b'; // e.g. "1 Jan"
716 // TODO(danvk): be smarter about making sure this really hits a "nice" time.
717 if (granularity
< DateGraph
.HOURLY
) {
718 start_time
= spacing
* Math
.floor(0.5 + start_time
/ spacing
);
720 for (var t
= start_time
; t
<= end_time
; t
+= spacing
) {
722 var frac
= d
.getHours() * 3600 + d
.getMinutes() * 60 + d
.getSeconds();
723 if (frac
== 0 || granularity
>= DateGraph
.DAILY
) {
724 // the extra hour covers DST problems.
725 ticks
.push({ v
:t
, label
: new Date(t
+ 3600*1000).strftime(format
) });
727 ticks
.push({ v
:t
, label
: this.hmsString_(t
) });
731 // Display a tick mark on the first of a set of months of each year.
732 // Years get a tick mark iff y % year_mod == 0. This is useful for
733 // displaying a tick mark once every 10 years, say, on long time scales.
735 var year_mod
= 1; // e.g. to only print one point every 10 years.
737 if (granularity
== DateGraph
.MONTHLY
) {
738 months
= [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ];
739 } else if (granularity
== DateGraph
.QUARTERLY
) {
740 months
= [ 0, 3, 6, 9 ];
741 } else if (granularity
== DateGraph
.BIANNUAL
) {
743 } else if (granularity
== DateGraph
.ANNUAL
) {
745 } else if (granularity
== DateGraph
.DECADAL
) {
750 var start_year
= new Date(start_time
).getFullYear();
751 var end_year
= new Date(end_time
).getFullYear();
752 var zeropad
= DateGraph
.zeropad
;
753 for (var i
= start_year
; i
<= end_year
; i
++) {
754 if (i
% year_mod
!= 0) continue;
755 for (var j
= 0; j
< months
.length
; j
++) {
756 var date_str
= i
+ "/" + zeropad(1 + months[j]) + "/01";
757 var t
= Date
.parse(date_str
);
758 if (t
< start_time
|| t
> end_time
) continue;
759 ticks
.push({ v
:t
, label
: new Date(t
).strftime('%b %y') });
769 * Add ticks to the x-axis based on a date range.
770 * @param {Number} startDate Start of the date window (millis since epoch)
771 * @param {Number} endDate End of the date window (millis since epoch)
772 * @return {Array.<Object>} Array of {label, value} tuples.
775 DateGraph
.prototype.dateTicker
= function(startDate
, endDate
) {
777 for (var i
= 0; i
< DateGraph
.NUM_GRANULARITIES
; i
++) {
778 var num_ticks
= this.NumXTicks(startDate
, endDate
, i
);
779 if (this.width_
/ num_ticks
>= this.attrs_
.pixelsPerXLabel
) {
786 return this.GetXAxis(startDate
, endDate
, chosen
);
788 // TODO(danvk): signal error.
793 * Add ticks when the x axis has numbers on it (instead of dates)
794 * @param {Number} startDate Start of the date window (millis since epoch)
795 * @param {Number} endDate End of the date window (millis since epoch)
796 * @return {Array.<Object>} Array of {label, value} tuples.
799 DateGraph
.prototype.numericTicks
= function(minV
, maxV
) {
801 // Try labels every 1, 2, 5, 10, 20, 50, 100, etc.
802 // Calculate the resulting tick spacing (i.e. this.height_ / nTicks
).
803 // The first spacing greater than this.attrs_.pixelsPerYLabel is what we use.
804 var mults
= [1, 2, 5];
805 var scale
, low_val
, high_val
, nTicks
;
806 for (var i
= -10; i
< 50; i
++) {
807 var base_scale
= Math
.pow(10, i
);
808 for (var j
= 0; j
< mults
.length
; j
++) {
809 scale
= base_scale
* mults
[j
];
810 low_val
= Math
.floor(minV
/ scale
) * scale
;
811 high_val
= Math
.ceil(maxV
/ scale
) * scale
;
812 nTicks
= (high_val
- low_val
) / scale
;
813 var spacing
= this.height_
/ nTicks
;
814 // wish I could break out of both loops at once...
815 if (spacing
> this.attrs_
.pixelsPerYLabel
) break;
817 if (spacing
> this.attrs_
.pixelsPerYLabel
) break;
820 // Construct labels for the ticks
822 for (var i
= 0; i
< nTicks
; i
++) {
823 var tickV
= low_val
+ i
* scale
;
824 var label
= this.round_(tickV
, 2);
825 if (this.labelsKMB_
) {
827 if (tickV
>= k
*k
*k
) {
828 label
= this.round_(tickV
/(k
*k
*k
), 1) + "B";
829 } else if (tickV
>= k
*k
) {
830 label
= this.round_(tickV
/(k
*k
), 1) + "M";
831 } else if (tickV
>= k
) {
832 label
= this.round_(tickV
/k
, 1) + "K";
835 ticks
.push( {label
: label
, v
: tickV
} );
841 * Adds appropriate ticks on the y-axis
842 * @param {Number} minY The minimum Y value in the data set
843 * @param {Number} maxY The maximum Y value in the data set
846 DateGraph
.prototype.addYTicks_
= function(minY
, maxY
) {
847 // Set the number of ticks so that the labels are human-friendly.
848 var ticks
= this.numericTicks(minY
, maxY
);
849 this.layout_
.updateOptions( { yAxis
: [minY
, maxY
],
854 * Update the graph with new data. Data is in the format
855 * [ [date1, val1, val2, ...], [date2, val1, val2, ...] if errorBars=false
856 * or, if errorBars=true,
857 * [ [date1, [val1,stddev1], [val2,stddev2], ...], [date2, ...], ...]
858 * @param {Array.<Object>} data The data (see above)
861 DateGraph
.prototype.drawGraph_
= function(data
) {
863 this.layout_
.removeAllDatasets();
864 // Loop over all fields in the dataset
865 for (var i
= 1; i
< data
[0].length
; i
++) {
867 for (var j
= 0; j
< data
.length
; j
++) {
868 var date
= data
[j
][0];
869 series
[j
] = [date
, data
[j
][i
]];
871 series
= this.rollingAverage(series
, this.rollPeriod_
);
873 // Prune down to the desired range, if necessary (for zooming)
874 var bars
= this.errorBars_
|| this.customBars_
;
875 if (this.dateWindow_
) {
876 var low
= this.dateWindow_
[0];
877 var high
= this.dateWindow_
[1];
879 for (var k
= 0; k
< series
.length
; k
++) {
880 if (series
[k
][0] >= low
&& series
[k
][0] <= high
) {
881 pruned
.push(series
[k
]);
882 var y
= bars
? series
[k
][1][0] : series
[k
][1];
883 if (maxY
== null || y
> maxY
) maxY
= y
;
888 for (var j
= 0; j
< series
.length
; j
++) {
889 var y
= bars
? series
[j
][1][0] : series
[j
][1];
890 if (maxY
== null || y
> maxY
) {
891 maxY
= bars
? y
+ series
[j
][1][1] : y
;
898 for (var j
=0; j
<series
.length
; j
++)
899 vals
[j
] = [series
[j
][0],
900 series
[j
][1][0], series
[j
][1][1], series
[j
][1][2]];
901 this.layout_
.addDataset(this.labels_
[i
- 1], vals
);
903 this.layout_
.addDataset(this.labels_
[i
- 1], series
);
907 // Use some heuristics to come up with a good maxY value, unless it's been
908 // set explicitly by the user.
909 if (this.valueRange_
!= null) {
910 this.addYTicks_(this.valueRange_
[0], this.valueRange_
[1]);
912 // Add some padding and round up to an integer to be human-friendly.
914 if (maxY
<= 0.0) maxY
= 1.0;
915 this.addYTicks_(0, maxY
);
920 // Tell PlotKit to use this new data and render itself
921 this.layout_
.evaluateWithError();
922 this.plotter_
.clear();
923 this.plotter_
.render();
924 this.canvas_
.getContext('2d').clearRect(0, 0,
925 this.canvas_
.width
, this.canvas_
.height
);
929 * Calculates the rolling average of a data set.
930 * If originalData is [label, val], rolls the average of those.
931 * If originalData is [label, [, it's interpreted as [value, stddev]
932 * and the roll is returned in the same form, with appropriately reduced
933 * stddev for each value.
934 * Note that this is where fractional input (i.e. '5/10') is converted into
936 * @param {Array} originalData The data in the appropriate format (see above)
937 * @param {Number} rollPeriod The number of days over which to average the data
939 DateGraph
.prototype.rollingAverage
= function(originalData
, rollPeriod
) {
940 if (originalData
.length
< 2)
942 var rollPeriod
= Math
.min(rollPeriod
, originalData
.length
- 1);
943 var rollingData
= [];
944 var sigma
= this.sigma_
;
946 if (this.fractions_
) {
948 var den
= 0; // numerator/denominator
950 for (var i
= 0; i
< originalData
.length
; i
++) {
951 num
+= originalData
[i
][1][0];
952 den
+= originalData
[i
][1][1];
953 if (i
- rollPeriod
>= 0) {
954 num
-= originalData
[i
- rollPeriod
][1][0];
955 den
-= originalData
[i
- rollPeriod
][1][1];
958 var date
= originalData
[i
][0];
959 var value
= den
? num
/ den
: 0.0;
960 if (this.errorBars_
) {
961 if (this.wilsonInterval_
) {
962 // For more details on this confidence interval, see:
963 // http://en.wikipedia.org/wiki
/Binomial_confidence_interval
965 var p
= value
< 0 ? 0 : value
, n
= den
;
966 var pm
= sigma
* Math
.sqrt(p
*(1-p
)/n + sigma*sigma/(4*n
*n
));
967 var denom
= 1 + sigma
* sigma
/ den
;
968 var low
= (p
+ sigma
* sigma
/ (2 * den) - pm) / denom
;
969 var high
= (p
+ sigma
* sigma
/ (2 * den) + pm) / denom
;
970 rollingData
[i
] = [date
,
971 [p
* mult
, (p
- low
) * mult
, (high
- p
) * mult
]];
973 rollingData
[i
] = [date
, [0, 0, 0]];
976 var stddev
= den
? sigma
* Math
.sqrt(value
* (1 - value
) / den
) : 1.0;
977 rollingData
[i
] = [date
, [mult
* value
, mult
* stddev
, mult
* stddev
]];
980 rollingData
[i
] = [date
, mult
* value
];
983 } else if (this.customBars_
) {
988 for (var i
= 0; i
< originalData
.length
; i
++) {
989 var data
= originalData
[i
][1];
991 rollingData
[i
] = [originalData
[i
][0], [y
, y
- data
[0], data
[2] - y
]];
997 if (i
- rollPeriod
>= 0) {
998 var prev
= originalData
[i
- rollPeriod
];
1004 rollingData
[i
] = [originalData
[i
][0], [ 1.0 * mid
/ count
,
1005 1.0 * (mid
- low
) / count
,
1006 1.0 * (high
- mid
) / count
]];
1009 // Calculate the rolling average for the first rollPeriod - 1 points where
1010 // there is not enough data to roll over the full number of days
1011 var num_init_points
= Math
.min(rollPeriod
- 1, originalData
.length
- 2);
1012 if (!this.errorBars_
){
1013 for (var i
= 0; i
< num_init_points
; i
++) {
1015 for (var j
= 0; j
< i
+ 1; j
++)
1016 sum
+= originalData
[j
][1];
1017 rollingData
[i
] = [originalData
[i
][0], sum
/ (i
+ 1)];
1019 // Calculate the rolling average for the remaining points
1020 for (var i
= Math
.min(rollPeriod
- 1, originalData
.length
- 2);
1021 i
< originalData
.length
;
1024 for (var j
= i
- rollPeriod
+ 1; j
< i
+ 1; j
++)
1025 sum
+= originalData
[j
][1];
1026 rollingData
[i
] = [originalData
[i
][0], sum
/ rollPeriod
];
1029 for (var i
= 0; i
< num_init_points
; i
++) {
1032 for (var j
= 0; 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
)/(i
+1);
1037 rollingData
[i
] = [originalData
[i
][0],
1038 [sum
/(i
+1), sigma
* stddev
, sigma
* stddev
]];
1040 // Calculate the rolling average for the remaining points
1041 for (var i
= Math
.min(rollPeriod
- 1, originalData
.length
- 2);
1042 i
< originalData
.length
;
1046 for (var j
= i
- rollPeriod
+ 1; j
< i
+ 1; j
++) {
1047 sum
+= originalData
[j
][1][0];
1048 variance
+= Math
.pow(originalData
[j
][1][1], 2);
1050 var stddev
= Math
.sqrt(variance
) / rollPeriod
;
1051 rollingData
[i
] = [originalData
[i
][0],
1052 [sum
/ rollPeriod
, sigma
* stddev
, sigma
* stddev
]];
1061 * Parses a date, returning the number of milliseconds since epoch. This can be
1062 * passed in as an xValueParser in the DateGraph constructor.
1063 * @param {String} A date in YYYYMMDD format.
1064 * @return {Number} Milliseconds since epoch.
1067 DateGraph
.prototype.dateParser
= function(dateStr
) {
1069 if (dateStr
.length
== 10 && dateStr
.search("-") != -1) { // e.g. '2009-07-12'
1070 dateStrSlashed
= dateStr
.replace("-", "/", "g");
1071 while (dateStrSlashed
.search("-") != -1) {
1072 dateStrSlashed
= dateStrSlashed
.replace("-", "/");
1074 return Date
.parse(dateStrSlashed
);
1075 } else if (dateStr
.length
== 8) { // e.g. '20090712'
1076 dateStrSlashed
= dateStr
.substr(0,4) + "/" + dateStr
.substr(4,2)
1077 + "/" + dateStr
.substr(6,2);
1078 return Date
.parse(dateStrSlashed
);
1080 // Any format that Date.parse will accept, e.g. "2009/07/12" or
1081 // "2009/07/12 12:34:56"
1082 return Date
.parse(dateStr
);
1087 * Parses a string in a special csv format. We expect a csv file where each
1088 * line is a date point, and the first field in each line is the date string.
1089 * We also expect that all remaining fields represent series.
1090 * if this.errorBars_ is set, then interpret the fields as:
1091 * date, series1, stddev1, series2, stddev2, ...
1092 * @param {Array.<Object>} data See above.
1095 DateGraph
.prototype.parseCSV_
= function(data
) {
1097 var lines
= data
.split("\n");
1098 var start
= this.labelsFromCSV_
? 1 : 0;
1099 if (this.labelsFromCSV_
) {
1100 var labels
= lines
[0].split(",");
1101 labels
.shift(); // a "date" parameter is assumed.
1102 this.labels_
= labels
;
1103 // regenerate automatic colors.
1104 this.setColors_(this.attrs_
);
1105 this.renderOptions_
.colorScheme
= this.colors_
;
1106 MochiKit
.Base
.update(this.plotter_
.options
, this.renderOptions_
);
1107 MochiKit
.Base
.update(this.layoutOptions_
, this.attrs_
);
1110 for (var i
= start
; i
< lines
.length
; i
++) {
1111 var line
= lines
[i
];
1112 if (line
.length
== 0) continue; // skip blank lines
1113 var inFields
= line
.split(',');
1114 if (inFields
.length
< 2)
1118 fields
[0] = this.xValueParser_(inFields
[0]);
1120 // If fractions are expected, parse the numbers as "A/B
"
1121 if (this.fractions_) {
1122 for (var j = 1; j < inFields.length; j++) {
1123 // TODO(danvk): figure out an appropriate way to flag parse errors.
1124 var vals = inFields[j].split("/");
1125 fields[j] = [parseFloat(vals[0]), parseFloat(vals[1])];
1127 } else if (this.errorBars_) {
1128 // If there are error bars, values are (value, stddev) pairs
1129 for (var j = 1; j < inFields.length; j += 2)
1130 fields[(j + 1) / 2] = [parseFloat(inFields[j]),
1131 parseFloat(inFields[j + 1])];
1132 } else if (this.customBars_) {
1133 // Bars are a low;center;high tuple
1134 for (var j = 1; j < inFields.length; j++) {
1135 var vals = inFields[j].split(";");
1136 fields[j] = [ parseFloat(vals[0]),
1137 parseFloat(vals[1]),
1138 parseFloat(vals[2]) ];
1141 // Values are just numbers
1142 for (var j = 1; j < inFields.length; j++)
1143 fields[j] = parseFloat(inFields[j]);
1151 * Parses a DataTable object from gviz.
1152 * The data is expected to have a first column that is either a date or a
1153 * number. All subsequent columns must be numbers. If there is a clear mismatch
1154 * between this.xValueParser_ and the type of the first column, it will be
1155 * fixed. Returned value is in the same format as return value of parseCSV_.
1156 * @param {Array.<Object>} data See above.
1159 DateGraph.prototype.parseDataTable_ = function(data) {
1160 var cols = data.getNumberOfColumns();
1161 var rows = data.getNumberOfRows();
1163 // Read column labels
1165 for (var i = 0; i < cols; i++) {
1166 labels.push(data.getColumnLabel(i));
1168 labels.shift(); // the x-axis parameter is assumed and unnamed.
1169 this.labels_ = labels;
1170 // regenerate automatic colors.
1171 this.setColors_(this.attrs_);
1172 this.renderOptions_.colorScheme = this.colors_;
1173 MochiKit.Base.update(this.plotter_.options, this.renderOptions_);
1174 MochiKit.Base.update(this.layoutOptions_, this.attrs_);
1176 var indepType = data.getColumnType(0);
1177 if (indepType != 'date' && indepType != 'number') {
1178 // TODO(danvk): standardize error reporting.
1179 alert("only
'date' and
'number' types are supported
for column
1" +
1180 "of DataTable
input (Got
'" + indepType + "')");
1185 for (var i = 0; i < rows; i++) {
1187 if (indepType == 'date') {
1188 row.push(data.getValue(i, 0).getTime());
1190 row.push(data.getValue(i, 0));
1192 for (var j = 1; j < cols; j++) {
1193 row.push(data.getValue(i, j));
1201 * Get the CSV data. If it's in a function, call that function. If it's in a
1202 * file, do an XMLHttpRequest to get it.
1205 DateGraph.prototype.start_ = function() {
1206 if (typeof this.file_ == 'function') {
1207 // Stubbed out to allow this to run off a filesystem
1208 this.loadedEvent_(this.file_());
1209 } else if (typeof this.file_ == 'object' &&
1210 typeof this.file_.getColumnRange == 'function') {
1211 // must be a DataTable from gviz.
1212 this.rawData_ = this.parseDataTable_(this.file_);
1213 this.drawGraph_(this.rawData_);
1215 var req = new XMLHttpRequest();
1217 req.onreadystatechange = function () {
1218 if (req.readyState == 4) {
1219 if (req.status == 200) {
1220 caller.loadedEvent_(req.responseText);
1225 req.open("GET
", this.file_, true);
1231 * Changes various properties of the graph. These can include:
1233 * <li>file: changes the source data for the graph</li>
1234 * <li>errorBars: changes whether the data contains stddev</li>
1236 * @param {Object} attrs The new properties and values
1238 DateGraph.prototype.updateOptions = function(attrs) {
1239 if (attrs.errorBars) {
1240 this.errorBars_ = attrs.errorBars;
1242 if (attrs.customBars) {
1243 this.customBars_ = attrs.customBars;
1245 if (attrs.strokeWidth) {
1246 this.strokeWidth_ = attrs.strokeWidth;
1248 if (attrs.rollPeriod) {
1249 this.rollPeriod_ = attrs.rollPeriod;
1251 if (attrs.dateWindow) {
1252 this.dateWindow_ = attrs.dateWindow;
1254 if (attrs.valueRange) {
1255 this.valueRange_ = attrs.valueRange;
1257 MochiKit.Base.update(this.attrs_, attrs);
1258 if (typeof(attrs.labels) != 'undefined') {
1259 this.labels_ = attrs.labels;
1260 this.labelsFromCSV_ = (attrs.labels == null);
1262 this.layout_.updateOptions({ 'errorBars': this.errorBars_ });
1263 if (attrs['file'] && attrs['file'] != this.file_) {
1264 this.file_ = attrs['file'];
1267 this.drawGraph_(this.rawData_);
1272 * Adjusts the number of days in the rolling average. Updates the graph to
1273 * reflect the new averaging period.
1274 * @param {Number} length Number of days over which to average the data.
1276 DateGraph.prototype.adjustRoll = function(length) {
1277 this.rollPeriod_ = length;
1278 this.drawGraph_(this.rawData_);
1283 * A wrapper around DateGraph that implements the gviz API.
1284 * @param {Object} container The DOM object the visualization should live in.
1286 DateGraph.GVizChart = function(container) {
1287 this.container = container;
1290 DateGraph.GVizChart.prototype.draw = function(data, options) {
1291 this.container.innerHTML = '';
1292 this.date_graph = new DateGraph(this.container, data, null, options || {});