blob: 60c6624c91eff4dbc92e45f32016179780558c94 [file] [log] [blame]
ramangrover29a04696c2013-07-31 21:50:38 -07001
2;(function(exports) {
3
4 var Util = {
5 extend: function() {
6 arguments[0] = arguments[0] || {};
7 for (var i = 1; i < arguments.length; i++)
8 {
9 for (var key in arguments[i])
10 {
11 if (arguments[i].hasOwnProperty(key))
12 {
13 if (typeof(arguments[i][key]) === 'object') {
14 if (arguments[i][key] instanceof Array) {
15 arguments[0][key] = arguments[i][key];
16 } else {
17 arguments[0][key] = Util.extend(arguments[0][key], arguments[i][key]);
18 }
19 } else {
20 arguments[0][key] = arguments[i][key];
21 }
22 }
23 }
24 }
25 return arguments[0];
26 }
27 };
28
29 /**
30 * Initialises a new <code>TimeSeries</code> with optional data options.
31 *
32 * Options are of the form (defaults shown):
33 *
34 * <pre>
35 * {
ramangrover29c996e282013-08-03 14:36:45 -070036 * resetBounds: true,
37 * resetBoundsInterval: 3000
ramangrover29a04696c2013-07-31 21:50:38 -070038 * }
39 * </pre>
40 *
41 * Presentation options for TimeSeries are specified as an argument to <code>SmoothieChart.addTimeSeries</code>.
42 *
43 * @constructor
44 */
45 function TimeSeries(options) {
46 this.options = Util.extend({}, TimeSeries.defaultOptions, options);
47 this.data = [];
ramangrover29c996e282013-08-03 14:36:45 -070048 this.maxValue = Number.NaN;
49 this.minValue = Number.NaN;
ramangrover29a04696c2013-07-31 21:50:38 -070050 }
51
52 TimeSeries.defaultOptions = {
53 resetBoundsInterval: 3000,
ramangrover2975b73552013-08-06 10:31:04 -070054 resetBounds: false
ramangrover29a04696c2013-07-31 21:50:38 -070055 };
56
57 /**
58 * Recalculate the min/max values for this <code>TimeSeries</code> object.
59 *
60 * This causes the graph to scale itself in the y-axis.
61 */
62 TimeSeries.prototype.resetBounds = function() {
63 if (this.data.length) {
ramangrover29c996e282013-08-03 14:36:45 -070064
ramangrover29a04696c2013-07-31 21:50:38 -070065 this.maxValue = this.data[0][1];
66 this.minValue = this.data[0][1];
67 for (var i = 1; i < this.data.length; i++) {
68 var value = this.data[i][1];
69 if (value > this.maxValue) {
70 this.maxValue = value;
71 }
72 if (value < this.minValue) {
73 this.minValue = value;
74 }
75 }
76 } else {
ramangrover29c996e282013-08-03 14:36:45 -070077
ramangrover29a04696c2013-07-31 21:50:38 -070078 this.maxValue = Number.NaN;
79 this.minValue = Number.NaN;
80 }
81 };
82
83 /**
84 * Adds a new data point to the <code>TimeSeries</code>, preserving chronological order.
85 *
86 * @param timestamp the position, in time, of this data point
87 * @param value the value of this data point
88 * @param sumRepeatedTimeStampValues if <code>timestamp</code> has an exact match in the series, this flag controls
89 * whether it is replaced, or the values summed (defaults to false.)
90 */
91 TimeSeries.prototype.append = function(timestamp, value, sumRepeatedTimeStampValues) {
ramangrover29c996e282013-08-03 14:36:45 -070092
ramangrover29a04696c2013-07-31 21:50:38 -070093 var i = this.data.length - 1;
94 while (i > 0 && this.data[i][0] > timestamp) {
95 i--;
96 }
97
98 if (this.data.length > 0 && this.data[i][0] === timestamp) {
ramangrover29c996e282013-08-03 14:36:45 -070099
ramangrover29a04696c2013-07-31 21:50:38 -0700100 if (sumRepeatedTimeStampValues) {
ramangrover29c996e282013-08-03 14:36:45 -0700101
ramangrover29a04696c2013-07-31 21:50:38 -0700102 this.data[i][1] += value;
103 value = this.data[i][1];
104 } else {
ramangrover29c996e282013-08-03 14:36:45 -0700105
ramangrover29a04696c2013-07-31 21:50:38 -0700106 this.data[i][1] = value;
107 }
108 } else if (i < this.data.length - 1) {
ramangrover29c996e282013-08-03 14:36:45 -0700109
ramangrover29a04696c2013-07-31 21:50:38 -0700110 this.data.splice(i + 1, 0, [timestamp, value]);
111 } else {
ramangrover29c996e282013-08-03 14:36:45 -0700112
ramangrover29a04696c2013-07-31 21:50:38 -0700113 this.data.push([timestamp, value]);
114 }
115
116 this.maxValue = isNaN(this.maxValue) ? value : Math.max(this.maxValue, value);
117 this.minValue = isNaN(this.minValue) ? value : Math.min(this.minValue, value);
118 };
119
120 TimeSeries.prototype.dropOldData = function(oldestValidTime, maxDataSetLength) {
ramangrover29c996e282013-08-03 14:36:45 -0700121
122
ramangrover29a04696c2013-07-31 21:50:38 -0700123 var removeCount = 0;
124 while (this.data.length - removeCount >= maxDataSetLength && this.data[removeCount + 1][0] < oldestValidTime) {
125 removeCount++;
126 }
127 if (removeCount !== 0) {
128 this.data.splice(0, removeCount);
129 }
130 };
131
132 /**
133 * Initialises a new <code>SmoothieChart</code>.
134 *
135 * Options are optional, and should be of the form below. Just specify the values you
136 * need and the rest will be given sensible defaults as shown:
137 *
138 * <pre>
139 * {
ramangrover29c996e282013-08-03 14:36:45 -0700140 * minValue: undefined,
141 * maxValue: undefined,
142 * maxValueScale: 1,
143 * yRangeFunction: undefined,
144 * scaleSmoothing: 0.125,
145 * millisPerPixel: 20,
ramangrover29a04696c2013-07-31 21:50:38 -0700146 * maxDataSetLength: 2,
ramangrover29c996e282013-08-03 14:36:45 -0700147 * interpolation: 'bezier'
148 * timestampFormatter: null,
149 * horizontalLines: [],
ramangrover29a04696c2013-07-31 21:50:38 -0700150 * grid:
151 * {
ramangrover29c996e282013-08-03 14:36:45 -0700152 * fillStyle: '#000000',
153 * lineWidth: 1,
154 * strokeStyle: '#777777',
155 * millisPerLine: 1000,
156 * sharpLines: false,
157 * verticalSections: 2,
158 * borderVisible: true
ramangrover29a04696c2013-07-31 21:50:38 -0700159 * },
160 * labels
161 * {
ramangrover29c996e282013-08-03 14:36:45 -0700162 * disabled: false,
163 * fillStyle: '#ffffff',
ramangrover29a04696c2013-07-31 21:50:38 -0700164 * fontSize: 15,
165 * fontFamily: 'sans-serif',
166 * precision: 2
167 * },
168 * }
169 * </pre>
170 *
171 * @constructor
172 */
173 function SmoothieChart(options) {
174 this.options = Util.extend({}, SmoothieChart.defaultChartOptions, options);
175 this.seriesSet = [];
176 this.currentValueRange = 1;
177 this.currentVisMinValue = 0;
178 }
179
180 SmoothieChart.defaultChartOptions = {
181 millisPerPixel: 20,
182 maxValueScale: 1,
183 interpolation: 'bezier',
184 scaleSmoothing: 0.125,
185 maxDataSetLength: 2,
186 grid: {
187 fillStyle: '#000000',
188 strokeStyle: '#777777',
189 lineWidth: 1,
190 sharpLines: false,
191 millisPerLine: 1000,
192 verticalSections: 2,
193 borderVisible: true
194 },
195 labels: {
196 fillStyle: '#ffffff',
197 disabled: false,
198 fontSize: 10,
199 fontFamily: 'monospace',
200 precision: 2
201 },
202 horizontalLines: []
203 };
204
ramangrover29a04696c2013-07-31 21:50:38 -0700205 SmoothieChart.AnimateCompatibility = (function() {
ramangrover29a04696c2013-07-31 21:50:38 -0700206 var lastTime = 0,
207 requestAnimationFrame = function(callback, element) {
208 var requestAnimationFrame =
209 window.requestAnimationFrame ||
210 window.webkitRequestAnimationFrame ||
211 window.mozRequestAnimationFrame ||
212 window.oRequestAnimationFrame ||
213 window.msRequestAnimationFrame ||
214 function(callback) {
215 var currTime = new Date().getTime(),
216 timeToCall = Math.max(0, 16 - (currTime - lastTime)),
217 id = window.setTimeout(function() {
218 callback(currTime + timeToCall);
219 }, timeToCall);
220 lastTime = currTime + timeToCall;
221 return id;
222 };
223 return requestAnimationFrame.call(window, callback, element);
224 },
225 cancelAnimationFrame = function(id) {
226 var cancelAnimationFrame =
227 window.cancelAnimationFrame ||
228 function(id) {
229 clearTimeout(id);
230 };
231 return cancelAnimationFrame.call(window, id);
232 };
233
234 return {
235 requestAnimationFrame: requestAnimationFrame,
236 cancelAnimationFrame: cancelAnimationFrame
237 };
238 })();
239
240 SmoothieChart.defaultSeriesPresentationOptions = {
241 lineWidth: 1,
242 strokeStyle: '#ffffff'
243 };
244
245 /**
246 * Adds a <code>TimeSeries</code> to this chart, with optional presentation options.
247 *
248 * Presentation options should be of the form (defaults shown):
249 *
250 * <pre>
251 * {
252 * lineWidth: 1,
253 * strokeStyle: '#ffffff',
254 * fillStyle: undefined
255 * }
256 * </pre>
257 */
258 SmoothieChart.prototype.addTimeSeries = function(timeSeries, options) {
259 this.seriesSet.push({timeSeries: timeSeries, options: Util.extend({}, SmoothieChart.defaultSeriesPresentationOptions, options)});
260 if (timeSeries.options.resetBounds && timeSeries.options.resetBoundsInterval > 0) {
261 timeSeries.resetBoundsTimerId = setInterval(
262 function() {
263 timeSeries.resetBounds();
264 },
265 timeSeries.options.resetBoundsInterval
266 );
267 }
268 };
269
270 /**
271 * Removes the specified <code>TimeSeries</code> from the chart.
272 */
273 SmoothieChart.prototype.removeTimeSeries = function(timeSeries) {
ramangrover29a04696c2013-07-31 21:50:38 -0700274 var numSeries = this.seriesSet.length;
275 for (var i = 0; i < numSeries; i++) {
276 if (this.seriesSet[i].timeSeries === timeSeries) {
277 this.seriesSet.splice(i, 1);
278 break;
279 }
280 }
ramangrover29a04696c2013-07-31 21:50:38 -0700281 if (timeSeries.resetBoundsTimerId) {
ramangrover29a04696c2013-07-31 21:50:38 -0700282 clearInterval(timeSeries.resetBoundsTimerId);
283 }
284 };
285
286 /**
287 * Instructs the <code>SmoothieChart</code> to start rendering to the provided canvas, with specified delay.
288 *
289 * @param canvas the target canvas element
290 * @param delayMillis an amount of time to wait before a data point is shown. This can prevent the end of the series
291 * from appearing on screen, with new values flashing into view, at the expense of some latency.
292 */
293 SmoothieChart.prototype.streamTo = function(canvas, delayMillis) {
294 this.canvas = canvas;
295 this.delay = delayMillis;
296 this.start();
297 };
298
299 /**
300 * Starts the animation of this chart.
301 */
302 SmoothieChart.prototype.start = function() {
303 if (this.frame) {
ramangrover29a04696c2013-07-31 21:50:38 -0700304 return;
305 }
306
ramangrover29a04696c2013-07-31 21:50:38 -0700307 var animate = function() {
308 this.frame = SmoothieChart.AnimateCompatibility.requestAnimationFrame(function() {
309 this.render();
310 animate();
311 }.bind(this));
312 }.bind(this);
313
314 animate();
315 };
316
317 /**
318 * Stops the animation of this chart.
319 */
320 SmoothieChart.prototype.stop = function() {
321 if (this.frame) {
322 SmoothieChart.AnimateCompatibility.cancelAnimationFrame(this.frame);
323 delete this.frame;
324 }
325 };
326
327 SmoothieChart.prototype.updateValueRange = function() {
ramangrover29a04696c2013-07-31 21:50:38 -0700328 var chartOptions = this.options,
329 chartMaxValue = Number.NaN,
330 chartMinValue = Number.NaN;
331
332 for (var d = 0; d < this.seriesSet.length; d++) {
ramangrover29a04696c2013-07-31 21:50:38 -0700333 var timeSeries = this.seriesSet[d].timeSeries;
334 if (!isNaN(timeSeries.maxValue)) {
335 chartMaxValue = !isNaN(chartMaxValue) ? Math.max(chartMaxValue, timeSeries.maxValue) : timeSeries.maxValue;
336 }
337
338 if (!isNaN(timeSeries.minValue)) {
339 chartMinValue = !isNaN(chartMinValue) ? Math.min(chartMinValue, timeSeries.minValue) : timeSeries.minValue;
340 }
341 }
342
ramangrover29a04696c2013-07-31 21:50:38 -0700343 if (chartOptions.maxValue != null) {
344 chartMaxValue = chartOptions.maxValue;
345 } else {
346 chartMaxValue *= chartOptions.maxValueScale;
347 }
348
ramangrover29a04696c2013-07-31 21:50:38 -0700349 if (chartOptions.minValue != null) {
350 chartMinValue = chartOptions.minValue;
351 }
352
ramangrover29a04696c2013-07-31 21:50:38 -0700353 if (this.options.yRangeFunction) {
354 var range = this.options.yRangeFunction({min: chartMinValue, max: chartMaxValue});
355 chartMinValue = range.min;
356 chartMaxValue = range.max;
357 }
358
359 if (!isNaN(chartMaxValue) && !isNaN(chartMinValue)) {
360 var targetValueRange = chartMaxValue - chartMinValue;
361 this.currentValueRange += chartOptions.scaleSmoothing * (targetValueRange - this.currentValueRange);
362 this.currentVisMinValue += chartOptions.scaleSmoothing * (chartMinValue - this.currentVisMinValue);
363 }
364
365 this.valueRange = { min: chartMinValue, max: chartMaxValue };
366 };
367
368 SmoothieChart.prototype.render = function(canvas, time) {
369 canvas = canvas || this.canvas;
370 time = time || new Date().getTime() - (this.delay || 0);
371
ramangrover29a04696c2013-07-31 21:50:38 -0700372
ramangrover29a04696c2013-07-31 21:50:38 -0700373 time -= time % this.options.millisPerPixel;
374
375 var context = canvas.getContext('2d'),
376 chartOptions = this.options,
377 dimensions = { top: 0, left: 0, width: canvas.clientWidth, height: canvas.clientHeight },
ramangrover29a04696c2013-07-31 21:50:38 -0700378 oldestValidTime = time - (dimensions.width * chartOptions.millisPerPixel),
379 valueToYPixel = function(value) {
380 var offset = value - this.currentVisMinValue;
381 return this.currentValueRange === 0
382 ? dimensions.height
383 : dimensions.height - (Math.round((offset / this.currentValueRange) * dimensions.height));
384 }.bind(this),
385 timeToXPixel = function(t) {
386 return Math.round(dimensions.width - ((time - t) / chartOptions.millisPerPixel));
387 };
388
389 this.updateValueRange();
390
391 context.font = chartOptions.labels.fontSize + 'px ' + chartOptions.labels.fontFamily;
392
ramangrover29a04696c2013-07-31 21:50:38 -0700393 context.save();
394
ramangrover29a04696c2013-07-31 21:50:38 -0700395 context.translate(dimensions.left, dimensions.top);
396
ramangrover29a04696c2013-07-31 21:50:38 -0700397 context.beginPath();
398 context.rect(0, 0, dimensions.width, dimensions.height);
399 context.clip();
400
ramangrover29a04696c2013-07-31 21:50:38 -0700401 context.save();
402 context.fillStyle = chartOptions.grid.fillStyle;
403 context.clearRect(0, 0, dimensions.width, dimensions.height);
404 context.fillRect(0, 0, dimensions.width, dimensions.height);
405 context.restore();
406
ramangrover29a04696c2013-07-31 21:50:38 -0700407 context.save();
408 context.lineWidth = chartOptions.grid.lineWidth;
409 context.strokeStyle = chartOptions.grid.strokeStyle;
ramangrover29a04696c2013-07-31 21:50:38 -0700410 if (chartOptions.grid.millisPerLine > 0) {
411 var textUntilX = dimensions.width - context.measureText(minValueString).width + 4;
412 for (var t = time - (time % chartOptions.grid.millisPerLine);
413 t >= oldestValidTime;
414 t -= chartOptions.grid.millisPerLine) {
415 var gx = timeToXPixel(t);
416 if (chartOptions.grid.sharpLines) {
417 gx -= 0.5;
418 }
419 context.beginPath();
420 context.moveTo(gx, 0);
421 context.lineTo(gx, dimensions.height);
422 context.stroke();
423 context.closePath();
424
ramangrover29a04696c2013-07-31 21:50:38 -0700425 if (chartOptions.timestampFormatter && gx < textUntilX) {
ramangrover29c996e282013-08-03 14:36:45 -0700426
ramangrover29a04696c2013-07-31 21:50:38 -0700427 var tx = new Date(t),
428 ts = chartOptions.timestampFormatter(tx),
429 tsWidth = context.measureText(ts).width;
430 textUntilX = gx - tsWidth - 2;
431 context.fillStyle = chartOptions.labels.fillStyle;
432 context.fillText(ts, gx - tsWidth, dimensions.height - 2);
433 }
434 }
435 }
436
ramangrover29a04696c2013-07-31 21:50:38 -0700437 for (var v = 1; v < chartOptions.grid.verticalSections; v++) {
438 var gy = Math.round(v * dimensions.height / chartOptions.grid.verticalSections);
439 if (chartOptions.grid.sharpLines) {
440 gy -= 0.5;
441 }
442 context.beginPath();
443 context.moveTo(0, gy);
444 context.lineTo(dimensions.width, gy);
445 context.stroke();
446 context.closePath();
447 }
ramangrover29a04696c2013-07-31 21:50:38 -0700448 if (chartOptions.grid.borderVisible) {
449 context.beginPath();
450 context.strokeRect(0, 0, dimensions.width, dimensions.height);
451 context.closePath();
452 }
453 context.restore();
454
ramangrover29a04696c2013-07-31 21:50:38 -0700455 if (chartOptions.horizontalLines && chartOptions.horizontalLines.length) {
456 for (var hl = 0; hl < chartOptions.horizontalLines.length; hl++) {
457 var line = chartOptions.horizontalLines[hl],
458 hly = Math.round(valueToYPixel(line.value)) - 0.5;
459 context.strokeStyle = line.color || '#ffffff';
460 context.lineWidth = line.lineWidth || 1;
461 context.beginPath();
462 context.moveTo(0, hly);
463 context.lineTo(dimensions.width, hly);
464 context.stroke();
465 context.closePath();
466 }
467 }
468
ramangrover29a04696c2013-07-31 21:50:38 -0700469 for (var d = 0; d < this.seriesSet.length; d++) {
470 context.save();
471 var timeSeries = this.seriesSet[d].timeSeries,
472 dataSet = timeSeries.data,
473 seriesOptions = this.seriesSet[d].options;
474
ramangrover29a04696c2013-07-31 21:50:38 -0700475 timeSeries.dropOldData(oldestValidTime, chartOptions.maxDataSetLength);
476
ramangrover29a04696c2013-07-31 21:50:38 -0700477 context.lineWidth = seriesOptions.lineWidth;
478 context.strokeStyle = seriesOptions.strokeStyle;
ramangrover29a04696c2013-07-31 21:50:38 -0700479 context.beginPath();
ramangrover29a04696c2013-07-31 21:50:38 -0700480 var firstX = 0, lastX = 0, lastY = 0;
481 for (var i = 0; i < dataSet.length && dataSet.length !== 1; i++) {
482 var x = timeToXPixel(dataSet[i][0]),
483 y = valueToYPixel(dataSet[i][1]);
484
485 if (i === 0) {
486 firstX = x;
487 context.moveTo(x, y);
488 } else {
489 switch (chartOptions.interpolation) {
490 case "linear":
491 case "line": {
492 context.lineTo(x,y);
493 break;
494 }
495 case "bezier":
496 default: {
ramangrover29c996e282013-08-03 14:36:45 -0700497
498
499
500
501
502
503
504
505
506 context.bezierCurveTo(
507 Math.round((lastX + x) / 2), lastY,
508 Math.round((lastX + x)) / 2, y,
509 x, y);
ramangrover29a04696c2013-07-31 21:50:38 -0700510 break;
511 }
512 }
513 }
514
515 lastX = x; lastY = y;
516 }
517
518 if (dataSet.length > 1) {
519 if (seriesOptions.fillStyle) {
ramangrover29c996e282013-08-03 14:36:45 -0700520
ramangrover29a04696c2013-07-31 21:50:38 -0700521 context.lineTo(dimensions.width + seriesOptions.lineWidth + 1, lastY);
522 context.lineTo(dimensions.width + seriesOptions.lineWidth + 1, dimensions.height + seriesOptions.lineWidth + 1);
523 context.lineTo(firstX, dimensions.height + seriesOptions.lineWidth);
524 context.fillStyle = seriesOptions.fillStyle;
525 context.fill();
526 }
527
528 if (seriesOptions.strokeStyle && seriesOptions.strokeStyle !== 'none') {
529 context.stroke();
530 }
531 context.closePath();
532 }
533 context.restore();
534 }
535
ramangrover29c996e282013-08-03 14:36:45 -0700536
ramangrover29a04696c2013-07-31 21:50:38 -0700537 if (!chartOptions.labels.disabled && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max)) {
538 var maxValueString = parseFloat(this.valueRange.max).toFixed(chartOptions.labels.precision),
539 minValueString = parseFloat(this.valueRange.min).toFixed(chartOptions.labels.precision);
540 context.fillStyle = chartOptions.labels.fillStyle;
541 context.fillText(maxValueString, dimensions.width - context.measureText(maxValueString).width - 2, chartOptions.labels.fontSize);
542 context.fillText(minValueString, dimensions.width - context.measureText(minValueString).width - 2, dimensions.height - 2);
543 }
544
ramangrover29c996e282013-08-03 14:36:45 -0700545 context.restore();
ramangrover29a04696c2013-07-31 21:50:38 -0700546 };
547
ramangrover29c996e282013-08-03 14:36:45 -0700548
ramangrover29a04696c2013-07-31 21:50:38 -0700549 SmoothieChart.timeFormatter = function(date) {
550 function pad2(number) { return (number < 10 ? '0' : '') + number }
551 return pad2(date.getHours()) + ':' + pad2(date.getMinutes()) + ':' + pad2(date.getSeconds());
552 };
553
554 exports.TimeSeries = TimeSeries;
555 exports.SmoothieChart = SmoothieChart;
556
557})(typeof exports === 'undefined' ? this : exports);
558