mirror of
https://github.com/timberjoegithub/grafana-statusmap.git
synced 2026-07-22 15:53:09 +00:00
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:
@@ -38,6 +38,10 @@ mod.directive('optionsColorLegend', function() {
|
||||
} else if (panel.color.mode === 'opacity') {
|
||||
let colorOptions = panel.color;
|
||||
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') {
|
||||
let colorOptions = panel.color;
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
let legendElem = $(elem).find('svg');
|
||||
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();
|
||||
}
|
||||
|
||||
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) {
|
||||
let legendElem = $(elem).find('svg');
|
||||
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) {
|
||||
let legendElem = $(elem).find('svg');
|
||||
legendElem.empty();
|
||||
@@ -252,6 +373,42 @@ function getColorScale(colorScheme, maxValue, minValue = 0) {
|
||||
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) {
|
||||
let legendOpacityScale;
|
||||
if (options.colorScale === 'linear') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@ $font-size-sm: 12px !default;
|
||||
.status-heatmap-canvas-wrapper {
|
||||
// position: relative;
|
||||
cursor: crosshair;
|
||||
|
||||
.datapoints-warning span {
|
||||
background-color: white;
|
||||
padding: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-heatmap-panel {
|
||||
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
<div class="status-heatmap-wrapper">
|
||||
<div class="status-heatmap-canvas-wrapper">
|
||||
|
||||
<div class="datapoints-warning" ng-if="ctrl.dataWarning">
|
||||
<span class="small" bs-tooltip="ctrl.dataWarning.tip">{{ctrl.dataWarning.title}}</span>
|
||||
<div class="datapoints-warning">
|
||||
<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 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="section gf-form-group">
|
||||
<h5 class="section-heading">Colors</h5>
|
||||
@@ -36,64 +44,104 @@
|
||||
</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">
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
label="Show legend"
|
||||
checked="ctrl.panel.legend.show" on-change="ctrl.render()">
|
||||
</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 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">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
@@ -21,7 +21,7 @@ export function statusHeatmapOptionsEditor() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: true,
|
||||
templateUrl: 'public/plugins/status-heatmap-panel/partials/options_editor.html',
|
||||
templateUrl: 'public/plugins/status-heatmap-panel/options_editor.html',
|
||||
controller: StatusHeatmapOptionsEditorCtrl,
|
||||
};
|
||||
}
|
||||
|
||||
+121
-78
@@ -49,7 +49,6 @@ export default function link(scope, elem, attrs, ctrl) {
|
||||
|
||||
ctrl.events.on('render', () => {
|
||||
render();
|
||||
// ctrl.renderingCompleted();
|
||||
});
|
||||
|
||||
function setElementHeight() {
|
||||
@@ -124,28 +123,44 @@ export default function link(scope, elem, attrs, ctrl) {
|
||||
heatmap.select(".axis-x").select(".domain").remove();
|
||||
}
|
||||
|
||||
function addYAxis() {
|
||||
let ticks = _.map(data, d => d.target);
|
||||
|
||||
// Set default Y min and max if no data
|
||||
if (_.isEmpty(data)) {
|
||||
ticks = [''];
|
||||
// divide chart height by ticks for cards drawing
|
||||
function getYScale(ticks) {
|
||||
let range = [];
|
||||
let step = chartHeight / ticks.length;
|
||||
range.push(chartHeight);
|
||||
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 step = chartHeight / ticks.length;
|
||||
range.push(chartHeight - yOffset);
|
||||
for (let i = 1; i < ticks.length; i++) {
|
||||
range.push(chartHeight - step * i - yOffset);
|
||||
}
|
||||
|
||||
console.log('yRange', range, yOffset);
|
||||
|
||||
scope.yScale = yScale = d3.scaleOrdinal()
|
||||
return d3.scaleOrdinal()
|
||||
.domain(ticks)
|
||||
.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)
|
||||
.tickSizeInner(0 - width)
|
||||
.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();
|
||||
}
|
||||
|
||||
// Wide Y values range and anjust to bucket size
|
||||
// Wide Y values range and adjust to bucket size
|
||||
function wideYAxisRange(min, max, tickInterval) {
|
||||
let y_widing = (max * (dataRangeWidingFactor - 1) - min * (dataRangeWidingFactor - 1)) / 2;
|
||||
let y_min, y_max;
|
||||
@@ -193,19 +208,43 @@ export default function link(scope, elem, attrs, ctrl) {
|
||||
};
|
||||
}
|
||||
|
||||
function fixYAxisTickSize() {
|
||||
heatmap.select(".axis-y")
|
||||
.selectAll(".tick line")
|
||||
.attr("x2", chartWidth);
|
||||
// Create svg element, add axes and
|
||||
// calculate sizes for cards drawing
|
||||
function addHeatmapCanvas() {
|
||||
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();
|
||||
|
||||
yAxisWidth = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
|
||||
chartWidth = width - yAxisWidth - margin.right;
|
||||
// fixYAxisTickSize();
|
||||
//
|
||||
|
||||
let xGridSize = Math.floor(chartWidth / cardsData.xBucketSize);
|
||||
cardWidth = xGridSize - cardPadding * 2;
|
||||
|
||||
addXAxis();
|
||||
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() {
|
||||
addHeatmapCanvas();
|
||||
|
||||
let maxValue = panel.color.max || cardsData.maxValue;
|
||||
let minValue = panel.color.min || cardsData.minValue;
|
||||
|
||||
if (panel.color.mode === 'discrete') {
|
||||
colorScale = ctrl.discreteHelper.getDiscreteColorScale();
|
||||
} else {
|
||||
colorScale = getColorScale(maxValue, minValue);
|
||||
}
|
||||
setOpacityScale(maxValue);
|
||||
setCardSize();
|
||||
|
||||
addAxes();
|
||||
|
||||
let cards = heatmap.selectAll(".status-heatmap-card").data(cardsData.cards);
|
||||
cards.append("title");
|
||||
cards = cards.enter().append("rect")
|
||||
.attr("value", c => c.value)
|
||||
.attr("xVal", c => c.x)
|
||||
.attr("cardId", c => c.id)
|
||||
.attr("x", getCardX)
|
||||
.attr("width", getCardWidth)
|
||||
.attr("yVal", c => c.y)
|
||||
.attr("y", getCardY)
|
||||
.attr("height", getCardHeight)
|
||||
.attr("rx", cardRound)
|
||||
@@ -269,14 +284,14 @@ export default function link(scope, elem, attrs, ctrl) {
|
||||
.style("fill", getCardColor)
|
||||
.style("stroke", getCardColor)
|
||||
.style("stroke-width", 0)
|
||||
//.style("stroke-width", getCardStrokeWidth)
|
||||
//.style("stroke-dasharray", "3,3")
|
||||
.style("opacity", getCardOpacity);
|
||||
|
||||
let $cards = $heatmap.find(".status-heatmap-card");
|
||||
$cards.on("mouseenter", (event) => {
|
||||
tooltip.mouseOverBucket = true;
|
||||
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) => {
|
||||
tooltip.mouseOverBucket = false;
|
||||
@@ -296,7 +311,9 @@ export default function link(scope, elem, attrs, ctrl) {
|
||||
}
|
||||
|
||||
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-width", 0);
|
||||
}
|
||||
@@ -316,6 +333,36 @@ export default function link(scope, elem, attrs, ctrl) {
|
||||
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) {
|
||||
if (panel.color.colorScale === 'linear') {
|
||||
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) {
|
||||
let x;
|
||||
if (xScale(d.x) < 0) {
|
||||
@@ -369,25 +406,22 @@ export default function link(scope, elem, attrs, ctrl) {
|
||||
}
|
||||
|
||||
function getCardY(d) {
|
||||
let y = yScale(d.y);
|
||||
|
||||
y = y + chartTop - cardHeight - cardPadding + yOffset;
|
||||
|
||||
return y;
|
||||
return yScale(d.y) + chartTop - cardHeight - cardPadding;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Cut card height to prevent overlay
|
||||
// if (y < chartTop) {
|
||||
// h = yScale(d.y) - cardPadding;
|
||||
// } else if (yScale(d.y) > chartBottom) {
|
||||
// h = chartBottom - y;
|
||||
// } else if (y + cardHeight > chartBottom) {
|
||||
// h = chartBottom - y;
|
||||
// }
|
||||
if (y < chartTop) {
|
||||
h = ys - cardPadding;
|
||||
} else if (ys > chartBottom) {
|
||||
h = chartBottom - y;
|
||||
} else if (y + cardHeight > chartBottom) {
|
||||
h = chartBottom - y;
|
||||
}
|
||||
|
||||
// Height can't be more than chart height
|
||||
h = Math.min(h, chartHeight);
|
||||
@@ -400,13 +434,15 @@ export default function link(scope, elem, attrs, ctrl) {
|
||||
function getCardColor(d) {
|
||||
if (panel.color.mode === 'opacity') {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function getCardOpacity(d) {
|
||||
if (panel.nullPointMode === 'null' && d.value == null ) {
|
||||
if (panel.nullPointMode === 'as empty' && d.value == null ) {
|
||||
return 0;
|
||||
}
|
||||
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 //
|
||||
/////////////////////////////
|
||||
@@ -477,6 +520,7 @@ export default function link(scope, elem, attrs, ctrl) {
|
||||
} else {
|
||||
emitGraphHoverEvet(event);
|
||||
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
|
||||
if (_.isEmpty(cardsData.cards)) {
|
||||
addHeatmapCanvas();
|
||||
addAxes();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+120
-30
@@ -7,8 +7,8 @@ import rendering from './rendering';
|
||||
// import aggregates, { aggregatesMap } from './aggregates';
|
||||
// import fragments, { fragmentsMap } from './fragments';
|
||||
// import { labelFormats } from './xAxisLabelFormats';
|
||||
// import canvasRendering from './canvas/rendering';
|
||||
import {statusHeatmapOptionsEditor} from './options_editor';
|
||||
import {ColorModeDiscrete} from "./color_mode_discrete";
|
||||
import './css/status-heatmap.css!';
|
||||
|
||||
const CANVAS = 'CANVAS';
|
||||
@@ -24,7 +24,10 @@ const panelDefaults = {
|
||||
cardColor: '#b4ff00',
|
||||
colorScale: 'sqrt',
|
||||
exponent: 0.5,
|
||||
colorScheme: 'interpolateGnYlRd'
|
||||
colorScheme: 'interpolateGnYlRd',
|
||||
// discrete mode settings
|
||||
defaultColor: '#757575',
|
||||
thresholds: [] // manual colors
|
||||
},
|
||||
cards: {
|
||||
cardPadding: null,
|
||||
@@ -52,8 +55,9 @@ const panelDefaults = {
|
||||
decimals: null
|
||||
},
|
||||
// how null points should be handled
|
||||
nullPointMode: 'null',
|
||||
highlightCards: true
|
||||
nullPointMode: 'as empty',
|
||||
highlightCards: true,
|
||||
useMax: true
|
||||
};
|
||||
|
||||
const renderer = CANVAS;
|
||||
@@ -87,7 +91,7 @@ const colorSchemes = [
|
||||
{ name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'darm' }
|
||||
];
|
||||
|
||||
let colorModes = ['opacity', 'spectrum'];
|
||||
let colorModes = ['opacity', 'spectrum', 'discrete'];
|
||||
let opacityScales = ['linear', 'sqrt'];
|
||||
|
||||
export class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
@@ -101,16 +105,34 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
this.colorModes = colorModes;
|
||||
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-snapshot-load', this.onDataReceived);
|
||||
this.events.on('data-error', this.onDataError);
|
||||
this.events.on('init-edit-mode', this.onInitEditMode);
|
||||
this.events.on('render', this.onRender);
|
||||
this.events.on('refresh', this.postRefresh);
|
||||
}
|
||||
|
||||
onDataReceived = (dataList) => {
|
||||
this.data = dataList;
|
||||
this.cardsData = this.convertToCards(this.data);
|
||||
|
||||
this.render();
|
||||
};
|
||||
|
||||
@@ -121,6 +143,19 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
|
||||
onRender = () => {
|
||||
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) => {
|
||||
@@ -133,45 +168,99 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
this.render();
|
||||
};
|
||||
|
||||
link = (scope, elem, attrs, ctrl) => {
|
||||
console.log('LINK');
|
||||
rendering(scope, elem, attrs, ctrl);
|
||||
// switch (renderer) {
|
||||
// case CANVAS: {
|
||||
// canvasRendering(scope, elem, attrs, ctrl);
|
||||
// break;
|
||||
// }
|
||||
// case SVG: {
|
||||
// svgRendering(scope, elem, attrs, ctrl);
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
postRefresh = () => {
|
||||
this.noColorDefined = false;
|
||||
};
|
||||
|
||||
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) => {
|
||||
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;}
|
||||
|
||||
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));
|
||||
|
||||
for(let i = 0; i < cardsData.yBucketSize; i++) {
|
||||
let s = data[i];
|
||||
// Collect all values for each bucket from datapoints with similar target.
|
||||
for(let i = 0; i < cardsData.targets.length; i++) {
|
||||
let target = cardsData.targets[i];
|
||||
|
||||
for (let j = 0; j < cardsData.xBucketSize; j++) {
|
||||
let card = {};
|
||||
let v = s.datapoints[j];
|
||||
let card = {
|
||||
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.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)
|
||||
cardsData.maxValue = card.value;
|
||||
if (cardsData.maxValue < card.maxValue)
|
||||
cardsData.maxValue = card.maxValue;
|
||||
|
||||
if (cardsData.minValue > card.value)
|
||||
cardsData.minValue = card.value;
|
||||
if (cardsData.minValue > card.minValue)
|
||||
cardsData.minValue = card.minValue;
|
||||
|
||||
cardsData.cards.push(card);
|
||||
}
|
||||
@@ -179,4 +268,5 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
|
||||
return cardsData;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
+59
-6
@@ -5,8 +5,6 @@ import kbn from 'app/core/utils/kbn';
|
||||
|
||||
let TOOLTIP_PADDING_X = 30;
|
||||
let TOOLTIP_PADDING_Y = 5;
|
||||
let HISTOGRAM_WIDTH = 160;
|
||||
let HISTOGRAM_HEIGHT = 40;
|
||||
|
||||
export class StatusHeatmapTooltip {
|
||||
constructor(elem, scope) {
|
||||
@@ -55,28 +53,83 @@ export class StatusHeatmapTooltip {
|
||||
this.tooltip = null;
|
||||
}
|
||||
|
||||
show(pos, x, y, value) {
|
||||
if (!this.panel.tooltip.show) { return; }
|
||||
show(pos) {
|
||||
if (!this.panel.tooltip.show || !this.tooltip) { return; }
|
||||
// shared tooltip mode
|
||||
if (pos.panelRelY) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!x || !y || !this.tooltip) {
|
||||
let cardId = d3.select(pos.target).attr('cardId');
|
||||
if (!cardId) {
|
||||
this.destroy();
|
||||
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 time = this.dashboard.formatDate(+x, tooltipTimeFormat);
|
||||
|
||||
let tooltipHtml = `<div class="graph-tooltip-time">${time}</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>
|
||||
name: <b>${y}</b> <br>
|
||||
value: <b>${value}</b> <br>
|
||||
</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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user