diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3a7f40560..682904a08d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,12 @@ **UI Improvements* - [Issue #770](https://github.com/grafana/grafana/issues/770). UI: Panel dropdown menu replaced with a new panel menu -**Misc** +**Graph** - [Issue #877](https://github.com/grafana/grafana/issues/877). Graph: Smart auto decimal precision when using scaled unit formats - [Issue #850](https://github.com/grafana/grafana/issues/850). Graph: Shared tooltip that shows multiple series & crosshair line, thx @toni-moreno +- [Issue #940](https://github.com/grafana/grafana/issues/940). Graph: New series style override option "Fill below to", useful to visualize max & min as a shadow for the mean + +**Misc** - [Issue #938](https://github.com/grafana/grafana/issues/938). Panel: Plugin panels now reside outside of app/panels directory **Fixes** diff --git a/src/app/components/require.config.js b/src/app/components/require.config.js index a478fc76ff2..5d8da32846f 100644 --- a/src/app/components/require.config.js +++ b/src/app/components/require.config.js @@ -40,6 +40,7 @@ require.config({ 'jquery.flot.stackpercent':'../vendor/jquery/jquery.flot.stackpercent', 'jquery.flot.time': '../vendor/jquery/jquery.flot.time', 'jquery.flot.crosshair': '../vendor/jquery/jquery.flot.crosshair', + 'jquery.flot.fillbelow': '../vendor/jquery/jquery.flot.fillbelow', modernizr: '../vendor/modernizr-2.6.1', @@ -83,6 +84,7 @@ require.config({ 'jquery.flot.stackpercent':['jquery', 'jquery.flot'], 'jquery.flot.time': ['jquery', 'jquery.flot'], 'jquery.flot.crosshair':['jquery', 'jquery.flot'], + 'jquery.flot.fillbelow':['jquery', 'jquery.flot'], 'angular-cookies': ['angular'], 'angular-dragdrop': ['jquery', 'angular'], 'angular-loader': ['angular'], diff --git a/src/app/components/timeSeries.js b/src/app/components/timeSeries.js index 79032fd69dd..4422e744d45 100644 --- a/src/app/components/timeSeries.js +++ b/src/app/components/timeSeries.js @@ -9,6 +9,7 @@ function (_, kbn) { this.datapoints = opts.datapoints; this.info = opts.info; this.label = opts.info.alias; + this.id = opts.info.alias; this.valueFormater = kbn.valueFormats.none; this.stats = {}; } @@ -50,6 +51,8 @@ function (_, kbn) { if (override.pointradius !== void 0) { this.points.radius = override.pointradius; } if (override.steppedLine !== void 0) { this.lines.steps = override.steppedLine; } if (override.zindex !== void 0) { this.zindex = override.zindex; } + if (override.fillBelowTo !== void 0) { this.fillBelowTo = override.fillBelowTo; } + if (override.yaxis !== void 0) { this.info.yaxis = override.yaxis; } diff --git a/src/app/directives/grafanaGraph.js b/src/app/directives/grafanaGraph.js index 635a287dfa3..bf7264cc16f 100755 --- a/src/app/directives/grafanaGraph.js +++ b/src/app/directives/grafanaGraph.js @@ -177,6 +177,7 @@ function (angular, $, kbn, moment, _, GraphTooltip) { var series = data[i]; series.applySeriesOverrides(panel.seriesOverrides); series.data = series.getFlotPairs(panel.nullPointMode, panel.y_formats); + // if hidden remove points and disable stack if (scope.hiddenSeries[series.info.alias]) { series.data = []; diff --git a/src/app/panels/graph/module.js b/src/app/panels/graph/module.js index 6f5a174056f..cea1d824f2c 100644 --- a/src/app/panels/graph/module.js +++ b/src/app/panels/graph/module.js @@ -16,6 +16,7 @@ define([ 'jquery.flot.time', 'jquery.flot.stack', 'jquery.flot.stackpercent', + 'jquery.flot.fillbelow', 'jquery.flot.crosshair' ], function (angular, app, $, _, kbn, moment, TimeSeries) { diff --git a/src/app/panels/graph/seriesOverridesCtrl.js b/src/app/panels/graph/seriesOverridesCtrl.js index 1b6b1dfc144..d9681241ad3 100644 --- a/src/app/panels/graph/seriesOverridesCtrl.js +++ b/src/app/panels/graph/seriesOverridesCtrl.js @@ -67,6 +67,7 @@ define([ $scope.addOverrideOption('Lines', 'lines', [true, false]); $scope.addOverrideOption('Line fill', 'fill', [0,1,2,3,4,5,6,7,8,9,10]); $scope.addOverrideOption('Line width', 'linewidth', [0,1,2,3,4,5,6,7,8,9,10]); + $scope.addOverrideOption('Fill below to', 'fillBelowTo', $scope.getSeriesNames()); $scope.addOverrideOption('Staircase line', 'steppedLine', [true, false]); $scope.addOverrideOption('Points', 'points', [true, false]); $scope.addOverrideOption('Points Radius', 'pointradius', [1,2,3,4,5]); diff --git a/src/test/specs/timeSeries-specs.js b/src/test/specs/timeSeries-specs.js index cf28c1aa450..a521ac3debc 100644 --- a/src/test/specs/timeSeries-specs.js +++ b/src/test/specs/timeSeries-specs.js @@ -70,6 +70,17 @@ define([ }); }); + describe('series option overrides, fill below to', function() { + beforeEach(function() { + series.info.alias = 'test'; + series.applySeriesOverrides([{ alias: 'test', fillBelowTo: 'min' }]); + }); + + it('should disable line fill and add fillBelowTo', function() { + expect(series.fillBelowTo).to.be('min'); + }); + }); + describe('series option overrides, pointradius, steppedLine', function() { beforeEach(function() { series.info.alias = 'test'; diff --git a/src/test/test-main.js b/src/test/test-main.js index cfb5c4a4a23..cab40157677 100644 --- a/src/test/test-main.js +++ b/src/test/test-main.js @@ -42,6 +42,7 @@ require.config({ 'jquery.flot.stackpercent':'../vendor/jquery/jquery.flot.stackpercent', 'jquery.flot.time': '../vendor/jquery/jquery.flot.time', 'jquery.flot.crosshair': '../vendor/jquery/jquery.flot.crosshair', + 'jquery.flot.fillbelow': '../vendor/jquery/jquery.flot.fillbelow', modernizr: '../vendor/modernizr-2.6.1', }, @@ -68,7 +69,6 @@ require.config({ exports: 'Crypto' }, - 'jquery-ui': ['jquery'], 'jquery.flot': ['jquery'], 'jquery.flot.pie': ['jquery', 'jquery.flot'], 'jquery.flot.events': ['jquery', 'jquery.flot'], @@ -77,6 +77,7 @@ require.config({ 'jquery.flot.stackpercent':['jquery', 'jquery.flot'], 'jquery.flot.time': ['jquery', 'jquery.flot'], 'jquery.flot.crosshair':['jquery', 'jquery.flot'], + 'jquery.flot.fillbelow':['jquery', 'jquery.flot'], 'angular-route': ['angular'], 'angular-cookies': ['angular'], diff --git a/src/vendor/jquery/jquery.flot.fillbelow.js b/src/vendor/jquery/jquery.flot.fillbelow.js new file mode 100644 index 00000000000..0153dd85e17 --- /dev/null +++ b/src/vendor/jquery/jquery.flot.fillbelow.js @@ -0,0 +1,289 @@ +(function($) { + "use strict"; + + var options = { + series: { + fillBelowTo: null + } + }; + + function init(plot) { + function findBelowSeries( series, allseries ) { + + var i; + + debugger; + for ( i = 0; i < allseries.length; ++i ) { + if ( allseries[ i ].id === series.fillBelowTo ) { + return allseries[ i ]; + } + } + + return null; + } + + /* top and bottom doesn't actually matter for this, we're just using it to help make this easier to think about */ + /* this is a vector cross product operation */ + function segmentIntersection(top_left_x, top_left_y, top_right_x, top_right_y, bottom_left_x, bottom_left_y, bottom_right_x, bottom_right_y) { + var top_delta_x, top_delta_y, bottom_delta_x, bottom_delta_y, + s, t; + + top_delta_x = top_right_x - top_left_x; + top_delta_y = top_right_y - top_left_y; + bottom_delta_x = bottom_right_x - bottom_left_x; + bottom_delta_y = bottom_right_y - bottom_left_y; + + s = ( + (-top_delta_y * (top_left_x - bottom_left_x)) + (top_delta_x * (top_left_y - bottom_left_y)) + ) / ( + -bottom_delta_x * top_delta_y + top_delta_x * bottom_delta_y + ); + + t = ( + (bottom_delta_x * (top_left_y - bottom_left_y)) - (bottom_delta_y * (top_left_x - bottom_left_x)) + ) / ( + -bottom_delta_x * top_delta_y + top_delta_x * bottom_delta_y + ); + + // Collision detected + if (s >= 0 && s <= 1 && t >= 0 && t <= 1) { + return [ + top_left_x + (t * top_delta_x), // X + top_left_y + (t * top_delta_y) // Y + ]; + } + + // No collision + return null; + } + + function plotDifferenceArea(plot, ctx, series) { + if ( series.fillBelowTo === null ) { + return; + } + + var otherseries, + + ps, + points, + + otherps, + otherpoints, + + plotOffset, + fillStyle; + + function openPolygon(x, y) { + ctx.beginPath(); + ctx.moveTo( + series.xaxis.p2c(x) + plotOffset.left, + series.yaxis.p2c(y) + plotOffset.top + ); + + } + + function closePolygon() { + ctx.closePath(); + ctx.fill(); + } + + function validateInput() { + if (points.length/ps !== otherpoints.length/otherps) { + console.error("Refusing to graph inconsistent number of points"); + return false; + } + + var i; + for (i = 0; i < (points.length / ps); i++) { + if ( + points[i * ps] !== null && + otherpoints[i * otherps] !== null && + points[i * ps] !== otherpoints[i * otherps] + ) { + console.error("Refusing to graph points without matching value"); + return false; + } + } + + return true; + } + + function findNextStart(start_i, end_i) { + console.assert(end_i > start_i, "expects the end index to be greater than the start index"); + + var start = ( + start_i === 0 || + points[start_i - 1] === null || + otherpoints[start_i - 1] === null + ), + equal = false, + i, + intersect; + + for (i = start_i; i < end_i; i++) { + // Take note of null points + if ( + points[(i * ps) + 1] === null || + otherpoints[(i * ps) + 1] === null + ) { + equal = false; + start = true; + } + + // Take note of equal points + else if (points[(i * ps) + 1] === otherpoints[(i * otherps) + 1]) { + equal = true; + start = false; + } + + + else if (points[(i * ps) + 1] > otherpoints[(i * otherps) + 1]) { + // If we begin above the desired point + if (start) { + openPolygon(points[i * ps], points[(i * ps) + 1]); + } + + // If an equal point preceeds this, start the polygon at that equal point + else if (equal) { + openPolygon(points[(i - 1) * ps], points[((i - 1) * ps) + 1]); + } + + // Otherwise, find the intersection point, and start it there + else { + intersect = intersectionPoint(i); + openPolygon(intersect[0], intersect[1]); + } + + topTraversal(i, end_i); + return; + } + + // If we go below equal, equal at any preceeding point is irrelevant + else { + start = false; + equal = false; + } + } + } + + function intersectionPoint(right_i) { + console.assert(right_i > 0, "expects the second point in the series line segment"); + + var i, intersect; + + for (i = 1; i < (otherpoints.length/otherps); i++) { + intersect = segmentIntersection( + points[(right_i - 1) * ps], points[((right_i - 1) * ps) + 1], + points[right_i * ps], points[(right_i * ps) + 1], + + otherpoints[(i - 1) * otherps], otherpoints[((i - 1) * otherps) + 1], + otherpoints[i * otherps], otherpoints[(i * otherps) + 1] + ); + + if (intersect !== null) { + return intersect; + } + } + + console.error("intersectionPoint() should only be called when an intersection happens"); + } + + function bottomTraversal(start_i, end_i) { + console.assert(start_i >= end_i, "the start should be the rightmost point, and the end should be the leftmost (excluding the equal or intersecting point)"); + + var i; + + for (i = start_i; i >= end_i; i--) { + ctx.lineTo( + otherseries.xaxis.p2c(otherpoints[i * otherps]) + plotOffset.left, + otherseries.yaxis.p2c(otherpoints[(i * otherps) + 1]) + plotOffset.top + ); + } + + closePolygon(); + } + + function topTraversal(start_i, end_i) { + console.assert(start_i <= end_i, "the start should be the rightmost point, and the end should be the leftmost (excluding the equal or intersecting point)"); + + var i, + intersect; + + for (i = start_i; i < end_i; i++) { + if (points[(i * ps) + 1] === null && i > start_i) { + bottomTraversal(i - 1, start_i); + findNextStart(i, end_i); + return; + } + + else if (points[(i * ps) + 1] === otherpoints[(i * otherps) + 1]) { + bottomTraversal(i, start_i); + findNextStart(i, end_i); + return; + } + + else if (points[(i * ps) + 1] < otherpoints[(i * otherps) + 1]) { + intersect = intersectionPoint(i); + ctx.lineTo( + series.xaxis.p2c(intersect[0]) + plotOffset.left, + series.yaxis.p2c(intersect[1]) + plotOffset.top + ); + bottomTraversal(i, start_i); + findNextStart(i, end_i); + return; + + } + + else { + ctx.lineTo( + series.xaxis.p2c(points[i * ps]) + plotOffset.left, + series.yaxis.p2c(points[(i * ps) + 1]) + plotOffset.top + ); + } + } + + bottomTraversal(end_i, start_i); + } + + + // Begin processing + + otherseries = findBelowSeries( series, plot.getData() ); + + if ( !otherseries ) { + return; + } + + ps = series.datapoints.pointsize; + points = series.datapoints.points; + otherps = otherseries.datapoints.pointsize; + otherpoints = otherseries.datapoints.points; + plotOffset = plot.getPlotOffset(); + + if (!validateInput()) { + return; + } + + + // Flot's getFillStyle() should probably be exposed somewhere + fillStyle = $.color.parse(series.color); + fillStyle.a = 0.4; + fillStyle.normalize(); + ctx.fillStyle = fillStyle.toString(); + + + // Begin recursive bi-directional traversal + findNextStart(0, points.length/ps); + } + + plot.hooks.drawSeries.push(plotDifferenceArea); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: "fillbelow", + version: "0.1.0" + }); + +})(jQuery);