diff --git a/public/app/plugins/panel/heatmap/color_legend.ts b/public/app/plugins/panel/heatmap/color_legend.ts new file mode 100644 index 00000000000..60bbe38d689 --- /dev/null +++ b/public/app/plugins/panel/heatmap/color_legend.ts @@ -0,0 +1,300 @@ +/// +import angular from 'angular'; +import _ from 'lodash'; +import $ from 'jquery'; +import d3 from 'd3'; +import {contextSrv} from 'app/core/core'; +import {tickStep} from 'app/core/utils/ticks'; + +let module = angular.module('grafana.directives'); + +/** + * Color legend for heatmap editor. + */ +module.directive('colorLegend', function() { + return { + restrict: 'E', + template: '
', + link: function(scope, elem, attrs) { + let ctrl = scope.ctrl; + let panel = scope.ctrl.panel; + + render(); + + ctrl.events.on('render', function() { + render(); + }); + + function render() { + let legendElem = $(elem).find('svg'); + let legendWidth = Math.floor(legendElem.outerWidth()); + + if (panel.color.mode === 'spectrum') { + let colorScheme = _.find(ctrl.colorSchemes, {value: panel.color.colorScheme}); + let colorScale = getColorScale(colorScheme, legendWidth); + drawSimpleColorLegend(elem, colorScale); + } else if (panel.color.mode === 'opacity') { + let colorOptions = panel.color; + drawSimpleOpacityLegend(elem, colorOptions); + } + } + } + }; +}); + +/** + * Heatmap legend with scale values. + */ +module.directive('heatmapLegend', function() { + return { + restrict: 'E', + template: '
', + link: function(scope, elem, attrs) { + let ctrl = scope.ctrl; + let panel = scope.ctrl.panel; + + render(); + ctrl.events.on('render', function() { + render(); + }); + + function render() { + clearLegend(elem); + if (!_.isEmpty(ctrl.data) && !_.isEmpty(ctrl.data.cards)) { + let rangeFrom = 0; + let rangeTo = ctrl.data.cardStats.max; + let maxValue = panel.color.max || rangeTo; + let minValue = panel.color.min || 0; + + if (panel.color.mode === 'spectrum') { + let colorScheme = _.find(ctrl.colorSchemes, {value: panel.color.colorScheme}); + drawColorLegend(elem, colorScheme, rangeFrom, rangeTo, maxValue, minValue); + } else if (panel.color.mode === 'opacity') { + let colorOptions = panel.color; + drawOpacityLegend(elem, colorOptions, rangeFrom, rangeTo, maxValue, minValue); + } + } + } + } + }; +}); + +function drawColorLegend(elem, colorScheme, rangeFrom, rangeTo, maxValue, minValue) { + let legendElem = $(elem).find('svg'); + let legend = d3.select(legendElem.get(0)); + clearLegend(elem); + + let legendWidth = Math.floor(legendElem.outerWidth()) - 30; + let legendHeight = legendElem.attr("height"); + + let rangeStep = 1; + if (rangeTo - rangeFrom > legendWidth) { + rangeStep = Math.floor((rangeTo - rangeFrom) / legendWidth); + } + let widthFactor = legendWidth / (rangeTo - rangeFrom); + let valuesRange = d3.range(rangeFrom, rangeTo, rangeStep); + + let colorScale = getColorScale(colorScheme, maxValue, minValue); + legend.selectAll(".heatmap-color-legend-rect") + .data(valuesRange) + .enter().append("rect") + .attr("x", d => d * widthFactor) + .attr("y", 0) + .attr("width", rangeStep * widthFactor + 1) // Overlap rectangles to prevent gaps + .attr("height", legendHeight) + .attr("stroke-width", 0) + .attr("fill", d => colorScale(d)); + + drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minValue, legendWidth); +} + +function drawOpacityLegend(elem, options, rangeFrom, rangeTo, maxValue, minValue) { + let legendElem = $(elem).find('svg'); + let legend = d3.select(legendElem.get(0)); + clearLegend(elem); + + let legendWidth = Math.floor(legendElem.outerWidth()) - 30; + let legendHeight = legendElem.attr("height"); + + let rangeStep = 10; + let widthFactor = legendWidth / (rangeTo - rangeFrom); + let valuesRange = d3.range(rangeFrom, rangeTo, rangeStep); + + let opacityScale = getOpacityScale(options, maxValue, minValue); + legend.selectAll(".heatmap-opacity-legend-rect") + .data(valuesRange) + .enter().append("rect") + .attr("x", d => d * widthFactor) + .attr("y", 0) + .attr("width", rangeStep * widthFactor) + .attr("height", legendHeight) + .attr("stroke-width", 0) + .attr("fill", options.cardColor) + .style("opacity", d => opacityScale(d)); + + drawLegendValues(elem, opacityScale, rangeFrom, rangeTo, maxValue, minValue, legendWidth); +} + +function drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minValue, legendWidth) { + let legendElem = $(elem).find('svg'); + let legend = d3.select(legendElem.get(0)); + + if (legendWidth <= 0 || legendElem.get(0).childNodes.length === 0) { + return; + } + + let legendValueDomain = _.sortBy(colorScale.domain()); + let legendValueScale = d3.scaleLinear() + .domain([0, rangeTo]) + .range([0, legendWidth]); + + let ticks = buildLegendTicks(0, rangeTo, maxValue, minValue); + let xAxis = d3.axisBottom(legendValueScale) + .tickValues(ticks) + .tickSize(2); + + let colorRect = legendElem.find(":first-child"); + let posY = colorRect.height() + 2; + let posX = getSvgElemX(colorRect); + d3.select(legendElem.get(0)).append("g") + .attr("class", "axis") + .attr("transform", "translate(" + posX + "," + posY + ")") + .call(xAxis); + + legend.select(".axis").select(".domain").remove(); +} + +function drawSimpleColorLegend(elem, colorScale) { + let legendElem = $(elem).find('svg'); + clearLegend(elem); + + let legendWidth = Math.floor(legendElem.outerWidth()); + let legendHeight = legendElem.attr("height"); + + if (legendWidth) { + let valuesNumber = Math.floor(legendWidth / 2); + let rangeStep = Math.floor(legendWidth / valuesNumber); + let valuesRange = d3.range(0, legendWidth, rangeStep); + + let legend = d3.select(legendElem.get(0)); + var legendRects = legend.selectAll(".heatmap-color-legend-rect").data(valuesRange); + + legendRects.enter().append("rect") + .attr("x", d => d) + .attr("y", 0) + .attr("width", rangeStep + 1) // Overlap rectangles to prevent gaps + .attr("height", legendHeight) + .attr("stroke-width", 0) + .attr("fill", d => colorScale(d)); + } +} + +function drawSimpleOpacityLegend(elem, options) { + let legendElem = $(elem).find('svg'); + clearLegend(elem); + + let legend = d3.select(legendElem.get(0)); + let legendWidth = Math.floor(legendElem.outerWidth()); + let legendHeight = legendElem.attr("height"); + + if (legendWidth) { + let legendOpacityScale; + if (options.colorScale === 'linear') { + legendOpacityScale = d3.scaleLinear() + .domain([0, legendWidth]) + .range([0, 1]); + } else if (options.colorScale === 'sqrt') { + legendOpacityScale = d3.scalePow().exponent(options.exponent) + .domain([0, legendWidth]) + .range([0, 1]); + } + + let rangeStep = 10; + let valuesRange = d3.range(0, legendWidth, rangeStep); + var legendRects = legend.selectAll(".heatmap-opacity-legend-rect").data(valuesRange); + + legendRects.enter().append("rect") + .attr("x", d => d) + .attr("y", 0) + .attr("width", rangeStep) + .attr("height", legendHeight) + .attr("stroke-width", 0) + .attr("fill", options.cardColor) + .style("opacity", d => legendOpacityScale(d)); + } +} + +function clearLegend(elem) { + let legendElem = $(elem).find('svg'); + legendElem.empty(); +} + +function getColorScale(colorScheme, maxValue, minValue = 0) { + let colorInterpolator = d3[colorScheme.value]; + let colorScaleInverted = colorScheme.invert === 'always' || + (colorScheme.invert === 'dark' && !contextSrv.user.lightTheme); + + let start = colorScaleInverted ? maxValue : minValue; + let end = colorScaleInverted ? minValue : maxValue; + + return d3.scaleSequential(colorInterpolator).domain([start, end]); +} + +function getOpacityScale(options, maxValue, minValue = 0) { + let legendOpacityScale; + if (options.colorScale === 'linear') { + legendOpacityScale = d3.scaleLinear() + .domain([minValue, maxValue]) + .range([0, 1]); + } else if (options.colorScale === 'sqrt') { + legendOpacityScale = d3.scalePow().exponent(options.exponent) + .domain([minValue, maxValue]) + .range([0, 1]); + } + return legendOpacityScale; +} + +function getSvgElemX(elem) { + let svgElem = elem.get(0); + if (svgElem && svgElem.x && svgElem.x.baseVal) { + return elem.get(0).x.baseVal.value; + } else { + return 0; + } +} + +function buildLegendTicks(rangeFrom, rangeTo, maxValue, minValue) { + let range = rangeTo - rangeFrom; + let tickStepSize = tickStep(rangeFrom, rangeTo, 3); + let ticksNum = Math.round(range / tickStepSize); + let ticks = []; + + for (let i = 0; i < ticksNum; i++) { + let current = tickStepSize * i; + // Add user-defined min and max if it had been set + if (isValueCloseTo(minValue, current, tickStepSize)) { + ticks.push(minValue); + continue; + } else if (minValue < current) { + ticks.push(minValue); + } + if (isValueCloseTo(maxValue, current, tickStepSize)) { + ticks.push(maxValue); + continue; + } else if (maxValue < current) { + ticks.push(maxValue); + } + ticks.push(tickStepSize * i); + } + if (!isValueCloseTo(maxValue, rangeTo, tickStepSize)) { + ticks.push(maxValue); + } + ticks.push(rangeTo); + ticks = _.sortBy(_.uniq(ticks)); + return ticks; +} + +function isValueCloseTo(val, valueTo, step) { + let diff = Math.abs(val - valueTo); + return diff < step * 0.3; +} diff --git a/public/app/plugins/panel/heatmap/display_editor.ts b/public/app/plugins/panel/heatmap/display_editor.ts index 775017b00da..1cea9505567 100644 --- a/public/app/plugins/panel/heatmap/display_editor.ts +++ b/public/app/plugins/panel/heatmap/display_editor.ts @@ -1,4 +1,10 @@ /// +import _ from 'lodash'; +import $ from 'jquery'; +import d3 from 'd3'; +import {contextSrv} from 'app/core/core'; + +const COLOR_LEGEND_SELECTOR = '.heatmap-color-legend'; export class HeatmapDisplayEditorCtrl { panel: any; diff --git a/public/app/plugins/panel/heatmap/heatmap_ctrl.ts b/public/app/plugins/panel/heatmap/heatmap_ctrl.ts index 37aa9410ea8..b564339673f 100644 --- a/public/app/plugins/panel/heatmap/heatmap_ctrl.ts +++ b/public/app/plugins/panel/heatmap/heatmap_ctrl.ts @@ -7,7 +7,7 @@ import TimeSeries from 'app/core/time_series'; import {axesEditor} from './axes_editor'; import {heatmapDisplayEditor} from './display_editor'; import rendering from './rendering'; -import { convertToHeatMap, elasticHistogramToHeatmap, calculateBucketSize, getMinLog} from './heatmap_data_converter'; +import {convertToHeatMap, convertToCards, elasticHistogramToHeatmap, calculateBucketSize, getMinLog} from './heatmap_data_converter'; let X_BUCKET_NUMBER_DEFAULT = 30; let Y_BUCKET_NUMBER_DEFAULT = 10; @@ -26,6 +26,9 @@ let panelDefaults = { exponent: 0.5, colorScheme: 'interpolateOranges', }, + legend: { + show: false + }, dataFormat: 'timeseries', xAxis: { show: true, @@ -188,11 +191,15 @@ export class HeatmapCtrl extends MetricsPanelCtrl { yBucketSize = 1; } + let {cards, cardStats} = convertToCards(bucketsData); + this.data = { buckets: bucketsData, heatmapStats: heatmapStats, xBucketSize: xBucketSize, - yBucketSize: yBucketSize + yBucketSize: yBucketSize, + cards: cards, + cardStats: cardStats }; } diff --git a/public/app/plugins/panel/heatmap/heatmap_data_converter.ts b/public/app/plugins/panel/heatmap/heatmap_data_converter.ts index ef405ea5508..993c32c0fca 100644 --- a/public/app/plugins/panel/heatmap/heatmap_data_converter.ts +++ b/public/app/plugins/panel/heatmap/heatmap_data_converter.ts @@ -51,6 +51,7 @@ function elasticHistogramToHeatmap(seriesList) { * @return {Array} Array of "card" objects */ function convertToCards(buckets) { + let min = 0, max = 0; let cards = []; _.forEach(buckets, xBucket => { _.forEach(xBucket.buckets, yBucket=> { @@ -62,10 +63,19 @@ function convertToCards(buckets) { count: yBucket.count, }; cards.push(card); + + if (cards.length === 1) { + min = yBucket.count; + max = yBucket.count; + } + + min = yBucket.count < min ? yBucket.count : min; + max = yBucket.count > max ? yBucket.count : max; }); }); - return cards; + let cardStats = {min, max}; + return {cards, cardStats}; } /** diff --git a/public/app/plugins/panel/heatmap/module.html b/public/app/plugins/panel/heatmap/module.html index 6cb89f2e1f2..5b5c5296ca1 100644 --- a/public/app/plugins/panel/heatmap/module.html +++ b/public/app/plugins/panel/heatmap/module.html @@ -7,5 +7,8 @@
+
+ +
diff --git a/public/app/plugins/panel/heatmap/module.ts b/public/app/plugins/panel/heatmap/module.ts index d6926455563..d5ef7291308 100644 --- a/public/app/plugins/panel/heatmap/module.ts +++ b/public/app/plugins/panel/heatmap/module.ts @@ -1,5 +1,5 @@ /// - +import './color_legend'; import {HeatmapCtrl} from './heatmap_ctrl'; export { diff --git a/public/app/plugins/panel/heatmap/partials/display_editor.html b/public/app/plugins/panel/heatmap/partials/display_editor.html index 863fcc49d07..f161bf6cab4 100644 --- a/public/app/plugins/panel/heatmap/partials/display_editor.html +++ b/public/app/plugins/panel/heatmap/partials/display_editor.html @@ -25,9 +25,6 @@ -
- -
@@ -37,10 +34,31 @@
-
- -
+ +
+ +
+ + +
+
Color scale
+
+ + +
+
+ + +
+
+ +
+
Legend
+ +
diff --git a/public/app/plugins/panel/heatmap/rendering.ts b/public/app/plugins/panel/heatmap/rendering.ts index de2c2fde719..8d85186b78e 100644 --- a/public/app/plugins/panel/heatmap/rendering.ts +++ b/public/app/plugins/panel/heatmap/rendering.ts @@ -8,7 +8,7 @@ import {appEvents, contextSrv} from 'app/core/core'; import {tickStep, getScaledDecimals, getFlotTickSize} from 'app/core/utils/ticks'; import d3 from 'd3'; import {HeatmapTooltip} from './heatmap_tooltip'; -import {convertToCards, mergeZeroBuckets} from './heatmap_data_converter'; +import {mergeZeroBuckets} from './heatmap_data_converter'; let MIN_CARD_SIZE = 1, CARD_PADDING = 1, @@ -384,10 +384,12 @@ export default function link(scope, elem, attrs, ctrl) { data.buckets = mergeZeroBuckets(data.buckets, _.min(tick_values)); } - let cardsData = convertToCards(data.buckets); - let maxValue = d3.max(cardsData, card => card.count); + let cardsData = data.cards; + let maxValueAuto = data.cardStats.max; + let maxValue = panel.color.max || maxValueAuto; + let minValue = panel.color.min || 0; - colorScale = getColorScale(maxValue); + colorScale = getColorScale(maxValue, minValue); setOpacityScale(maxValue); setCardSize(); @@ -434,14 +436,14 @@ export default function link(scope, elem, attrs, ctrl) { .style("stroke-width", 0); } - function getColorScale(maxValue) { + function getColorScale(maxValue, minValue = 0) { let colorScheme = _.find(ctrl.colorSchemes, {value: panel.color.colorScheme}); let colorInterpolator = d3[colorScheme.value]; let colorScaleInverted = colorScheme.invert === 'always' || (colorScheme.invert === 'dark' && !contextSrv.user.lightTheme); - let start = colorScaleInverted ? maxValue : 0; - let end = colorScaleInverted ? 0 : maxValue; + let start = colorScaleInverted ? maxValue : minValue; + let end = colorScaleInverted ? minValue : maxValue; return d3.scaleSequential(colorInterpolator).domain([start, end]); } @@ -704,78 +706,11 @@ export default function link(scope, elem, attrs, ctrl) { } } - function drawColorLegend() { - d3.select("#heatmap-color-legend").selectAll("rect").remove(); - - let legend = d3.select("#heatmap-color-legend"); - let legendWidth = Math.floor($(d3.select("#heatmap-color-legend").node()).outerWidth()); - let legendHeight = d3.select("#heatmap-color-legend").attr("height"); - - let legendColorScale = getColorScale(legendWidth); - - let rangeStep = 2; - let valuesRange = d3.range(0, legendWidth, rangeStep); - var legendRects = legend.selectAll(".heatmap-color-legend-rect").data(valuesRange); - - legendRects.enter().append("rect") - .attr("x", d => d) - .attr("y", 0) - .attr("width", rangeStep + 1) // Overlap rectangles to prevent gaps - .attr("height", legendHeight) - .attr("stroke-width", 0) - .attr("fill", d => { - return legendColorScale(d); - }); - } - - function drawOpacityLegend() { - d3.select("#heatmap-opacity-legend").selectAll("rect").remove(); - - let legend = d3.select("#heatmap-opacity-legend"); - let legendWidth = Math.floor($(d3.select("#heatmap-opacity-legend").node()).outerWidth()); - let legendHeight = d3.select("#heatmap-opacity-legend").attr("height"); - - let legendOpacityScale; - if (panel.color.colorScale === 'linear') { - legendOpacityScale = d3.scaleLinear() - .domain([0, legendWidth]) - .range([0, 1]); - } else if (panel.color.colorScale === 'sqrt') { - legendOpacityScale = d3.scalePow().exponent(panel.color.exponent) - .domain([0, legendWidth]) - .range([0, 1]); - } - - let rangeStep = 1; - let valuesRange = d3.range(0, legendWidth, rangeStep); - var legendRects = legend.selectAll(".heatmap-opacity-legend-rect").data(valuesRange); - - legendRects.enter().append("rect") - .attr("x", d => d) - .attr("y", 0) - .attr("width", rangeStep) - .attr("height", legendHeight) - .attr("stroke-width", 0) - .attr("fill", panel.color.cardColor) - .style("opacity", d => { - return legendOpacityScale(d); - }); - } - function render() { data = ctrl.data; panel = ctrl.panel; timeRange = ctrl.range; - // Draw only if color editor is opened - if (!d3.select("#heatmap-color-legend").empty()) { - drawColorLegend(); - } - - if (!d3.select("#heatmap-opacity-legend").empty()) { - drawOpacityLegend(); - } - if (!setElementHeight() || !data) { return; } diff --git a/public/app/plugins/panel/heatmap/specs/heatmap_data_converter_specs.ts b/public/app/plugins/panel/heatmap/specs/heatmap_data_converter_specs.ts index 03adf1f9fa3..a898a14ff10 100644 --- a/public/app/plugins/panel/heatmap/specs/heatmap_data_converter_specs.ts +++ b/public/app/plugins/panel/heatmap/specs/heatmap_data_converter_specs.ts @@ -3,7 +3,8 @@ import _ from 'lodash'; import { describe, beforeEach, it, sinon, expect, angularMocks } from '../../../../../test/lib/common'; import TimeSeries from 'app/core/time_series2'; -import { convertToHeatMap, elasticHistogramToHeatmap, calculateBucketSize, isHeatmapDataEqual } from '../heatmap_data_converter'; +import {convertToHeatMap, convertToCards, elasticHistogramToHeatmap, + calculateBucketSize, isHeatmapDataEqual} from '../heatmap_data_converter'; describe('isHeatmapDataEqual', () => { let ctx: any = {}; @@ -244,6 +245,47 @@ describe('ES Histogram converter', () => { }); }); +describe('convertToCards', () => { + let buckets = {}; + + beforeEach(() => { + buckets = { + '1422774000000': { + x: 1422774000000, + buckets: { + '1': { y: 1, values: [1], count: 1, bounds: {} }, + '2': { y: 2, values: [2], count: 1, bounds: {} } + } + }, + '1422774060000': { + x: 1422774060000, + buckets: { + '2': { y: 2, values: [2, 3], count: 2, bounds: {} } + } + }, + }; + }); + + it('should build proper cards data', () => { + let expectedCards = [ + {x: 1422774000000, y: 1, count: 1, values: [1], yBounds: {}}, + {x: 1422774000000, y: 2, count: 1, values: [2], yBounds: {}}, + {x: 1422774060000, y: 2, count: 2, values: [2, 3], yBounds: {}} + ]; + let {cards, cardStats} = convertToCards(buckets); + expect(cards).to.eql(expectedCards); + }); + + it('should build proper cards stats', () => { + let expectedStats = { + min: 1, + max: 2 + }; + let {cards, cardStats} = convertToCards(buckets); + expect(cardStats).to.eql(expectedStats); + }); +}); + /** * Compare two numbers with given precision. Suitable for compare float numbers after conversions with precision loss. * @param a diff --git a/public/app/plugins/panel/heatmap/specs/renderer_specs.ts b/public/app/plugins/panel/heatmap/specs/renderer_specs.ts index 5d3eb665e55..01d09a84228 100644 --- a/public/app/plugins/panel/heatmap/specs/renderer_specs.ts +++ b/public/app/plugins/panel/heatmap/specs/renderer_specs.ts @@ -11,8 +11,7 @@ import TimeSeries from 'app/core/time_series2'; import moment from 'moment'; import { Emitter } from 'app/core/core'; import rendering from '../rendering'; -import { convertToHeatMap } from '../heatmap_data_converter'; -// import d3 from 'd3'; +import {convertToHeatMap, convertToCards} from '../heatmap_data_converter'; describe('grafanaHeatmap', function () { @@ -115,8 +114,9 @@ describe('grafanaHeatmap', function () { let bucketsData = convertToHeatMap(ctx.series, ctx.data.yBucketSize, ctx.data.xBucketSize, logBase); ctx.data.buckets = bucketsData; - // console.log("bucketsData", bucketsData); - // console.log("series", ctrl.panel.yAxis.logBase, ctx.series.length); + let {cards, cardStats} = convertToCards(bucketsData); + ctx.data.cards = cards; + ctx.data.cardStats = cardStats; let elemHtml = `
diff --git a/public/sass/components/_panel_heatmap.scss b/public/sass/components/_panel_heatmap.scss index 9d07e6a363c..134186f25a2 100644 --- a/public/sass/components/_panel_heatmap.scss +++ b/public/sass/components/_panel_heatmap.scss @@ -46,3 +46,46 @@ stroke-width: 1; } } + +.heatmap-selection { + stroke-width: 1; + fill: rgba(102, 102, 102, 0.4); + stroke: rgba(102, 102, 102, 0.8); +} + +.heatmap-legend-wrapper { + @include clearfix(); + margin: 0 $spacer; + padding-top: 10px; + + svg { + width: 100%; + max-width: 300px; + height: 33px; + float: left; + white-space: nowrap; + padding-left: 10px; + } + + .heatmap-legend-values { + display: inline-block; + } + + .axis .tick { + text { + fill: $text-color; + color: $text-color; + font-size: $font-size-sm; + } + + line { + opacity: 0.4; + stroke: $text-color-weak; + } + + .domain { + opacity: 0.4; + stroke: $text-color-weak; + } + } +}