From 8fe48d63902c0323c0964ab0ccdddc9e1766e57a Mon Sep 17 00:00:00 2001 From: egghead Date: Fri, 1 Mar 2013 15:55:00 -0800 Subject: [PATCH 1/2] First stab at timeseries -- poor mans version --- lib/riemann/dash/public/vendor/smoothie.js | 374 ++++++++++++++++++++ lib/riemann/dash/public/views/timeseries.js | 74 ++++ lib/riemann/dash/views/index.erubis | 3 +- 3 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 lib/riemann/dash/public/vendor/smoothie.js create mode 100644 lib/riemann/dash/public/views/timeseries.js diff --git a/lib/riemann/dash/public/vendor/smoothie.js b/lib/riemann/dash/public/vendor/smoothie.js new file mode 100644 index 0000000..82dda4a --- /dev/null +++ b/lib/riemann/dash/public/vendor/smoothie.js @@ -0,0 +1,374 @@ +// MIT License: +// +// Copyright (c) 2010-2011, Joe Walnes +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +/** + * Smoothie Charts - http://smoothiecharts.org/ + * (c) 2010-2012, Joe Walnes + * + * v1.0: Main charting library, by Joe Walnes + * v1.1: Auto scaling of axis, by Neil Dunn + * v1.2: fps (frames per second) option, by Mathias Petterson + * v1.3: Fix for divide by zero, by Paul Nikitochkin + * v1.4: Set minimum, top-scale padding, remove timeseries, add optional timer to reset bounds, by Kelley Reynolds + * v1.5: Set default frames per second to 50... smoother. + * .start(), .stop() methods for conserving CPU, by Dmitry Vyal + * options.interpolation = 'bezier' or 'line', by Dmitry Vyal + * options.maxValue to fix scale, by Dmitry Vyal + * v1.6: minValue/maxValue will always get converted to floats, by Przemek Matylla + * v1.7: options.grid.fillStyle may be a transparent color, by Dmitry A. Shashkin + * Smooth rescaling, by Kostas Michalopoulos + * v1.8: Set max length to customize number of live points in the dataset with options.maxDataSetLength, by Krishna Narni + * v1.9: Display timestamps along the bottom, by Nick and Stev-io + * (https://groups.google.com/forum/?fromgroups#!topic/smoothie-charts/-Ywse8FCpKI%5B1-25%5D) + * Refactored by Krishna Narni, to support timestamp formatting function + * v1.10: Switch to requestAnimationFrame, removed the now obsoleted options.fps, by Gergely Imreh + * v1.11: options.grid.sharpLines option added, by @drewnoakes + * Addressed warning seen in Firefox when seriesOption.fillStyle undefined, by @drewnoakes + */ + +function TimeSeries(options) { + options = options || {}; + options.resetBoundsInterval = options.resetBoundsInterval || 3000; // Reset the max/min bounds after this many milliseconds + options.resetBounds = options.resetBounds === undefined ? true : options.resetBounds; // Enable or disable the resetBounds timer + this.options = options; + this.data = []; + + this.maxValue = Number.NaN; // The maximum value ever seen in this time series. + this.minValue = Number.NaN; // The minimum value ever seen in this time series. + + // Start a resetBounds Interval timer desired + if (options.resetBounds) { + this.boundsTimer = setInterval((function(thisObj) { return function() { thisObj.resetBounds(); } })(this), options.resetBoundsInterval); + } +} + +// Reset the min and max for this timeseries so the graph rescales itself +TimeSeries.prototype.resetBounds = function() { + this.maxValue = Number.NaN; + this.minValue = Number.NaN; + for (var i = 0; i < this.data.length; i++) { + this.maxValue = !isNaN(this.maxValue) ? Math.max(this.maxValue, this.data[i][1]) : this.data[i][1]; + this.minValue = !isNaN(this.minValue) ? Math.min(this.minValue, this.data[i][1]) : this.data[i][1]; + } +}; + +TimeSeries.prototype.append = function(timestamp, value) { + this.data.push([timestamp, value]); + this.maxValue = !isNaN(this.maxValue) ? Math.max(this.maxValue, value) : value; + this.minValue = !isNaN(this.minValue) ? Math.min(this.minValue, value) : value; +}; + +function SmoothieChart(options) { + // Defaults + options = options || {}; + options.grid = options.grid || { fillStyle:'#000000', strokeStyle: '#777777', lineWidth: 1, sharpLines: false, millisPerLine: 1000, verticalSections: 2 }; + options.millisPerPixel = options.millisPerPixel || 20; + options.maxValueScale = options.maxValueScale || 1; + // NOTE there are no default values for 'minValue' and 'maxValue' + options.labels = options.labels || { fillStyle:'#ffffff' }; + options.interpolation = options.interpolation || "bezier"; + options.scaleSmoothing = options.scaleSmoothing || 0.125; + options.maxDataSetLength = options.maxDataSetLength || 2; + options.timestampFormatter = options.timestampFormatter || null; + this.options = options; + this.seriesSet = []; + this.currentValueRange = 1; + this.currentVisMinValue = 0; +} + +// Based on http://inspirit.github.com/jsfeat/js/compatibility.js +SmoothieChart.AnimateCompatibility = (function() { + var lastTime = 0, + + requestAnimationFrame = function(callback, element) { + var requestAnimationFrame = + window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + window.oRequestAnimationFrame || + window.msRequestAnimationFrame || + function(callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function() { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + return requestAnimationFrame.call(window, callback, element); + }, + + cancelAnimationFrame = function(id) { + var cancelAnimationFrame = + window.cancelAnimationFrame || + function(id) { + clearTimeout(id); + }; + return cancelAnimationFrame.call(window, id); + }; + + return { + requestAnimationFrame: requestAnimationFrame, + cancelAnimationFrame: cancelAnimationFrame + }; +})(); + +SmoothieChart.prototype.addTimeSeries = function(timeSeries, options) { + this.seriesSet.push({timeSeries: timeSeries, options: options || {}}); +}; + +SmoothieChart.prototype.removeTimeSeries = function(timeSeries) { + this.seriesSet.splice(this.seriesSet.indexOf(timeSeries), 1); +}; + +SmoothieChart.prototype.streamTo = function(canvas, delay) { + this.canvas = canvas; + this.delay = delay; + this.start(); +}; + +SmoothieChart.prototype.start = function() { + if (!this.frame) { + this.animate(); + } +}; + +SmoothieChart.prototype.animate = function() { + this.frame = SmoothieChart.AnimateCompatibility.requestAnimationFrame(this.animate.bind(this)); + this.render(this.canvas, new Date().getTime() - (this.delay || 0)); +}; + +SmoothieChart.prototype.stop = function() { + if (this.frame) { + SmootheiChart.AnimateCompatibility.cancelAnimationFrame( this.frame ); + delete this.frame; + } +}; + +// Sample timestamp formatting function +SmoothieChart.timeFormatter = function(dateObject) { + function pad2(number){return (number < 10 ? '0' : '') + number}; + return pad2(dateObject.getHours())+':'+pad2(dateObject.getMinutes())+':'+pad2(dateObject.getSeconds()); +}; + +SmoothieChart.prototype.render = function(canvas, time) { + var canvasContext = canvas.getContext("2d"); + var options = this.options; + var dimensions = {top: 0, left: 0, width: canvas.clientWidth, height: canvas.clientHeight}; + + // Save the state of the canvas context, any transformations applied in this method + // will get removed from the stack at the end of this method when .restore() is called. + canvasContext.save(); + + // Round time down to pixel granularity, so motion appears smoother. + time = time - time % options.millisPerPixel; + + // Move the origin. + canvasContext.translate(dimensions.left, dimensions.top); + + // Create a clipped rectangle - anything we draw will be constrained to this rectangle. + // This prevents the occasional pixels from curves near the edges overrunning and creating + // screen cheese (that phrase should need no explanation). + canvasContext.beginPath(); + canvasContext.rect(0, 0, dimensions.width, dimensions.height); + canvasContext.clip(); + + // Clear the working area. + canvasContext.save(); + canvasContext.fillStyle = options.grid.fillStyle; + canvasContext.clearRect(0, 0, dimensions.width, dimensions.height); + canvasContext.fillRect(0, 0, dimensions.width, dimensions.height); + canvasContext.restore(); + + // Grid lines.... + canvasContext.save(); + canvasContext.lineWidth = options.grid.lineWidth || 1; + canvasContext.strokeStyle = options.grid.strokeStyle || '#ffffff'; + // Vertical (time) dividers. + if (options.grid.millisPerLine > 0) { + for (var t = time - (time % options.grid.millisPerLine); t >= time - (dimensions.width * options.millisPerPixel); t -= options.grid.millisPerLine) { + canvasContext.beginPath(); + var gx = Math.round(dimensions.width - ((time - t) / options.millisPerPixel)); + if (options.grid.sharpLines) + gx -= 0.5; + canvasContext.moveTo(gx, 0); + canvasContext.lineTo(gx, dimensions.height); + canvasContext.stroke(); + // To display timestamps along the bottom + // May have to adjust millisPerLine to display non-overlapping timestamps, depending on the canvas size + if (options.timestampFormatter){ + var tx=new Date(t); + // Formats the timestamp based on user specified formatting function + // SmoothieChart.timeFormatter function above is one such formatting option + var ts = options.timestampFormatter(tx); + var txtwidth=(canvasContext.measureText(ts).width/2)+canvasContext.measureText(minValueString).width + 4; + if (gx= options.maxDataSetLength && dataSet[1][0] < time - (dimensions.width * options.millisPerPixel)) { + dataSet.splice(0, 1); + } + + // Set style for this dataSet. + canvasContext.lineWidth = seriesOptions.lineWidth || 1; + canvasContext.strokeStyle = seriesOptions.strokeStyle || '#ffffff'; + // Draw the line... + canvasContext.beginPath(); + // Retain lastX, lastY for calculating the control points of bezier curves. + var firstX = 0, lastX = 0, lastY = 0; + for (var i = 0; i < dataSet.length; i++) { + // TODO: Deal with dataSet.length < 2. + var x = Math.round(dimensions.width - ((time - dataSet[i][0]) / options.millisPerPixel)); + var value = dataSet[i][1]; + var offset = value - visMinValue; + var scaledValue = dimensions.height - (valueRange ? Math.round((offset / valueRange) * dimensions.height) : 0); + var y = Math.max(Math.min(scaledValue, dimensions.height - 1), 1); // Ensure line is always on chart. + + if (i == 0) { + firstX = x; + canvasContext.moveTo(x, y); + } + // Great explanation of Bezier curves: http://en.wikipedia.org/wiki/Bezier_curve#Quadratic_curves + // + // Assuming A was the last point in the line plotted and B is the new point, + // we draw a curve with control points P and Q as below. + // + // A---P + // | + // | + // | + // Q---B + // + // Importantly, A and P are at the same y coordinate, as are B and Q. This is + // so adjacent curves appear to flow as one. + // + else { + switch (options.interpolation) { + case "line": + canvasContext.lineTo(x,y); + break; + case "bezier": + default: + canvasContext.bezierCurveTo( // startPoint (A) is implicit from last iteration of loop + Math.round((lastX + x) / 2), lastY, // controlPoint1 (P) + Math.round((lastX + x)) / 2, y, // controlPoint2 (Q) + x, y); // endPoint (B) + break; + } + } + + lastX = x; lastY = y; + } + if (dataSet.length > 0 && seriesOptions.fillStyle) { + // Close up the fill region. + canvasContext.lineTo(dimensions.width + seriesOptions.lineWidth + 1, lastY); + canvasContext.lineTo(dimensions.width + seriesOptions.lineWidth + 1, dimensions.height + seriesOptions.lineWidth + 1); + canvasContext.lineTo(firstX, dimensions.height + seriesOptions.lineWidth); + canvasContext.fillStyle = seriesOptions.fillStyle; + canvasContext.fill(); + } + canvasContext.stroke(); + canvasContext.closePath(); + canvasContext.restore(); + } + + // Draw the axis values on the chart. + if (!options.labels.disabled) { + canvasContext.fillStyle = options.labels.fillStyle; + var maxValueString = parseFloat(maxValue).toFixed(2); + var minValueString = parseFloat(minValue).toFixed(2); + canvasContext.fillText(maxValueString, dimensions.width - canvasContext.measureText(maxValueString).width - 2, 10); + canvasContext.fillText(minValueString, dimensions.width - canvasContext.measureText(minValueString).width - 2, dimensions.height - 2); + } + + canvasContext.restore(); // See .save() above. +}; diff --git a/lib/riemann/dash/public/views/timeseries.js b/lib/riemann/dash/public/views/timeseries.js new file mode 100644 index 0000000..339c6f7 --- /dev/null +++ b/lib/riemann/dash/public/views/timeseries.js @@ -0,0 +1,74 @@ +(function() { + var fitopts = {min: 6, max: 1000}; + + var TimeSeriesView = function(json) { + view.View.call(this, json); + this.query = json.query; + this.title = json.title; + this.clickFocusable = true; + this.el.addClass('timeseries'); + this.el.append( + '
' + + '' + + '
' + ); + + this.$canvas = this.el.find(".timeseries"); + this.canvas = this.$canvas.get(0); + this.smoothie = new SmoothieChart(); + this.smoothie.streamTo(this.canvas, 2000); + + this.series = new TimeSeries(); + this.smoothie.addTimeSeries(this.series, {lineWidth: 3}); + + this.reflow(); + + if (this.query) { + var reflowed = false; + var me = this; + this.sub = subs.subscribe(this.query, function(e) { + var metric = format.float(e.metric); + me.series.append(new Date(e.time).getTime(), metric); + }); + } + } + + view.inherit(view.View, TimeSeriesView); + view.TimeSeries = TimeSeriesView; + view.types.TimeSeries = TimeSeriesView; + console.log(view.types); + console.log("Hello world!!!"); + + TimeSeriesView.prototype.json = function() { + return $.extend(view.View.prototype.json.call(this), { + type: 'TimeSeries', + title: this.title, + query: this.query + }); + } + + TimeSeriesView.prototype.editForm = function() { + return Mustache.render('' + + '
' + + '' + + '', + this) + } + + TimeSeriesView.prototype.reflow = function() { + // Size metric + var width = this.el.width(); + var height = this.el.height(); + this.$canvas.attr("width", width - 10); + this.$canvas.attr("height", height - 10); + + } + + TimeSeriesView.prototype.delete = function() { + if (this.sub) { + subs.unsubscribe(this.sub); + } + view.View.prototype.delete.call(this); + } +})(); + diff --git a/lib/riemann/dash/views/index.erubis b/lib/riemann/dash/views/index.erubis index ac5f75d..611e47a 100644 --- a/lib/riemann/dash/views/index.erubis +++ b/lib/riemann/dash/views/index.erubis @@ -9,7 +9,7 @@
- + @@ -28,6 +28,7 @@ + From fa2996265f1bf9c23938067d37ac5f5d35c89bed Mon Sep 17 00:00:00 2001 From: egghead Date: Sat, 2 Mar 2013 05:46:15 +0000 Subject: [PATCH 2/2] Rounding out timeseries with customization and title updating --- lib/riemann/dash/public/views/timeseries.js | 51 +++++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/lib/riemann/dash/public/views/timeseries.js b/lib/riemann/dash/public/views/timeseries.js index 339c6f7..60f2010 100644 --- a/lib/riemann/dash/public/views/timeseries.js +++ b/lib/riemann/dash/public/views/timeseries.js @@ -5,23 +5,40 @@ view.View.call(this, json); this.query = json.query; this.title = json.title; + this.delay = json.delay; + this.lineWidth = json.lineWidth; + this.strokeStyle = json.strokeStyle; + this.fillStyle = json.fillStyle; + this.clickFocusable = true; this.el.addClass('timeseries'); this.el.append( '
' + + '
' + + '

' + this.title + '

' + + '
' + '' + - '
' + '' ); this.$canvas = this.el.find(".timeseries"); + this.$titlecontainer = this.el.find("div.title"); + this.$titlecontainer.css({"color": "#f2f2f2", + "text-shadow": "#262626 2px 2px 4px", + "font-size": "2em"}) + + this.$title = this.$titlecontainer.find("h2"); this.canvas = this.$canvas.get(0); + + this.reflow(); + this.smoothie = new SmoothieChart(); - this.smoothie.streamTo(this.canvas, 2000); + this.smoothie.streamTo(this.canvas, this.delay); this.series = new TimeSeries(); - this.smoothie.addTimeSeries(this.series, {lineWidth: 3}); - - this.reflow(); + this.smoothie.addTimeSeries(this.series, {lineWidth: this.lineWidth || 2, + strokeStyle: this.strokeStyle || "#FFF", + fillStyle: this.fillStyle}); if (this.query) { var reflowed = false; @@ -29,6 +46,12 @@ this.sub = subs.subscribe(this.query, function(e) { var metric = format.float(e.metric); me.series.append(new Date(e.time).getTime(), metric); + _.delay(function() { + if (me.$title) { + me.$title.text(me.title + ": " + metric); + } + }, +me.delay) + }); } } @@ -36,14 +59,16 @@ view.inherit(view.View, TimeSeriesView); view.TimeSeries = TimeSeriesView; view.types.TimeSeries = TimeSeriesView; - console.log(view.types); - console.log("Hello world!!!"); TimeSeriesView.prototype.json = function() { return $.extend(view.View.prototype.json.call(this), { type: 'TimeSeries', title: this.title, - query: this.query + delay: this.delay, + query: this.query, + strokeStyle: this.strokeStyle, + lineWidth: this.lineWidth, + fillStyle: this.fillStyle }); } @@ -51,7 +76,15 @@ return Mustache.render('' + '
' + '' + - '', + '
' + + '' + + '
' + + '' + + '
' + + '' + + '
' + + '' + + '', this) }