discrete color mapping

- use max from multiple values or error if no useMax check.
- tooltip with mapped values for discrete color mode
- tooltip with error messages
- global data errors
- individual bucket errors in tooltip
This commit is contained in:
Ivan Mikheykin
2018-08-29 17:26:24 +03:00
parent 06afdc1ee8
commit 0d7289d3d7
9 changed files with 620 additions and 150 deletions
+157
View File
@@ -38,6 +38,10 @@ mod.directive('optionsColorLegend', function() {
} else if (panel.color.mode === 'opacity') { } else if (panel.color.mode === 'opacity') {
let colorOptions = panel.color; let colorOptions = panel.color;
drawSimpleOpacityLegend(elem, colorOptions); drawSimpleOpacityLegend(elem, colorOptions);
} else if (panel.color.mode === 'discrete') {
let colorOptions = panel.color;
let colorScale = getDiscreteColorScale(colorOptions, legendWidth);
drawSimpleDiscreteColorLegend(elem, colorOptions, colorScale);
} }
} }
} }
@@ -74,6 +78,9 @@ mod.directive('statusHeatmapLegend', function() {
} else if (panel.color.mode === 'opacity') { } else if (panel.color.mode === 'opacity') {
let colorOptions = panel.color; let colorOptions = panel.color;
drawOpacityLegend(elem, colorOptions, rangeFrom, rangeTo, maxValue, minValue); drawOpacityLegend(elem, colorOptions, rangeFrom, rangeTo, maxValue, minValue);
} else if (panel.color.mode === 'discrete') {
let colorOptions = panel.color;
drawDiscreteColorLegend(elem, colorOptions, rangeFrom, rangeTo, maxValue, minValue);
} }
} }
} }
@@ -147,6 +154,37 @@ function drawOpacityLegend(elem, options, rangeFrom, rangeTo, maxValue, minValue
drawLegendValues(elem, opacityScale, rangeFrom, rangeTo, maxValue, minValue, legendWidth); drawLegendValues(elem, opacityScale, rangeFrom, rangeTo, maxValue, minValue, legendWidth);
} }
function drawDiscreteColorLegend(elem, colorOptions, rangeFrom, rangeTo, maxValue, minValue) {
let legendElem = $(elem).find('svg');
let legend = d3.select(legendElem.get(0));
clearLegend(elem);
let thresholds = colorOptions.thresholds;
let legendWidth = Math.floor(legendElem.outerWidth()) - 30;
let legendHeight = legendElem.attr("height");
let valuesNumber = thresholds.length;
let rangeStep = Math.floor(legendWidth / valuesNumber);
let valuesRange = d3.range(0, legendWidth, rangeStep);
let widthFactor = 1; // legendWidth / (rangeTo - rangeFrom);
let colorScale = getDiscreteColorScale(colorOptions, legendWidth);
legend.selectAll(".status-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));
drawDiscreteLegendValues(elem, colorOptions, legendWidth);
}
function drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minValue, legendWidth) { function drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minValue, legendWidth) {
let legendElem = $(elem).find('svg'); let legendElem = $(elem).find('svg');
let legend = d3.select(legendElem.get(0)); let legend = d3.select(legendElem.get(0));
@@ -176,6 +214,62 @@ function drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minVal
legend.select(".axis").select(".domain").remove(); legend.select(".axis").select(".domain").remove();
} }
function drawDiscreteLegendValues(elem, colorOptions, legendWidth) {
let thresholds = colorOptions.thresholds;
let legendElem = $(elem).find('svg');
let legend = d3.select(legendElem.get(0));
if (legendWidth <= 0 || legendElem.get(0).childNodes.length === 0) {
return;
}
let valuesNumber = thresholds.length;
let rangeStep = Math.floor(legendWidth / valuesNumber);
let valuesRange = d3.range(0, legendWidth, rangeStep);
let legendValueScale = d3.scaleLinear()
.domain([0, valuesNumber])
.range([0, legendWidth]);
let thresholdValues = [];
let thresholdTooltips = [];
for (let i = 0; i < thresholds.length; i++) {
thresholdValues.push(thresholds[i].value);
thresholdTooltips.push(thresholds[i].tooltip);
}
let xAxis = d3.axisBottom(legendValueScale)
.tickValues(d3.range(0, valuesNumber, 1)) //thresholdValues)
.tickSize(2)
.tickFormat((t) => {
let i = Math.floor(t);
let v = thresholdTooltips[i];
if (v != undefined) {
return ""+v;
} else {
v = thresholdValues[i];
if (v != undefined) {
return ""+v;
} else {
return "n/a";
}
}
});
let colorRect = legendElem.find(":first-child");
let posY = getSvgElemHeight(legendElem) + 2;
let posX = getSvgElemX(colorRect) + Math.floor(rangeStep/2);
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) { function drawSimpleColorLegend(elem, colorScale) {
let legendElem = $(elem).find('svg'); let legendElem = $(elem).find('svg');
clearLegend(elem); clearLegend(elem);
@@ -236,6 +330,33 @@ function drawSimpleOpacityLegend(elem, options) {
} }
} }
function drawSimpleDiscreteColorLegend(elem, colorOptions, colorScale) {
let thresholds = colorOptions.thresholds;
let legendElem = $(elem).find('svg');
clearLegend(elem);
let legendWidth = Math.floor(legendElem.outerWidth());
let legendHeight = legendElem.attr("height");
if (legendWidth) {
let valuesNumber = thresholds.length;
let rangeStep = Math.floor(legendWidth / valuesNumber);
let valuesRange = d3.range(0, legendWidth, rangeStep);
let legend = d3.select(legendElem.get(0));
var legendRects = legend.selectAll(".status-heatmap-discrete-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 clearLegend(elem) { function clearLegend(elem) {
let legendElem = $(elem).find('svg'); let legendElem = $(elem).find('svg');
legendElem.empty(); legendElem.empty();
@@ -252,6 +373,42 @@ function getColorScale(colorScheme, maxValue, minValue = 0) {
return d3.scaleSequential(colorInterpolator).domain([start, end]); return d3.scaleSequential(colorInterpolator).domain([start, end]);
} }
// scale input range to discrete colors to draw a legend
function getDiscreteColorScale(colorOptions, maxValue, minValue = 0) {
let start = minValue;
let end = maxValue;
let thresholdValues = [];
let thresholdColors = [];
for (let i = 0; i < colorOptions.thresholds.length; i++) {
thresholdColors.push(colorOptions.thresholds[i].color);
thresholdValues.push(colorOptions.thresholds[i].value);
}
// TODO sort colors by value and index?
let thresholdScaler = (d) => {
let color = thresholdColors[Math.floor(d)];
if (color != undefined) {
return color
}
return 'rgba(0,0,0,0)';
// for (let i = 0; i < thresholdValues.length; i++ ) {
// if (d == thresholdValues[i]) {
// return thresholdColors[i];
// }
// }
// return thresholdColors[0];
};
let inputRangeScaler = d3.scaleLinear().domain([start, end]).range([0, thresholdColors.length+1]);
// scale min-max to 0 - max-thrs-value
return function(d) {
return thresholdScaler(inputRangeScaler(d));
}
}
function getOpacityScale(options, maxValue, minValue = 0) { function getOpacityScale(options, maxValue, minValue = 0) {
let legendOpacityScale; let legendOpacityScale;
if (options.colorScale === 'linear') { if (options.colorScale === 'linear') {
+73
View File
@@ -0,0 +1,73 @@
import _ from 'lodash';
// Helper methods to handle discrete color mode
export class ColorModeDiscrete {
constructor(scope) {
this.scope = scope;
this.panelCtrl = scope.ctrl;
this.panel = scope.ctrl.panel;
}
// get tooltip for each value ordered by thresholds priority
convertValuesToTooltips(values) {
let thresholds = this.panel.color.thresholds;
let tooltips = [];
for (let i = 0; i < thresholds.length; i++) {
for (let j = 0; j < values.length; j++) {
if (values[j] === thresholds[i].value) {
tooltips.push(thresholds[i].tooltip);
}
}
}
return tooltips;
}
getNotMatchedValues(values) {
let notMatched = [];
for (let j = 0; j < values.length; j++) {
if (!this.getMatchedThreshold(values[j])) {
notMatched.push(values[j]);
}
}
return notMatched;
}
getDiscreteColorScale() {
return (d) => {
let threshold = this.getMatchedThreshold(d);
if (!threshold) {
// Error if value not in thresholds
return 'rgba(0,0,0,1)';
}
else {
return threshold.color;
}
};
}
updateCardsValuesHasColorInfo() {
let cards = this.panelCtrl.cardsData.cards;
for (let i=0; i<cards.length; i++) {
let values = cards[i].values;
for (let j=0; j<values.length; j++) {
if (!this.getMatchedThreshold(values[j])) {
cards[i].noColorDefined = true;
this.panelCtrl.cardsData.noColorDefined = true;
break;
}
}
}
}
getMatchedThreshold(value) {
let thresholds = this.panel.color.thresholds;
for (let k = 0; k < thresholds.length; k++) {
if (value === thresholds[k].value) {
return thresholds[k];
}
}
return null;
}
}
+5
View File
@@ -4,6 +4,11 @@ $font-size-sm: 12px !default;
.status-heatmap-canvas-wrapper { .status-heatmap-canvas-wrapper {
// position: relative; // position: relative;
cursor: crosshair; cursor: crosshair;
.datapoints-warning span {
background-color: white;
padding: 1px;
}
} }
.status-heatmap-panel { .status-heatmap-panel {
+3 -2
View File
@@ -1,8 +1,9 @@
<div class="status-heatmap-wrapper"> <div class="status-heatmap-wrapper">
<div class="status-heatmap-canvas-wrapper"> <div class="status-heatmap-canvas-wrapper">
<div class="datapoints-warning" ng-if="ctrl.dataWarning"> <div class="datapoints-warning">
<span class="small" bs-tooltip="ctrl.dataWarning.tip">{{ctrl.dataWarning.title}}</span> <span class="small" ng-if="ctrl.multipleValues" bs-tooltip="{{ctrl.dataWarnings.multipleValues.tip}}">{{ctrl.dataWarnings.multipleValues.title}}</span>
<span class="small" ng-if="ctrl.noColorDefined" bs-tooltip="{{ctrl.dataWarnings.noColorDefined.tip}}">{{ctrl.dataWarnings.noColorDefined.title}}</span>
</div> </div>
<div class="status-heatmap-panel" ng-dblclick="ctrl.zoomOut()"></div> <div class="status-heatmap-panel" ng-dblclick="ctrl.zoomOut()"></div>
@@ -1,3 +1,11 @@
<div ng-show="ctrl.multipleValues">
<strong>Error</strong>: data has multiple values for one target. Please change target or check "use max value".
</div>
<div ng-show="ctrl.noColorDefined">
<strong>Error</strong>: data value with undefined color. Check metric values, color values or define new color.
</div>
<div class="editor-row"> <div class="editor-row">
<div class="section gf-form-group"> <div class="section gf-form-group">
<h5 class="section-heading">Colors</h5> <h5 class="section-heading">Colors</h5>
@@ -36,64 +44,104 @@
</div> </div>
</div> </div>
<div ng-show="ctrl.panel.color.mode === 'discrete'">
<!-- <gf-form-switch class="gf-form" label-class="width-9"
label="Invert order"
checked="ctrl.panel.color.invertedThresholds" on-change="ctrl.render()">
</gf-form-switch> -->
<div class="gf-form"> <div class="gf-form">
<strong>Note:</strong> Bucket color determined by maximum for multiple values<br/>
</div>
<div class="gf-form" ng-repeat="threshold in ctrl.panel.color.thresholds">
<label class="gf-form-label width-9">If bucket value ==</label><!-- {{ $index + 1 }} -->
<input type="text" class="input-small gf-form-input width-4" ng-model="threshold.value" ng-change="ctrl.render()"/>
<label class="gf-form-label">
fill
</label>
<label class="gf-form-label">
<spectrum-picker ng-model="threshold.color" ng-change="ctrl.render()"/>
</label>
<label class="gf-form-label">set tooltip</label>
<input type="text" class="input-small gf-form-input width-9" ng-model="threshold.tooltip" ng-change="ctrl.render()"/>
<label class="gf-form-label">
<a class="pointer" ng-click="ctrl.onEditorRemoveThreshold($index)">
<i class="fa fa-trash"/>
</a>
</label>
</div>
<div class="gf-form">
<label class="gf-form-label" ng-show="ctrl.panel.color.thresholds.length == 0">
<a class="pointer" ng-click="ctrl.onEditorAddThreeLights()">
Add trafic lights
</a>
</label>
<label class="gf-form-label">
<a class="pointer" ng-click="ctrl.onEditorAddThreshold()">
Add new status
</a>
</label>
</div>
</div>
<div class="gf-form" ng-show="ctrl.panel.color.mode !== 'discrete'">
<options-color-legend></options-color-legend> <options-color-legend></options-color-legend>
</div> </div>
</div> </div>
<div class="section gf-form-group"> <div class="section gf-form-group" ng-show="ctrl.panel.color.mode !== 'discrete'">
<h5 class="section-heading">Color scale</h5> <h5 class="section-heading">Color scale</h5>
<div class="gf-form"> <div class="gf-form">
<label class="gf-form-label width-8">Min</label> <label class="gf-form-label width-8">Min value</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> <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>
<div class="gf-form"> <div class="gf-form">
<label class="gf-form-label width-8">Max</label> <label class="gf-form-label width-8">Max value</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> <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> </div>
<div class="section gf-form-group"> <div class="section gf-form-group">
<h5 class="section-heading">Legend</h5> <h5 class="section-heading">Display</h5>
<gf-form-switch class="gf-form" label-class="width-8" <gf-form-switch class="gf-form" label-class="width-8"
label="Show legend" label="Show legend"
checked="ctrl.panel.legend.show" on-change="ctrl.render()"> checked="ctrl.panel.legend.show" on-change="ctrl.render()">
</gf-form-switch> </gf-form-switch>
<gf-form-switch class="gf-form" label-class="width-8"
label="Show tooltip"
checked="ctrl.panel.tooltip.show" on-change="ctrl.render()">
</gf-form-switch>
</div> </div>
<div class="section gf-form-group"> <div class="section gf-form-group">
<h5 class="section-heading">Buckets</h5> <h5 class="section-heading">Graph</h5>
<gf-form-switch class="gf-form" label-class="width-9"
label="Use max value for color"
checked="ctrl.panel.useMax" on-change="ctrl.render()">
</gf-form-switch>
<div class="gf-form"> <div class="gf-form">
<label class="gf-form-label width-8">Space</label> <label class="gf-form-label width-9">Display nulls</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input max-width-9" ng-model="ctrl.panel.nullPointMode" ng-options="f for f in ['as empty', 'as zero']" ng-change="ctrl.render()"></select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-9">Buckets spacing</label>
<input type="number" class="gf-form-input width-5" placeholder="auto" data-placement="right" bs-tooltip="''" ng-model="ctrl.panel.cards.cardPadding" ng-change="ctrl.refresh()" ng-model-onblur> <input type="number" class="gf-form-input width-5" placeholder="auto" data-placement="right" bs-tooltip="''" ng-model="ctrl.panel.cards.cardPadding" ng-change="ctrl.refresh()" ng-model-onblur>
</div> </div>
<div class="gf-form"> <div class="gf-form">
<label class="gf-form-label width-8">Round</label> <label class="gf-form-label width-9">Bucket rounding</label>
<input type="number" class="gf-form-input width-5" placeholder="auto" data-placement="right" bs-tooltip="''" ng-model="ctrl.panel.cards.cardRound" ng-change="ctrl.refresh()" ng-model-onblur> <input type="number" class="gf-form-input width-5" placeholder="auto" data-placement="right" bs-tooltip="''" ng-model="ctrl.panel.cards.cardRound" ng-change="ctrl.refresh()" ng-model-onblur>
</div> </div>
</div> </div>
<div class="section gf-form-group"> <div class="section gf-form-group">
<h5 class="section-heading">Tooltip</h5>
<gf-form-switch class="gf-form" label-class="width-8"
label="Show tooltip"
checked="ctrl.panel.tooltip.show" on-change="ctrl.render()">
</gf-form-switch>
<div ng-if="ctrl.panel.tooltip.show">
<gf-form-switch class="gf-form" label-class="width-8"
label="Histogram"
checked="ctrl.panel.tooltip.showHistogram" on-change="ctrl.render()">
</gf-form-switch>
</div>
</div> </div>
<div class="section gf-form-group"> <div class="section gf-form-group">
<h5 class="section-heading">Null value</h5>
<div class="gf-form">
<label class="gf-form-label width-7">Null value</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input max-width-9" ng-model="ctrl.panel.nullPointMode" ng-options="f for f in ['null', 'null as zero']" ng-change="ctrl.render()"></select>
</div>
</div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -21,7 +21,7 @@ export function statusHeatmapOptionsEditor() {
return { return {
restrict: 'E', restrict: 'E',
scope: true, scope: true,
templateUrl: 'public/plugins/status-heatmap-panel/partials/options_editor.html', templateUrl: 'public/plugins/status-heatmap-panel/options_editor.html',
controller: StatusHeatmapOptionsEditorCtrl, controller: StatusHeatmapOptionsEditorCtrl,
}; };
} }
+121 -78
View File
@@ -49,7 +49,6 @@ export default function link(scope, elem, attrs, ctrl) {
ctrl.events.on('render', () => { ctrl.events.on('render', () => {
render(); render();
// ctrl.renderingCompleted();
}); });
function setElementHeight() { function setElementHeight() {
@@ -124,28 +123,44 @@ export default function link(scope, elem, attrs, ctrl) {
heatmap.select(".axis-x").select(".domain").remove(); heatmap.select(".axis-x").select(".domain").remove();
} }
function addYAxis() { // divide chart height by ticks for cards drawing
let ticks = _.map(data, d => d.target); function getYScale(ticks) {
let range = [];
// Set default Y min and max if no data let step = chartHeight / ticks.length;
if (_.isEmpty(data)) { range.push(chartHeight);
ticks = ['']; for (let i = 1; i < ticks.length; i++) {
range.push(chartHeight - step * i);
}
return d3.scaleOrdinal()
.domain(ticks)
.range(range);
} }
// divide chart height by ticks with offset for ticks drawing
function getYAxisScale(ticks) {
let range = []; let range = [];
let step = chartHeight / ticks.length; let step = chartHeight / ticks.length;
range.push(chartHeight - yOffset); range.push(chartHeight - yOffset);
for (let i = 1; i < ticks.length; i++) { for (let i = 1; i < ticks.length; i++) {
range.push(chartHeight - step * i - yOffset); range.push(chartHeight - step * i - yOffset);
} }
return d3.scaleOrdinal()
console.log('yRange', range, yOffset);
scope.yScale = yScale = d3.scaleOrdinal()
.domain(ticks) .domain(ticks)
.range(range); .range(range);
}
let yAxis = d3.axisLeft(yScale) function addYAxis() {
let ticks = _.uniq(_.map(data, d => d.target));
// Set default Y min and max if no data
if (_.isEmpty(data)) {
ticks = [''];
}
let yAxisScale = getYAxisScale(ticks);
scope.yScale = yScale = getYScale(ticks);
let yAxis = d3.axisLeft(yAxisScale)
.tickValues(ticks) .tickValues(ticks)
.tickSizeInner(0 - width) .tickSizeInner(0 - width)
.tickPadding(Y_AXIS_TICK_PADDING); .tickPadding(Y_AXIS_TICK_PADDING);
@@ -164,7 +179,7 @@ export default function link(scope, elem, attrs, ctrl) {
heatmap.select(".axis-y").selectAll(".tick line").remove(); heatmap.select(".axis-y").selectAll(".tick line").remove();
} }
// Wide Y values range and anjust to bucket size // Wide Y values range and adjust to bucket size
function wideYAxisRange(min, max, tickInterval) { function wideYAxisRange(min, max, tickInterval) {
let y_widing = (max * (dataRangeWidingFactor - 1) - min * (dataRangeWidingFactor - 1)) / 2; let y_widing = (max * (dataRangeWidingFactor - 1) - min * (dataRangeWidingFactor - 1)) / 2;
let y_min, y_max; let y_min, y_max;
@@ -193,19 +208,43 @@ export default function link(scope, elem, attrs, ctrl) {
}; };
} }
function fixYAxisTickSize() { // Create svg element, add axes and
heatmap.select(".axis-y") // calculate sizes for cards drawing
.selectAll(".tick line") function addHeatmapCanvas() {
.attr("x2", chartWidth); let heatmap_elem = $heatmap[0];
width = Math.floor($heatmap.width()) - padding.right;
height = Math.floor($heatmap.height()) - padding.bottom;
if (heatmap) {
heatmap.remove();
} }
function addAxes() { heatmap = d3.select(heatmap_elem)
.append("svg")
.attr("width", width)
.attr("height", height);
chartHeight = height - margin.top - margin.bottom;
chartTop = margin.top;
chartBottom = chartTop + chartHeight;
cardPadding = panel.cards.cardPadding !== null ? panel.cards.cardPadding : CARD_PADDING;
cardRound = panel.cards.cardRound !== null ? panel.cards.cardRound : CARD_ROUND;
// calculate yOffset for YAxis
let yGridSize = Math.floor(chartHeight / cardsData.yBucketSize);
cardHeight = yGridSize ? yGridSize - cardPadding * 2 : 0;
yOffset = cardHeight / 2;
addYAxis(); addYAxis();
yAxisWidth = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING; yAxisWidth = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
chartWidth = width - yAxisWidth - margin.right; chartWidth = width - yAxisWidth - margin.right;
// fixYAxisTickSize();
// let xGridSize = Math.floor(chartWidth / cardsData.xBucketSize);
cardWidth = xGridSize - cardPadding * 2;
addXAxis(); addXAxis();
xAxisHeight = getXAxisHeight(heatmap); xAxisHeight = getXAxisHeight(heatmap);
@@ -218,49 +257,25 @@ export default function link(scope, elem, attrs, ctrl) {
} }
} }
function addHeatmapCanvas() {
let heatmap_elem = $heatmap[0];
width = Math.floor($heatmap.width()) - padding.right;
height = Math.floor($heatmap.height()) - padding.bottom;
chartHeight = height - margin.top - margin.bottom - yOffset;
chartTop = margin.top;
chartBottom = chartTop + chartHeight;
cardPadding = panel.cards.cardPadding !== null ? panel.cards.cardPadding : CARD_PADDING;
cardRound = panel.cards.cardRound !== null ? panel.cards.cardRound : CARD_ROUND;
if (heatmap) {
heatmap.remove();
}
heatmap = d3.select(heatmap_elem)
.append("svg")
.attr("width", width)
.attr("height", height);
}
function addHeatmap() { function addHeatmap() {
addHeatmapCanvas(); addHeatmapCanvas();
let maxValue = panel.color.max || cardsData.maxValue; let maxValue = panel.color.max || cardsData.maxValue;
let minValue = panel.color.min || cardsData.minValue; let minValue = panel.color.min || cardsData.minValue;
if (panel.color.mode === 'discrete') {
colorScale = ctrl.discreteHelper.getDiscreteColorScale();
} else {
colorScale = getColorScale(maxValue, minValue); colorScale = getColorScale(maxValue, minValue);
}
setOpacityScale(maxValue); setOpacityScale(maxValue);
setCardSize();
addAxes();
let cards = heatmap.selectAll(".status-heatmap-card").data(cardsData.cards); let cards = heatmap.selectAll(".status-heatmap-card").data(cardsData.cards);
cards.append("title"); cards.append("title");
cards = cards.enter().append("rect") cards = cards.enter().append("rect")
.attr("value", c => c.value) .attr("cardId", c => c.id)
.attr("xVal", c => c.x)
.attr("x", getCardX) .attr("x", getCardX)
.attr("width", getCardWidth) .attr("width", getCardWidth)
.attr("yVal", c => c.y)
.attr("y", getCardY) .attr("y", getCardY)
.attr("height", getCardHeight) .attr("height", getCardHeight)
.attr("rx", cardRound) .attr("rx", cardRound)
@@ -269,14 +284,14 @@ export default function link(scope, elem, attrs, ctrl) {
.style("fill", getCardColor) .style("fill", getCardColor)
.style("stroke", getCardColor) .style("stroke", getCardColor)
.style("stroke-width", 0) .style("stroke-width", 0)
//.style("stroke-width", getCardStrokeWidth)
//.style("stroke-dasharray", "3,3")
.style("opacity", getCardOpacity); .style("opacity", getCardOpacity);
let $cards = $heatmap.find(".status-heatmap-card"); let $cards = $heatmap.find(".status-heatmap-card");
$cards.on("mouseenter", (event) => { $cards.on("mouseenter", (event) => {
tooltip.mouseOverBucket = true; tooltip.mouseOverBucket = true;
highlightCard(event); highlightCard(event);
let current_card = d3.select(event.target);
tooltip.show(event, current_card.attr('xVal'), current_card.attr('yVal'), current_card.attr('value'));
}) })
.on("mouseleave", (event) => { .on("mouseleave", (event) => {
tooltip.mouseOverBucket = false; tooltip.mouseOverBucket = false;
@@ -296,7 +311,9 @@ export default function link(scope, elem, attrs, ctrl) {
} }
function resetCardHighLight(event) { function resetCardHighLight(event) {
d3.select(event.target).style("fill", tooltip.originalFillColor) d3
.select(event.target)
.style("fill", tooltip.originalFillColor)
.style("stroke", tooltip.originalFillColor) .style("stroke", tooltip.originalFillColor)
.style("stroke-width", 0); .style("stroke-width", 0);
} }
@@ -316,6 +333,36 @@ export default function link(scope, elem, attrs, ctrl) {
return d3.scaleSequential(colorInterpolator).domain([start, end]); return d3.scaleSequential(colorInterpolator).domain([start, end]);
} }
// scale input range to discrete colors to draw a legend
function getDiscreteColorScale() {
let thresholds = panel.color.thresholds;
let thresholdValues = [];
let thresholdColors = [];
for (let i = 0; i < thresholds.length; i++) {
thresholdColors.push(thresholds[i].color);
thresholdValues.push(thresholds[i].value);
}
// TODO sort colors by value and index?
let thresholdScaler = (d) => {
for (let i = 0; i < thresholdValues.length; i++ ) {
if (d == thresholdValues[i]) {
return thresholdColors[i];
}
}
// Error if value not in thresholds
return 'rgba(0,0,0,1)';
};
// scale min-max to 0 - max-thrs-value
return function(d) {
return thresholdScaler(d);
}
}
function setOpacityScale(maxValue) { function setOpacityScale(maxValue) {
if (panel.color.colorScale === 'linear') { if (panel.color.colorScale === 'linear') {
opacityScale = d3.scaleLinear() opacityScale = d3.scaleLinear()
@@ -328,16 +375,6 @@ export default function link(scope, elem, attrs, ctrl) {
} }
} }
function setCardSize() {
let xGridSize = Math.floor(chartWidth / cardsData.xBucketSize);
let yGridSize = Math.floor(chartHeight / cardsData.yBucketSize);
cardWidth = xGridSize - cardPadding * 2;
cardHeight = yGridSize ? yGridSize - cardPadding * 2 : 0;
yOffset = cardHeight / 2;
}
function getCardX(d) { function getCardX(d) {
let x; let x;
if (xScale(d.x) < 0) { if (xScale(d.x) < 0) {
@@ -369,25 +406,22 @@ export default function link(scope, elem, attrs, ctrl) {
} }
function getCardY(d) { function getCardY(d) {
let y = yScale(d.y); return yScale(d.y) + chartTop - cardHeight - cardPadding;
y = y + chartTop - cardHeight - cardPadding + yOffset;
return y;
} }
function getCardHeight(d) { function getCardHeight(d) {
let y = yScale(d.y) + chartTop - cardHeight - cardPadding; let ys = yScale(d.y);
let y = ys + chartTop - cardHeight - cardPadding;
let h = cardHeight; let h = cardHeight;
// Cut card height to prevent overlay // Cut card height to prevent overlay
// if (y < chartTop) { if (y < chartTop) {
// h = yScale(d.y) - cardPadding; h = ys - cardPadding;
// } else if (yScale(d.y) > chartBottom) { } else if (ys > chartBottom) {
// h = chartBottom - y; h = chartBottom - y;
// } else if (y + cardHeight > chartBottom) { } else if (y + cardHeight > chartBottom) {
// h = chartBottom - y; h = chartBottom - y;
// } }
// Height can't be more than chart height // Height can't be more than chart height
h = Math.min(h, chartHeight); h = Math.min(h, chartHeight);
@@ -400,13 +434,15 @@ export default function link(scope, elem, attrs, ctrl) {
function getCardColor(d) { function getCardColor(d) {
if (panel.color.mode === 'opacity') { if (panel.color.mode === 'opacity') {
return panel.color.cardColor; return panel.color.cardColor;
} else { } else if (panel.color.mode === 'spectrum') {
return colorScale(d.value);
} else if (panel.color.mode === 'discrete') {
return colorScale(d.value); return colorScale(d.value);
} }
} }
function getCardOpacity(d) { function getCardOpacity(d) {
if (panel.nullPointMode === 'null' && d.value == null ) { if (panel.nullPointMode === 'as empty' && d.value == null ) {
return 0; return 0;
} }
if (panel.color.mode === 'opacity') { if (panel.color.mode === 'opacity') {
@@ -416,6 +452,13 @@ export default function link(scope, elem, attrs, ctrl) {
} }
} }
function getCardStrokeWidth(d) {
if (panel.color.mode === 'discrete') {
return '1';
}
return '0';
}
///////////////////////////// /////////////////////////////
// Selection and crosshair // // Selection and crosshair //
///////////////////////////// /////////////////////////////
@@ -477,6 +520,7 @@ export default function link(scope, elem, attrs, ctrl) {
} else { } else {
emitGraphHoverEvet(event); emitGraphHoverEvet(event);
drawCrosshair(event.offsetX); drawCrosshair(event.offsetX);
tooltip.show(event); //, data);
} }
} }
@@ -576,7 +620,6 @@ export default function link(scope, elem, attrs, ctrl) {
// Draw default axes and return if no data // Draw default axes and return if no data
if (_.isEmpty(cardsData.cards)) { if (_.isEmpty(cardsData.cards)) {
addHeatmapCanvas(); addHeatmapCanvas();
addAxes();
return; return;
} }
+120 -30
View File
@@ -7,8 +7,8 @@ import rendering from './rendering';
// import aggregates, { aggregatesMap } from './aggregates'; // import aggregates, { aggregatesMap } from './aggregates';
// import fragments, { fragmentsMap } from './fragments'; // import fragments, { fragmentsMap } from './fragments';
// import { labelFormats } from './xAxisLabelFormats'; // import { labelFormats } from './xAxisLabelFormats';
// import canvasRendering from './canvas/rendering';
import {statusHeatmapOptionsEditor} from './options_editor'; import {statusHeatmapOptionsEditor} from './options_editor';
import {ColorModeDiscrete} from "./color_mode_discrete";
import './css/status-heatmap.css!'; import './css/status-heatmap.css!';
const CANVAS = 'CANVAS'; const CANVAS = 'CANVAS';
@@ -24,7 +24,10 @@ const panelDefaults = {
cardColor: '#b4ff00', cardColor: '#b4ff00',
colorScale: 'sqrt', colorScale: 'sqrt',
exponent: 0.5, exponent: 0.5,
colorScheme: 'interpolateGnYlRd' colorScheme: 'interpolateGnYlRd',
// discrete mode settings
defaultColor: '#757575',
thresholds: [] // manual colors
}, },
cards: { cards: {
cardPadding: null, cardPadding: null,
@@ -52,8 +55,9 @@ const panelDefaults = {
decimals: null decimals: null
}, },
// how null points should be handled // how null points should be handled
nullPointMode: 'null', nullPointMode: 'as empty',
highlightCards: true highlightCards: true,
useMax: true
}; };
const renderer = CANVAS; const renderer = CANVAS;
@@ -87,7 +91,7 @@ const colorSchemes = [
{ name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'darm' } { name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'darm' }
]; ];
let colorModes = ['opacity', 'spectrum']; let colorModes = ['opacity', 'spectrum', 'discrete'];
let opacityScales = ['linear', 'sqrt']; let opacityScales = ['linear', 'sqrt'];
export class StatusHeatmapCtrl extends MetricsPanelCtrl { export class StatusHeatmapCtrl extends MetricsPanelCtrl {
@@ -101,16 +105,34 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
this.colorModes = colorModes; this.colorModes = colorModes;
this.colorSchemes = colorSchemes; this.colorSchemes = colorSchemes;
this.multipleValues = false;
this.noColorDefined = false;
this.discreteHelper = new ColorModeDiscrete($scope);
this.dataWarnings = {
"noColorDefined": {
title: 'Data has value with undefined color',
tip: 'Check metric values, color values or define a new color',
},
"multipleValues": {
title: 'Data has multiple values for one target',
tip: 'Change targets definitions or set "use max value"',
}
};
this.events.on('data-received', this.onDataReceived); this.events.on('data-received', this.onDataReceived);
this.events.on('data-snapshot-load', this.onDataReceived); this.events.on('data-snapshot-load', this.onDataReceived);
this.events.on('data-error', this.onDataError); this.events.on('data-error', this.onDataError);
this.events.on('init-edit-mode', this.onInitEditMode); this.events.on('init-edit-mode', this.onInitEditMode);
this.events.on('render', this.onRender); this.events.on('render', this.onRender);
this.events.on('refresh', this.postRefresh);
} }
onDataReceived = (dataList) => { onDataReceived = (dataList) => {
this.data = dataList; this.data = dataList;
this.cardsData = this.convertToCards(this.data); this.cardsData = this.convertToCards(this.data);
this.render(); this.render();
}; };
@@ -121,6 +143,19 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
onRender = () => { onRender = () => {
if (!this.data) { return; } if (!this.data) { return; }
this.multipleValues = false;
if (!this.panel.useMax) {
if (!_.isEmpty(this.cardsData)) {
this.multipleValues = this.cardsData.multipleValues;
}
}
this.noColorDefined = false;
if (this.panel.color.mode === 'discrete') {
this.discreteHelper.updateCardsValuesHasColorInfo();
this.noColorDefined = this.cardsData.noColorDefined;
}
}; };
onCardColorChange = (newColor) => { onCardColorChange = (newColor) => {
@@ -133,45 +168,99 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
this.render(); this.render();
}; };
link = (scope, elem, attrs, ctrl) => { postRefresh = () => {
console.log('LINK'); this.noColorDefined = false;
rendering(scope, elem, attrs, ctrl);
// switch (renderer) {
// case CANVAS: {
// canvasRendering(scope, elem, attrs, ctrl);
// break;
// }
// case SVG: {
// svgRendering(scope, elem, attrs, ctrl);
// break;
// }
// }
}; };
onEditorAddThreshold = () => {
this.panel.color.thresholds.push({ color: this.panel.defaultColor });
this.render();
};
onEditorRemoveThreshold = (index) => {
this.panel.color.thresholds.splice(index, 1);
this.render();
};
onEditorAddThreeLights = () => {
this.panel.color.thresholds.push({color: "red", value: 2, tooltip: "error" });
this.panel.color.thresholds.push({color: "yellow", value: 1, tooltip: "warning" });
this.panel.color.thresholds.push({color: "green", value: 0, tooltip: "ok" });
this.render();
};
link = (scope, elem, attrs, ctrl) => {
rendering(scope, elem, attrs, ctrl);
};
// group values into buckets by target
convertToCards = (data) => { convertToCards = (data) => {
let cardsData = { cards: [], xBucketSize: 0, yBucketSize: 0, maxValue: 0, minValue: 0 }; let cardsData = {
cards: [],
xBucketSize: 0,
yBucketSize: 0,
maxValue: 0,
minValue: 0,
multipleValues: false,
noColorDefined: false,
};
if (!data || data.length == 0) { return cardsData;} if (!data || data.length == 0) { return cardsData;}
cardsData.yBucketSize = data.length; // collect uniq targets and their indexes in data array
cardsData.targetIndex = {};
for (let i = 0; i < data.length; i++) {
let ts = data[i];
let target = ts.target;
if (cardsData.targetIndex[target] == undefined) {
cardsData.targetIndex[target] = []
}
cardsData.targetIndex[target].push(i);
}
// TODO add some logic for targets heirarchy
cardsData.targets = _.keys(cardsData.targetIndex);
cardsData.targets.sort();
cardsData.yBucketSize = cardsData.targets.length;
cardsData.xBucketSize = _.min(_.map(data, d => d.datapoints.length)); cardsData.xBucketSize = _.min(_.map(data, d => d.datapoints.length));
for(let i = 0; i < cardsData.yBucketSize; i++) { // Collect all values for each bucket from datapoints with similar target.
let s = data[i]; for(let i = 0; i < cardsData.targets.length; i++) {
let target = cardsData.targets[i];
for (let j = 0; j < cardsData.xBucketSize; j++) { for (let j = 0; j < cardsData.xBucketSize; j++) {
let card = {}; let card = {
let v = s.datapoints[j]; id: i*cardsData.xBucketSize + j,
values: [],
multipleValues: false,
noColorDefined: false,
};
card.x = v[TIME_INDEX]; // collect values from all timeseries with target
for (let si = 0; si < cardsData.targetIndex[target].length; si++) {
let s = data[cardsData.targetIndex[target][si]];
let datapoint = s.datapoints[j];
if (card.values.length === 0) {
card.x = datapoint[TIME_INDEX];
card.y = s.target; card.y = s.target;
card.value = v[VALUE_INDEX]; }
card.values.push(datapoint[VALUE_INDEX]);
}
card.minValue = _.min(card.values);
card.maxValue = _.max(card.values);
if (card.values.length > 1) {
cardsData.multipleValues = true;
card.multipleValues = true;
card.value = card.maxValue; // max value by default
} else {
card.value = card.maxValue; // max value by default
}
if (cardsData.maxValue < card.value) if (cardsData.maxValue < card.maxValue)
cardsData.maxValue = card.value; cardsData.maxValue = card.maxValue;
if (cardsData.minValue > card.value) if (cardsData.minValue > card.minValue)
cardsData.minValue = card.value; cardsData.minValue = card.minValue;
cardsData.cards.push(card); cardsData.cards.push(card);
} }
@@ -179,4 +268,5 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
return cardsData; return cardsData;
}; };
} }
+59 -6
View File
@@ -5,8 +5,6 @@ import kbn from 'app/core/utils/kbn';
let TOOLTIP_PADDING_X = 30; let TOOLTIP_PADDING_X = 30;
let TOOLTIP_PADDING_Y = 5; let TOOLTIP_PADDING_Y = 5;
let HISTOGRAM_WIDTH = 160;
let HISTOGRAM_HEIGHT = 40;
export class StatusHeatmapTooltip { export class StatusHeatmapTooltip {
constructor(elem, scope) { constructor(elem, scope) {
@@ -55,28 +53,83 @@ export class StatusHeatmapTooltip {
this.tooltip = null; this.tooltip = null;
} }
show(pos, x, y, value) { show(pos) {
if (!this.panel.tooltip.show) { return; } if (!this.panel.tooltip.show || !this.tooltip) { return; }
// shared tooltip mode // shared tooltip mode
if (pos.panelRelY) { if (pos.panelRelY) {
return; return;
} }
let cardId = d3.select(pos.target).attr('cardId');
if (!x || !y || !this.tooltip) { if (!cardId) {
this.destroy(); this.destroy();
return; return;
} }
let card = this.panelCtrl.cardsData.cards[cardId];
if (!card) {
this.destroy();
return;
}
let x = card.x;
let y = card.y;
let value = card.value;
let values = card.values;
let tooltipTimeFormat = 'YYYY-MM-DD HH:mm:ss'; let tooltipTimeFormat = 'YYYY-MM-DD HH:mm:ss';
let time = this.dashboard.formatDate(+x, tooltipTimeFormat); let time = this.dashboard.formatDate(+x, tooltipTimeFormat);
let tooltipHtml = `<div class="graph-tooltip-time">${time}</div> let tooltipHtml = `<div class="graph-tooltip-time">${time}</div>
<div class="status-heatmap-histogram"></div>`; <div class="status-heatmap-histogram"></div>`;
if (this.panel.color.mode === 'discrete') {
let statuses = this.panelCtrl.discreteHelper.convertValuesToTooltips(values);
let statusesHtml = '';
if (statuses.length > 0) {
statusesHtml = `
statuses:
<ul>
${_.join(_.map(statuses, v => `<li>${v}</li>`), "")}
</ul>`;
}
tooltipHtml += `<div>
name: <b>${y}</b> <br>
${statusesHtml}
</div>`;
} else {
if (values.length === 1) {
tooltipHtml += `<div> tooltipHtml += `<div>
name: <b>${y}</b> <br> name: <b>${y}</b> <br>
value: <b>${value}</b> <br> value: <b>${value}</b> <br>
</div>`; </div>`;
} else {
tooltipHtml += `<div>
name: <b>${y}</b> <br>
values:
<ul>
${_.join(_.map(values, v => `<li>${v}</li>`), "")}
</ul>
</div>`;
}
}
// "Ambiguous bucket state: Multiple values!";
if (!this.panel.useMax && card.multipleValues) {
tooltipHtml += `<div><b>Error:</b> ${this.panelCtrl.dataWarnings.multipleValues.title}</div>`;
}
// Discrete mode errors
if (this.panel.color.mode === 'discrete') {
if (card.noColorDefined) {
let badValues = this.panelCtrl.discreteHelper.getNotMatchedValues(values);
tooltipHtml += `<div><b>Error:</b> ${this.panelCtrl.dataWarnings.noColorDefined.title}
<br>bad values:
<ul>
${_.join(_.map(badValues, v => `<li>${v}</li>`), "")}
</ul>
</div>`;
}
}
this.tooltip.html(tooltipHtml); this.tooltip.html(tooltipHtml);