pull/8021/merge
Torkel Ödegaard 8 years ago
commit 1507c02ebb
  1. 300
      public/app/plugins/panel/heatmap/color_legend.ts
  2. 6
      public/app/plugins/panel/heatmap/display_editor.ts
  3. 11
      public/app/plugins/panel/heatmap/heatmap_ctrl.ts
  4. 12
      public/app/plugins/panel/heatmap/heatmap_data_converter.ts
  5. 3
      public/app/plugins/panel/heatmap/module.html
  6. 2
      public/app/plugins/panel/heatmap/module.ts
  7. 30
      public/app/plugins/panel/heatmap/partials/display_editor.html
  8. 83
      public/app/plugins/panel/heatmap/rendering.ts
  9. 44
      public/app/plugins/panel/heatmap/specs/heatmap_data_converter_specs.ts
  10. 8
      public/app/plugins/panel/heatmap/specs/renderer_specs.ts
  11. 43
      public/sass/components/_panel_heatmap.scss

@ -0,0 +1,300 @@
///<reference path="../../../headers/common.d.ts" />
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: '<div class="heatmap-color-legend"><svg width="16.8rem" height="24px"></svg></div>',
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: '<div class="heatmap-color-legend"><svg width="100px" height="14px"></svg></div>',
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;
}

@ -1,4 +1,10 @@
///<reference path="../../../headers/common.d.ts" />
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;

@ -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
};
}

@ -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};
}
/**

@ -7,5 +7,8 @@
<div class="heatmap-panel" ng-dblclick="ctrl.zoomOut()"></div>
</div>
<div class="heatmap-legend-wrapper" ng-if="ctrl.panel.legend.show">
<heatmap-legend></heatmap-legend>
</div>
</div>
<div class="clearfix"></div>

@ -1,5 +1,5 @@
///<reference path="../../../headers/common.d.ts" />
import './color_legend';
import {HeatmapCtrl} from './heatmap_ctrl';
export {

@ -25,9 +25,6 @@
<label class="gf-form-label width-9">Exponent</label>
<input type="number" class="gf-form-input width-8" placeholder="auto" data-placement="right" bs-tooltip="''" ng-model="ctrl.panel.color.exponent" ng-change="ctrl.refresh()" ng-model-onblur>
</div>
<div class="gf-form">
<svg id="heatmap-opacity-legend" width="19em" height="2em"></svg>
</div>
</div>
<div ng-show="ctrl.panel.color.mode === 'spectrum'">
@ -37,10 +34,31 @@
<select class="input-small gf-form-input" ng-model="ctrl.panel.color.colorScheme" ng-options="s.value as s.name for s in ctrl.colorSchemes" ng-change="ctrl.render()"></select>
</div>
</div>
<div class="gf-form">
<svg id="heatmap-color-legend" width="19em" height="2em"></svg>
</div>
</div>
<div class="gf-form">
<color-legend></color-legend>
</div>
</div>
<div class="section gf-form-group">
<h5 class="section-heading">Color scale</h5>
<div class="gf-form">
<label class="gf-form-label width-8">Min</label>
<input type="number" ng-model="ctrl.panel.color.min" class="gf-form-input width-5" placeholder="auto" data-placement="right" bs-tooltip="''" ng-change="ctrl.refresh()" ng-model-onblur>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Max</label>
<input type="number" ng-model="ctrl.panel.color.max" class="gf-form-input width-5" placeholder="auto" data-placement="right" bs-tooltip="''" ng-change="ctrl.refresh()" ng-model-onblur>
</div>
</div>
<div class="section gf-form-group">
<h5 class="section-heading">Legend</h5>
<gf-form-switch class="gf-form" label-class="width-8"
label="Show legend"
checked="ctrl.panel.legend.show" on-change="ctrl.render()">
</gf-form-switch>
</div>
<div class="section gf-form-group">

@ -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;
}

@ -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

@ -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 = `
<div class="heatmap-wrapper">

@ -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;
}
}
}

Loading…
Cancel
Save