mirror of
https://github.com/timberjoegithub/grafana-statusmap.git
synced 2026-07-22 15:53:09 +00:00
feat: migrate to typescript, support 6.3.0+
- use babel to 7, update dependencies, fix #72 - proper babel build to support ngInject, fix #78 - proper buckets display in 6.3.0+, fix #76 - rename js to ts, build d3 lib as js, build src as ts
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
import d3 from 'd3';
|
||||
import $ from 'jquery';
|
||||
import _ from 'lodash';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
|
||||
let TOOLTIP_PADDING_X = 30;
|
||||
let TOOLTIP_PADDING_Y = 10;
|
||||
|
||||
export class AnnotationTooltip {
|
||||
scope: any;
|
||||
dashboard: any;
|
||||
panelCtrl: any;
|
||||
panel: any;
|
||||
mouseOverAnnotationTick: boolean;
|
||||
|
||||
tooltipBase: any;
|
||||
tooltip: any;
|
||||
|
||||
constructor(elem, scope) {
|
||||
this.scope = scope;
|
||||
this.dashboard = scope.ctrl.dashboard;
|
||||
@@ -90,7 +98,7 @@ export class AnnotationTooltip {
|
||||
let tooltipTimeFormat = 'YYYY-MM-DD HH:mm:ss';
|
||||
let annoTime = this.dashboard.formatDate(anno.time, tooltipTimeFormat);
|
||||
let annoText = anno.text;
|
||||
let annoTags = [];
|
||||
let annoTags:any = [];
|
||||
if (anno.tags) {
|
||||
annoTags = _.map(anno.tags, t => ({"text": t, "backColor": "rgb(63, 43, 91)", "borderColor":"rgb(101, 81, 129)"}))
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
import angular from 'angular';
|
||||
import _ from 'lodash';
|
||||
import $ from 'jquery';
|
||||
import d3 from 'd3';
|
||||
import * as d3ScaleChromatic from './libs/d3-scale-chromatic/index';
|
||||
import {contextSrv} from 'app/core/core';
|
||||
import {tickStep} from 'app/core/utils/ticks';
|
||||
|
||||
let mod = angular.module('grafana.directives');
|
||||
import coreModule from 'app/core/core_module';
|
||||
|
||||
const LEGEND_STEP_WIDTH = 2;
|
||||
|
||||
/**
|
||||
* Bigger color legend for opacity and spectrum modes editor.
|
||||
*/
|
||||
mod.directive('optionsColorLegend', function() {
|
||||
coreModule.directive('optionsColorLegend', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<div class="status-heatmap-color-legend"><svg width="16.8rem" height="24px"></svg></div>',
|
||||
@@ -47,7 +45,7 @@ mod.directive('optionsColorLegend', function() {
|
||||
/**
|
||||
* Graph legend with values.
|
||||
*/
|
||||
mod.directive('statusHeatmapLegend', function() {
|
||||
coreModule.directive('statusHeatmapLegend', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<div class="status-heatmap-color-legend"><svg width="100px" height="6px"></svg></div>',
|
||||
@@ -160,11 +158,13 @@ function drawDiscreteColorLegend(elem, colorOptions, discreteHelper) {
|
||||
let valuesNumber = thresholds.length;
|
||||
|
||||
// graph width as a fallback
|
||||
let $heatmap = $(elem).parent().parent().parent().find('.status-heatmap-panel');
|
||||
let graphWidth = $heatmap.find('svg').attr("width");
|
||||
const $heatmap = $(elem).parent().parent().parent().find('.status-heatmap-panel');
|
||||
const graphWidthAttr = $heatmap.find('svg').attr("width");
|
||||
let graphWidth = parseInt(graphWidthAttr);
|
||||
|
||||
|
||||
// calculate max width of tooltip and use it as width for each item
|
||||
let textWidth = [];
|
||||
let textWidth:number[] = [];
|
||||
legend.selectAll(".hidden-texts")
|
||||
.data(tooltips)
|
||||
.enter().append("text")
|
||||
@@ -179,8 +179,8 @@ function drawDiscreteColorLegend(elem, colorOptions, discreteHelper) {
|
||||
|
||||
let legendWidth = Math.floor(_.min([
|
||||
graphWidth - 30,
|
||||
(_.max(textWidth) + 3) * valuesNumber,
|
||||
]));
|
||||
(_.max(textWidth)! + 3) * valuesNumber,
|
||||
])!);
|
||||
legendElem.attr("width", legendWidth);
|
||||
|
||||
let legendHeight = legendElem.attr("height");
|
||||
@@ -398,7 +398,7 @@ function buildLegendTicks(rangeFrom, rangeTo, maxValue, minValue) {
|
||||
let range = rangeTo - rangeFrom;
|
||||
let tickStepSize = tickStep(rangeFrom, rangeTo, 3);
|
||||
let ticksNum = Math.round(range / tickStepSize);
|
||||
let ticks = [];
|
||||
let ticks:any = [];
|
||||
|
||||
for (let i = 0; i < ticksNum; i++) {
|
||||
let current = tickStepSize * i;
|
||||
@@ -1,7 +1,23 @@
|
||||
import _ from 'lodash';
|
||||
import {StatusHeatmapCtrl} from "./status_heatmap_ctrl";
|
||||
|
||||
interface Tooltip {
|
||||
tooltip: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
declare class DiscreteColorThreshold {
|
||||
color: string;
|
||||
value: number;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
// Helper methods to handle discrete color mode
|
||||
export class ColorModeDiscrete {
|
||||
scope: any;
|
||||
panelCtrl: StatusHeatmapCtrl;
|
||||
panel: any;
|
||||
|
||||
constructor(scope) {
|
||||
this.scope = scope;
|
||||
this.panelCtrl = scope.ctrl;
|
||||
@@ -9,9 +25,9 @@ export class ColorModeDiscrete {
|
||||
}
|
||||
|
||||
// get tooltip for each value ordered by thresholds priority
|
||||
convertValuesToTooltips(values) {
|
||||
convertValuesToTooltips(values:any[]) : Tooltip[] {
|
||||
let thresholds = this.panel.color.thresholds;
|
||||
let tooltips = [];
|
||||
let tooltips:Tooltip[] = [];
|
||||
|
||||
for (let i = 0; i < thresholds.length; i++) {
|
||||
for (let j = 0; j < values.length; j++) {
|
||||
@@ -26,9 +42,8 @@ export class ColorModeDiscrete {
|
||||
return tooltips;
|
||||
}
|
||||
|
||||
|
||||
getNotMatchedValues(values) {
|
||||
let notMatched = [];
|
||||
getNotMatchedValues(values:any[]) {
|
||||
let notMatched:any[] = [];
|
||||
for (let j = 0; j < values.length; j++) {
|
||||
if (!this.getMatchedThreshold(values[j])) {
|
||||
notMatched.push(values[j]);
|
||||
@@ -37,8 +52,8 @@ export class ColorModeDiscrete {
|
||||
return notMatched;
|
||||
}
|
||||
|
||||
getNotColoredValues(values) {
|
||||
let notMatched = [];
|
||||
getNotColoredValues(values:any[]) {
|
||||
let notMatched:any[] = [];
|
||||
for (let j = 0; j < values.length; j++) {
|
||||
let threshold = this.getMatchedThreshold(values[j]);
|
||||
if (!threshold || !threshold.color || threshold.color == "") {
|
||||
@@ -48,7 +63,6 @@ export class ColorModeDiscrete {
|
||||
return notMatched;
|
||||
}
|
||||
|
||||
|
||||
getDiscreteColor(index) {
|
||||
let color = this.getThreshold(index).color;
|
||||
if (!color || color == "") {
|
||||
@@ -1,11 +0,0 @@
|
||||
import {loadPluginCss} from 'app/plugins/sdk';
|
||||
import { StatusHeatmapCtrl } from './status_heatmap_ctrl';
|
||||
|
||||
loadPluginCss({
|
||||
dark: 'plugins/flant-statusmap-panel/css/statusmap.dark.css',
|
||||
light: 'plugins/flant-statusmap-panel/css/statusmap.light.css'
|
||||
});
|
||||
|
||||
export {
|
||||
StatusHeatmapCtrl as PanelCtrl
|
||||
};
|
||||
@@ -1,9 +1,20 @@
|
||||
import { MetricsPanelCtrl } from 'app/plugins/sdk';
|
||||
// Libraries
|
||||
import _ from 'lodash';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import { auto } from 'angular';
|
||||
|
||||
// Components
|
||||
import './color_legend';
|
||||
|
||||
// Utils
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import {loadPluginCss} from 'app/plugins/sdk';
|
||||
|
||||
// Types
|
||||
import { MetricsPanelCtrl } from 'app/plugins/sdk';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { AnnotationsSrv } from 'app/features/annotations/annotations_srv';
|
||||
|
||||
import {CardsStorage, Card} from './graph';
|
||||
import rendering from './rendering';
|
||||
// import aggregates, { aggregatesMap } from './aggregates';
|
||||
// import fragments, { fragmentsMap } from './fragments';
|
||||
@@ -11,58 +22,12 @@ import rendering from './rendering';
|
||||
import {statusHeatmapOptionsEditor} from './options_editor';
|
||||
import {ColorModeDiscrete} from "./color_mode_discrete";
|
||||
|
||||
|
||||
const CANVAS = 'CANVAS';
|
||||
const SVG = 'SVG';
|
||||
const VALUE_INDEX = 0,
|
||||
TIME_INDEX = 1;
|
||||
|
||||
const panelDefaults = {
|
||||
// aggregate: aggregates.AVG,
|
||||
// fragment: fragments.HOUR,
|
||||
color: {
|
||||
mode: 'spectrum',
|
||||
cardColor: '#b4ff00',
|
||||
colorScale: 'sqrt',
|
||||
exponent: 0.5,
|
||||
colorScheme: 'interpolateGnYlRd',
|
||||
// discrete mode settings
|
||||
defaultColor: '#757575',
|
||||
thresholds: [] // manual colors
|
||||
},
|
||||
cards: {
|
||||
cardMinWidth: 5,
|
||||
cardVSpacing: 2,
|
||||
cardHSpacing: 2,
|
||||
cardRound: null
|
||||
},
|
||||
xAxis: {
|
||||
show: true,
|
||||
showWeekends: true,
|
||||
minBucketWidthToShowWeekends: 4,
|
||||
showCrosshair: true,
|
||||
labelFormat: '%a %m/%d'
|
||||
},
|
||||
yAxis: {
|
||||
show: true,
|
||||
showCrosshair: false
|
||||
},
|
||||
tooltip: {
|
||||
show: true
|
||||
},
|
||||
legend: {
|
||||
show: true
|
||||
},
|
||||
data: {
|
||||
unitFormat: 'short',
|
||||
decimals: null
|
||||
},
|
||||
// how null points should be handled
|
||||
nullPointMode: 'as empty',
|
||||
yAxisSort: 'metrics',
|
||||
highlightCards: true,
|
||||
useMax: true
|
||||
};
|
||||
|
||||
const renderer = CANVAS;
|
||||
|
||||
const colorSchemes = [
|
||||
@@ -97,13 +62,100 @@ const colorSchemes = [
|
||||
let colorModes = ['opacity', 'spectrum', 'discrete'];
|
||||
let opacityScales = ['linear', 'sqrt'];
|
||||
|
||||
export class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
interface DataWarning {
|
||||
title: string;
|
||||
tip: string;
|
||||
}
|
||||
|
||||
interface DataWarnings {
|
||||
noColorDefined: DataWarning;
|
||||
multipleValues: DataWarning;
|
||||
}
|
||||
|
||||
interface ColorThreshold {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
loadPluginCss({
|
||||
dark: 'plugins/flant-statusmap-panel/css/statusmap.dark.css',
|
||||
light: 'plugins/flant-statusmap-panel/css/statusmap.light.css'
|
||||
});
|
||||
|
||||
class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
static templateUrl = 'module.html';
|
||||
|
||||
opacityScales: any = [];
|
||||
colorModes: any = [];
|
||||
colorSchemes: any = [];
|
||||
unitFormats: any;
|
||||
data: any;
|
||||
cardsData: any;
|
||||
graph: any;
|
||||
multipleValues: boolean;
|
||||
noColorDefined: boolean;
|
||||
discreteHelper: ColorModeDiscrete;
|
||||
dataWarnings: DataWarnings;
|
||||
|
||||
annotations: object[] = [];
|
||||
annotationsPromise: any;
|
||||
|
||||
panelDefaults: any = {
|
||||
// datasource name, null = default datasource
|
||||
datasource: null,
|
||||
// color mode
|
||||
color: {
|
||||
mode: 'spectrum',
|
||||
cardColor: '#b4ff00',
|
||||
colorScale: 'sqrt',
|
||||
exponent: 0.5,
|
||||
colorScheme: 'interpolateGnYlRd',
|
||||
// discrete mode settings
|
||||
defaultColor: '#757575',
|
||||
thresholds: [] // manual colors
|
||||
},
|
||||
// buckets settings
|
||||
cards: {
|
||||
cardMinWidth: 5,
|
||||
cardVSpacing: 2,
|
||||
cardHSpacing: 2,
|
||||
cardRound: null
|
||||
},
|
||||
xAxis: {
|
||||
show: true,
|
||||
showWeekends: true,
|
||||
minBucketWidthToShowWeekends: 4,
|
||||
showCrosshair: true,
|
||||
labelFormat: '%a %m/%d'
|
||||
},
|
||||
yAxis: {
|
||||
show: true,
|
||||
showCrosshair: false
|
||||
},
|
||||
tooltip: {
|
||||
show: true
|
||||
},
|
||||
legend: {
|
||||
show: true
|
||||
},
|
||||
data: {
|
||||
unitFormat: 'short',
|
||||
decimals: null
|
||||
},
|
||||
// how null points should be handled
|
||||
nullPointMode: 'as empty',
|
||||
yAxisSort: 'metrics',
|
||||
highlightCards: true,
|
||||
useMax: true
|
||||
};
|
||||
|
||||
/** @ngInject */
|
||||
constructor($scope, $injector, $rootScope, timeSrv, annotationsSrv) {
|
||||
constructor($scope: any, $injector: auto.IInjectorService, private annotationsSrv: AnnotationsSrv) {
|
||||
super($scope, $injector);
|
||||
_.defaultsDeep(this.panel, panelDefaults);
|
||||
|
||||
_.defaultsDeep(this.panel, this.panelDefaults);
|
||||
|
||||
this.opacityScales = opacityScales;
|
||||
this.colorModes = colorModes;
|
||||
@@ -131,31 +183,39 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
};
|
||||
|
||||
this.annotations = [];
|
||||
this.annotationsSrv = annotationsSrv;
|
||||
|
||||
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);
|
||||
this.events.on('render', this.onRender.bind(this));
|
||||
this.events.on('data-received', this.onDataReceived.bind(this));
|
||||
this.events.on('data-error', this.onDataError.bind(this));
|
||||
this.events.on('data-snapshot-load', this.onDataReceived.bind(this));
|
||||
this.events.on('init-edit-mode', this.onInitEditMode.bind(this));
|
||||
this.events.on('refresh', this.postRefresh.bind(this));
|
||||
// custom event from rendering.js
|
||||
this.events.on('render-complete', this.onRenderComplete);
|
||||
this.events.on('render-complete', this.onRenderComplete.bind(this));
|
||||
}
|
||||
|
||||
onRenderComplete = (data) => {
|
||||
onRenderComplete(data):void {
|
||||
this.graph.chartWidth = data.chartWidth;
|
||||
this.renderingCompleted();
|
||||
};
|
||||
}
|
||||
|
||||
getChartWidth():number {
|
||||
const wndWidth = $(window).width();
|
||||
// gripPos.w is a width in grid's measurements. Grid size in Grafana is 24.
|
||||
const panelWidthFactor = this.panel.gridPos.w / 24;
|
||||
const panelWidth = Math.ceil(wndWidth * panelWidthFactor);
|
||||
// approximate chartWidth because y axis ticks not rendered yet on first data receive.
|
||||
const chartWidth = _.max([
|
||||
panelWidth - 200,
|
||||
panelWidth/2
|
||||
]);
|
||||
|
||||
return chartWidth!;
|
||||
}
|
||||
|
||||
// override calculateInterval for discrete color mode
|
||||
calculateInterval = () => {
|
||||
let panelWidth = Math.ceil($(window).width() * (this.panel.gridPos.w / 24));
|
||||
// approximate chartWidth because y axis ticks not rendered yet on first data receive.
|
||||
let chartWidth = _.max([
|
||||
panelWidth - 200,
|
||||
panelWidth/2
|
||||
]);
|
||||
calculateInterval() {
|
||||
let chartWidth = this.getChartWidth();
|
||||
|
||||
let minCardWidth = this.panel.cards.cardMinWidth;
|
||||
let minSpacing = this.panel.cards.cardHSpacing;
|
||||
@@ -169,6 +229,7 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
|
||||
// Calculate low limit of interval
|
||||
let lowLimitMs = 1; // 1 millisecond default low limit
|
||||
|
||||
let intervalOverride = this.panel.interval;
|
||||
|
||||
// if no panel interval check datasource
|
||||
@@ -188,12 +249,13 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
if (lowLimitMs > intervalMs) {
|
||||
intervalMs = lowLimitMs;
|
||||
}
|
||||
let interval = kbn.secondsToHms(intervalMs / 1000);
|
||||
|
||||
this.intervalMs = intervalMs;
|
||||
this.interval = kbn.secondsToHms(intervalMs / 1000);
|
||||
};
|
||||
this.interval = interval;
|
||||
}
|
||||
|
||||
issueQueries = (datasource) => {
|
||||
issueQueries(datasource: any) {
|
||||
this.annotationsPromise = this.annotationsSrv.getAnnotations({
|
||||
dashboard: this.dashboard,
|
||||
panel: this.panel,
|
||||
@@ -206,49 +268,65 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
* (but not wait for completion). This resolves
|
||||
* issue 11806.
|
||||
*/
|
||||
// 5.x before 5.4 doesn't have dataPromises
|
||||
// 5.x before 5.4 doesn't have datasourcePromises.
|
||||
|
||||
if ("undefined" !== typeof(this.annotationsSrv.datasourcePromises)) {
|
||||
return this.annotationsSrv.datasourcePromises.then(r => {
|
||||
return super.issueQueries(datasource);
|
||||
return this.issueQueriesWithInterval(datasource, this.interval);
|
||||
});
|
||||
} else {
|
||||
return super.issueQueries(datasource);
|
||||
return this.issueQueriesWithInterval(datasource, this.interval);
|
||||
}
|
||||
}
|
||||
|
||||
// Grafana 6.2 (and older) is using this.interval for queries,
|
||||
// but Grafana 6.3+ is calculating interval again in queryRunner,
|
||||
// so we need to save-restore this.panel.interval.
|
||||
issueQueriesWithInterval(datasource: any, interval: any) {
|
||||
var origInterval = this.panel.interval;
|
||||
this.panel.interval = this.interval;
|
||||
var res = super.issueQueries(datasource);
|
||||
this.panel.interval = origInterval;
|
||||
return res;
|
||||
}
|
||||
|
||||
onDataReceived = (dataList) => {
|
||||
|
||||
onDataReceived(dataList: any) {
|
||||
this.data = dataList;
|
||||
this.cardsData = this.convertToCards(this.data);
|
||||
|
||||
console.log("OnDataReceived");
|
||||
|
||||
this.annotationsPromise.then(
|
||||
result => {
|
||||
(result: { alertState: any; annotations: any }) => {
|
||||
this.loading = false;
|
||||
//this.alertState = result.alertState;
|
||||
if (result.annotations && result.annotations.length > 0) {
|
||||
this.annotations = result.annotations;
|
||||
} else {
|
||||
this.annotations = null;
|
||||
this.annotations = [];
|
||||
}
|
||||
console.log("annotationsPromise result " + this.annotations.length + " annotations");
|
||||
this.render();
|
||||
},
|
||||
() => {
|
||||
this.loading = false;
|
||||
this.annotations = null;
|
||||
this.annotations = [];
|
||||
console.log("annotationsPromise onrejected");
|
||||
this.render();
|
||||
}
|
||||
);
|
||||
|
||||
//this.render();
|
||||
};
|
||||
}
|
||||
|
||||
onInitEditMode = () => {
|
||||
onInitEditMode() {
|
||||
this.addEditorTab('Options', statusHeatmapOptionsEditor, 2);
|
||||
this.unitFormats = kbn.getUnitFormats();
|
||||
};
|
||||
}
|
||||
|
||||
onRender = () => {
|
||||
if (!this.data) { return; }
|
||||
onRender() {
|
||||
if (!this.range || !this.data) { return; }
|
||||
|
||||
this.multipleValues = false;
|
||||
if (!this.panel.useMax) {
|
||||
@@ -264,47 +342,47 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
this.noColorDefined = this.cardsData.noColorDefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onCardColorChange = (newColor) => {
|
||||
onCardColorChange(newColor) {
|
||||
this.panel.color.cardColor = newColor;
|
||||
this.render();
|
||||
};
|
||||
}
|
||||
|
||||
onDataError = () => {
|
||||
onDataError() {
|
||||
this.data = [];
|
||||
this.annotations = [];
|
||||
this.render();
|
||||
};
|
||||
}
|
||||
|
||||
postRefresh = () => {
|
||||
postRefresh() {
|
||||
this.noColorDefined = false;
|
||||
};
|
||||
}
|
||||
|
||||
onEditorAddThreshold = () => {
|
||||
onEditorAddThreshold() {
|
||||
this.panel.color.thresholds.push({ color: this.panel.defaultColor });
|
||||
this.render();
|
||||
};
|
||||
}
|
||||
|
||||
onEditorRemoveThreshold = (index) => {
|
||||
onEditorRemoveThreshold(index:number) {
|
||||
this.panel.color.thresholds.splice(index, 1);
|
||||
this.render();
|
||||
};
|
||||
}
|
||||
|
||||
onEditorRemoveThresholds = () => {
|
||||
onEditorRemoveThresholds() {
|
||||
this.panel.color.thresholds = [];
|
||||
this.render();
|
||||
};
|
||||
}
|
||||
|
||||
onEditorAddThreeLights = () => {
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
/* https://ethanschoonover.com/solarized/ */
|
||||
onEditorAddSolarized = () => {
|
||||
onEditorAddSolarized() {
|
||||
this.panel.color.thresholds.push({color: "#b58900", value: 0, tooltip: "yellow" });
|
||||
this.panel.color.thresholds.push({color: "#cb4b16", value: 1, tooltip: "orange" });
|
||||
this.panel.color.thresholds.push({color: "#dc322f", value: 2, tooltip: "red" });
|
||||
@@ -316,13 +394,13 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
this.render();
|
||||
}
|
||||
|
||||
link = (scope, elem, attrs, ctrl) => {
|
||||
link(scope, elem, attrs, ctrl) {
|
||||
rendering(scope, elem, attrs, ctrl);
|
||||
};
|
||||
}
|
||||
|
||||
// group values into buckets by target
|
||||
convertToCards = (data) => {
|
||||
let cardsData = {
|
||||
convertToCards(data) {
|
||||
let cardsData = <CardsStorage> {
|
||||
cards: [],
|
||||
xBucketSize: 0,
|
||||
yBucketSize: 0,
|
||||
@@ -355,14 +433,11 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
let target = cardsData.targets[i];
|
||||
|
||||
for (let j = 0; j < cardsData.xBucketSize; j++) {
|
||||
let card = {
|
||||
id: i*cardsData.xBucketSize + j,
|
||||
values: [],
|
||||
multipleValues: false,
|
||||
noColorDefined: false,
|
||||
y: target,
|
||||
x: -1,
|
||||
};
|
||||
let card = new Card();
|
||||
card.id = i*cardsData.xBucketSize + j;
|
||||
card.values = [];
|
||||
card.y = target;
|
||||
card.x = -1;
|
||||
|
||||
// collect values from all timeseries with target
|
||||
for (let si = 0; si < cardsData.targetIndex[target].length; si++) {
|
||||
@@ -399,5 +474,9 @@ export class StatusHeatmapCtrl extends MetricsPanelCtrl {
|
||||
}
|
||||
|
||||
return cardsData;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
StatusHeatmapCtrl, StatusHeatmapCtrl as PanelCtrl
|
||||
};
|
||||
@@ -1,6 +1,10 @@
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
|
||||
export class StatusHeatmapOptionsEditorCtrl {
|
||||
panel: any;
|
||||
panelCtrl: any;
|
||||
unitFormats: any;
|
||||
|
||||
constructor($scope) {
|
||||
$scope.editor = this;
|
||||
this.panelCtrl = $scope.ctrl;
|
||||
@@ -1,728 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import $ from 'jquery';
|
||||
import moment from 'moment';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import {appEvents, contextSrv} from 'app/core/core';
|
||||
import {tickStep, getScaledDecimals, getFlotTickSize} from 'app/core/utils/ticks';
|
||||
import d3 from 'd3';
|
||||
import * as d3ScaleChromatic from './libs/d3-scale-chromatic/index';
|
||||
import {StatusHeatmapTooltip} from './tooltip';
|
||||
import {AnnotationTooltip} from './annotations';
|
||||
|
||||
let MIN_CARD_SIZE = 5,
|
||||
CARD_H_SPACING = 2,
|
||||
CARD_V_SPACING = 2,
|
||||
CARD_ROUND = 0,
|
||||
DATA_RANGE_WIDING_FACTOR = 1.2,
|
||||
DEFAULT_X_TICK_SIZE_PX = 100,
|
||||
DEFAULT_Y_TICK_SIZE_PX = 50,
|
||||
X_AXIS_TICK_PADDING = 10,
|
||||
Y_AXIS_TICK_PADDING = 5,
|
||||
MIN_SELECTION_WIDTH = 2;
|
||||
|
||||
export default function link(scope, elem, attrs, ctrl) {
|
||||
let data, cardsData, timeRange, panel, heatmap;
|
||||
|
||||
// $heatmap is JQuery object, but heatmap is D3
|
||||
let $heatmap = elem.find('.status-heatmap-panel');
|
||||
let tooltip = new StatusHeatmapTooltip($heatmap, scope);
|
||||
let annotationTooltip = new AnnotationTooltip($heatmap, scope);
|
||||
|
||||
let width, height,
|
||||
yScale, xScale,
|
||||
chartWidth, chartHeight,
|
||||
chartTop, chartBottom,
|
||||
yAxisWidth, xAxisHeight,
|
||||
cardVSpacing, cardHSpacing, cardRound,
|
||||
cardWidth, cardHeight,
|
||||
colorScale, opacityScale,
|
||||
mouseUpHandler,
|
||||
xGridSize, yGridSize;
|
||||
|
||||
let yOffset = 0;
|
||||
|
||||
let selection = {
|
||||
active: false,
|
||||
x1: -1,
|
||||
x2: -1
|
||||
};
|
||||
|
||||
let padding = {left: 0, right: 0, top: 0, bottom: 0},
|
||||
margin = {left: 25, right: 15, top: 10, bottom: 20},
|
||||
dataRangeWidingFactor = DATA_RANGE_WIDING_FACTOR;
|
||||
|
||||
ctrl.events.on('render', () => {
|
||||
render();
|
||||
});
|
||||
|
||||
function setElementHeight() {
|
||||
try {
|
||||
var height = ctrl.height || panel.height || ctrl.row.height;
|
||||
if (_.isString(height)) {
|
||||
height = parseInt(height.replace('px', ''), 10);
|
||||
}
|
||||
|
||||
height -= panel.legend.show ? 32 : 10; // bottom padding and space for legend. Change margin in .status-heatmap-color-legend !
|
||||
|
||||
$heatmap.css('height', height + 'px');
|
||||
|
||||
return true;
|
||||
} catch (e) { // IE throws errors sometimes
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getYAxisWidth(elem) {
|
||||
let axis_text = elem.selectAll(".axis-y text").nodes();
|
||||
let max_text_width = _.max(_.map(axis_text, text => {
|
||||
// Use SVG getBBox method
|
||||
return text.getBBox().width;
|
||||
}));
|
||||
|
||||
return Math.ceil(max_text_width);
|
||||
}
|
||||
|
||||
function getXAxisHeight(elem) {
|
||||
let axis_line = elem.select(".axis-x line");
|
||||
if (!axis_line.empty()) {
|
||||
let axis_line_position = parseFloat(elem.select(".axis-x line").attr("y2"));
|
||||
let canvas_width = parseFloat(elem.attr("height"));
|
||||
return canvas_width - axis_line_position;
|
||||
} else {
|
||||
// Default height
|
||||
return 30;
|
||||
}
|
||||
}
|
||||
|
||||
function addXAxis() {
|
||||
// Scale timestamps to cards centers
|
||||
scope.xScale = xScale = d3.scaleTime()
|
||||
.domain([timeRange.from, timeRange.to])
|
||||
.range([xGridSize/2, chartWidth-xGridSize/2]);
|
||||
|
||||
let ticks = chartWidth / DEFAULT_X_TICK_SIZE_PX;
|
||||
let grafanaTimeFormatter = grafanaTimeFormat(ticks, timeRange.from, timeRange.to);
|
||||
let timeFormat;
|
||||
let dashboardTimeZone = ctrl.dashboard.getTimezone();
|
||||
if (dashboardTimeZone === 'utc') {
|
||||
timeFormat = d3.utcFormat(grafanaTimeFormatter);
|
||||
} else {
|
||||
timeFormat = d3.timeFormat(grafanaTimeFormatter);
|
||||
}
|
||||
|
||||
let xAxis = d3.axisBottom(xScale)
|
||||
.ticks(ticks)
|
||||
.tickFormat(timeFormat)
|
||||
.tickPadding(X_AXIS_TICK_PADDING)
|
||||
.tickSize(chartHeight);
|
||||
|
||||
let posY = chartTop;
|
||||
let posX = yAxisWidth;
|
||||
|
||||
heatmap.append("g")
|
||||
.attr("class", "axis axis-x")
|
||||
.attr("transform", "translate(" + posX + "," + posY + ")")
|
||||
.call(xAxis);
|
||||
|
||||
// Remove horizontal line in the top of axis labels (called domain in d3)
|
||||
heatmap.select(".axis-x").select(".domain").remove();
|
||||
}
|
||||
|
||||
// divide chart height by ticks for cards drawing
|
||||
function getYScale(ticks) {
|
||||
let range = [];
|
||||
let step = chartHeight / ticks.length;
|
||||
// svg has y=0 on the top, so top card should have a minimal value in range
|
||||
range.push(step);
|
||||
for (let i = 1; i < ticks.length; i++) {
|
||||
range.push(step * (i+1));
|
||||
}
|
||||
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;
|
||||
// svg has y=0 on the top, so top tick should have a minimal value in range
|
||||
range.push(yOffset);
|
||||
for (let i = 1; i < ticks.length; i++) {
|
||||
range.push(step * i + yOffset);
|
||||
}
|
||||
return d3.scaleOrdinal()
|
||||
.domain(ticks)
|
||||
.range(range);
|
||||
}
|
||||
|
||||
function addYAxis() {
|
||||
let ticks = _.uniq(_.map(data, d => d.target));
|
||||
|
||||
// Set default Y min and max if no data
|
||||
if (_.isEmpty(data)) {
|
||||
ticks = [''];
|
||||
}
|
||||
|
||||
if (panel.yAxisSort == 'a → z') {
|
||||
ticks.sort((a, b) => a.localeCompare(b, 'en', {ignorePunctuation: false, numeric: true}));
|
||||
} else if (panel.yAxisSort == 'z → a') {
|
||||
ticks.sort((b, a) => a.localeCompare(b, 'en', {ignorePunctuation: false, numeric: true}));
|
||||
}
|
||||
|
||||
let yAxisScale = getYAxisScale(ticks);
|
||||
scope.yScale = yScale = getYScale(ticks);
|
||||
|
||||
let yAxis = d3.axisLeft(yAxisScale)
|
||||
.tickValues(ticks)
|
||||
.tickSizeInner(0 - width)
|
||||
.tickPadding(Y_AXIS_TICK_PADDING);
|
||||
|
||||
heatmap.append("g")
|
||||
.attr("class", "axis axis-y")
|
||||
.call(yAxis);
|
||||
|
||||
// Calculate Y axis width first, then move axis into visible area
|
||||
let posY = margin.top;
|
||||
let posX = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
|
||||
heatmap.select(".axis-y").attr("transform", "translate(" + posX + "," + posY + ")");
|
||||
|
||||
// Remove vertical line in the right of axis labels (called domain in d3)
|
||||
heatmap.select(".axis-y").select(".domain").remove();
|
||||
heatmap.select(".axis-y").selectAll(".tick line").remove();
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
if (tickInterval === 0) {
|
||||
y_max = max * dataRangeWidingFactor;
|
||||
y_min = min - min * (dataRangeWidingFactor - 1);
|
||||
tickInterval = (y_max - y_min) / 2;
|
||||
} else {
|
||||
y_max = Math.ceil((max + y_widing) / tickInterval) * tickInterval;
|
||||
y_min = Math.floor((min - y_widing) / tickInterval) * tickInterval;
|
||||
}
|
||||
|
||||
// Don't wide axis below 0 if all values are positive
|
||||
if (min >= 0 && y_min < 0) {
|
||||
y_min = 0;
|
||||
}
|
||||
|
||||
return {y_min, y_max};
|
||||
}
|
||||
|
||||
function tickValueFormatter(decimals, scaledDecimals = null) {
|
||||
let format = panel.yAxis.format;
|
||||
return function(value) {
|
||||
return kbn.valueFormats[format](value, decimals, scaledDecimals);
|
||||
};
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
cardHSpacing = panel.cards.cardHSpacing !== null ? panel.cards.cardHSpacing : CARD_H_SPACING;
|
||||
cardVSpacing = panel.cards.cardVSpacing !== null ? panel.cards.cardVSpacing : CARD_V_SPACING;
|
||||
cardRound = panel.cards.cardRound !== null ? panel.cards.cardRound : CARD_ROUND;
|
||||
|
||||
// calculate yOffset for YAxis
|
||||
yGridSize = Math.floor(chartHeight / cardsData.yBucketSize);
|
||||
cardHeight = yGridSize ? yGridSize - cardVSpacing : 0;
|
||||
yOffset = cardHeight / 2;
|
||||
|
||||
addYAxis();
|
||||
|
||||
yAxisWidth = getYAxisWidth(heatmap) + Y_AXIS_TICK_PADDING;
|
||||
chartWidth = width - yAxisWidth - margin.right;
|
||||
|
||||
// TODO allow per-y cardWidth!
|
||||
// we need to fill chartWidth with xBucketSize cards.
|
||||
xGridSize = chartWidth / (cardsData.xBucketSize+1);
|
||||
cardWidth = xGridSize - cardHSpacing;
|
||||
|
||||
addXAxis();
|
||||
xAxisHeight = getXAxisHeight(heatmap);
|
||||
|
||||
if (!panel.yAxis.show) {
|
||||
heatmap.select(".axis-y").selectAll("line").style("opacity", 0);
|
||||
}
|
||||
|
||||
if (!panel.xAxis.show) {
|
||||
heatmap.select(".axis-x").selectAll("line").style("opacity", 0);
|
||||
}
|
||||
}
|
||||
|
||||
function addHeatmap() {
|
||||
addHeatmapCanvas();
|
||||
|
||||
let maxValue = panel.color.max || cardsData.maxValue;
|
||||
let minValue = panel.color.min || cardsData.minValue;
|
||||
|
||||
if (panel.color.mode !== 'discrete') {
|
||||
colorScale = getColorScale(maxValue, minValue);
|
||||
}
|
||||
setOpacityScale(maxValue);
|
||||
|
||||
let cards = heatmap.selectAll(".status-heatmap-card").data(cardsData.cards);
|
||||
cards.append("title");
|
||||
cards = cards.enter().append("rect")
|
||||
.attr("cardId", c => c.id)
|
||||
.attr("x", getCardX)
|
||||
.attr("width", getCardWidth)
|
||||
.attr("y", getCardY)
|
||||
.attr("height", getCardHeight)
|
||||
.attr("rx", cardRound)
|
||||
.attr("ry", cardRound)
|
||||
.attr("class", "bordered status-heatmap-card")
|
||||
.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);
|
||||
})
|
||||
.on("mouseleave", (event) => {
|
||||
tooltip.mouseOverBucket = false;
|
||||
resetCardHighLight(event);
|
||||
});
|
||||
|
||||
_renderAnnotations();
|
||||
|
||||
ctrl.events.emit('render-complete', {
|
||||
"chartWidth": chartWidth
|
||||
});
|
||||
}
|
||||
|
||||
function highlightCard(event) {
|
||||
let color = d3.select(event.target).style("fill");
|
||||
let highlightColor = d3.color(color).darker(2);
|
||||
let strokeColor = d3.color(color).brighter(4);
|
||||
let current_card = d3.select(event.target);
|
||||
tooltip.originalFillColor = color;
|
||||
current_card.style("fill", highlightColor)
|
||||
.style("stroke", strokeColor)
|
||||
.style("stroke-width", 1);
|
||||
}
|
||||
|
||||
function resetCardHighLight(event) {
|
||||
d3
|
||||
.select(event.target)
|
||||
.style("fill", tooltip.originalFillColor)
|
||||
.style("stroke", tooltip.originalFillColor)
|
||||
.style("stroke-width", 0);
|
||||
}
|
||||
|
||||
function getColorScale(maxValue, minValue = 0) {
|
||||
let colorScheme = _.find(ctrl.colorSchemes, {value: panel.color.colorScheme});
|
||||
let colorInterpolator = d3ScaleChromatic[colorScheme.value];
|
||||
let colorScaleInverted = colorScheme.invert === 'always' ||
|
||||
(colorScheme.invert === 'dark' && !contextSrv.user.lightTheme);
|
||||
|
||||
if (maxValue == minValue)
|
||||
maxValue = minValue + 1;
|
||||
|
||||
let start = colorScaleInverted ? maxValue : minValue;
|
||||
let end = colorScaleInverted ? minValue : maxValue;
|
||||
|
||||
return d3.scaleSequential(colorInterpolator).domain([start, end]);
|
||||
}
|
||||
|
||||
function setOpacityScale(maxValue) {
|
||||
if (panel.color.colorScale === 'linear') {
|
||||
opacityScale = d3.scaleLinear()
|
||||
.domain([0, maxValue])
|
||||
.range([0, 1]);
|
||||
} else if (panel.color.colorScale === 'sqrt') {
|
||||
opacityScale = d3.scalePow().exponent(panel.color.exponent)
|
||||
.domain([0, maxValue])
|
||||
.range([0, 1]);
|
||||
}
|
||||
}
|
||||
|
||||
function getCardX(d) {
|
||||
let x;
|
||||
// cx is the center of the card. Card should be placed to the left.
|
||||
let cx = xScale(d.x);
|
||||
|
||||
if (cx - cardWidth/2 < 0) {
|
||||
x = yAxisWidth + cardHSpacing/2;
|
||||
} else {
|
||||
x = yAxisWidth + cx - cardWidth/2;
|
||||
}
|
||||
|
||||
return x;
|
||||
}
|
||||
|
||||
// xScale returns card center. Adjust cardWidth in case of overlaping.
|
||||
function getCardWidth(d) {
|
||||
let w;
|
||||
let cx = xScale(d.x);
|
||||
|
||||
if (cx < cardWidth/2) {
|
||||
// Center should not exceed half of card.
|
||||
// Cut card to the left to prevent overlay of y axis.
|
||||
let cutted_width = (cx - cardHSpacing/2) + cardWidth/2;
|
||||
w = cutted_width > 0 ? cutted_width : 0;
|
||||
} else if (chartWidth - cx < cardWidth/2) {
|
||||
// Cut card to the right to prevent overlay of right graph edge.
|
||||
w = cardWidth/2 + (chartWidth - cx - cardHSpacing/2);
|
||||
} else {
|
||||
w = cardWidth;
|
||||
}
|
||||
|
||||
// Card width should be MIN_CARD_SIZE at least
|
||||
w = Math.max(w, MIN_CARD_SIZE);
|
||||
|
||||
if (cardHSpacing == 0) {
|
||||
w = w+1;
|
||||
}
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
function getCardY(d) {
|
||||
return yScale(d.y) + chartTop - cardHeight - cardVSpacing/2;
|
||||
}
|
||||
|
||||
function getCardHeight(d) {
|
||||
let ys = yScale(d.y);
|
||||
let y = ys + chartTop - cardHeight - cardVSpacing/2;
|
||||
let h = cardHeight;
|
||||
|
||||
// Cut card height to prevent overlay
|
||||
if (y < chartTop) {
|
||||
h = ys - cardVSpacing/2;
|
||||
} 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);
|
||||
// Card height should be MIN_CARD_SIZE at least
|
||||
h = Math.max(h, MIN_CARD_SIZE);
|
||||
|
||||
if (cardVSpacing == 0) {
|
||||
h = h+1
|
||||
}
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
function getCardColor(d) {
|
||||
if (panel.color.mode === 'opacity') {
|
||||
return panel.color.cardColor;
|
||||
} else if (panel.color.mode === 'spectrum') {
|
||||
return colorScale(d.value);
|
||||
} else if (panel.color.mode === 'discrete') {
|
||||
return ctrl.discreteHelper.getBucketColor(d.values);
|
||||
}
|
||||
}
|
||||
|
||||
function getCardOpacity(d) {
|
||||
if (panel.nullPointMode === 'as empty' && d.value == null ) {
|
||||
return 0;
|
||||
}
|
||||
if (panel.color.mode === 'opacity') {
|
||||
return opacityScale(d.value);
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function getCardStrokeWidth(d) {
|
||||
if (panel.color.mode === 'discrete') {
|
||||
return '1';
|
||||
}
|
||||
return '0';
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// Selection and crosshair //
|
||||
/////////////////////////////
|
||||
|
||||
// Shared crosshair and tooltip
|
||||
appEvents.on('graph-hover', event => {
|
||||
drawSharedCrosshair(event.pos);
|
||||
}, scope);
|
||||
|
||||
appEvents.on('graph-hover-clear', () => {
|
||||
clearCrosshair();
|
||||
}, scope);
|
||||
|
||||
function onMouseDown(event) {
|
||||
selection.active = true;
|
||||
selection.x1 = event.offsetX;
|
||||
|
||||
mouseUpHandler = function() {
|
||||
onMouseUp();
|
||||
};
|
||||
|
||||
$(document).one("mouseup", mouseUpHandler);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
$(document).unbind("mouseup", mouseUpHandler);
|
||||
mouseUpHandler = null;
|
||||
selection.active = false;
|
||||
|
||||
let selectionRange = Math.abs(selection.x2 - selection.x1);
|
||||
if (selection.x2 >= 0 && selectionRange > MIN_SELECTION_WIDTH) {
|
||||
let timeFrom = xScale.invert(Math.min(selection.x1, selection.x2) - yAxisWidth - xGridSize/2);
|
||||
let timeTo = xScale.invert(Math.max(selection.x1, selection.x2) - yAxisWidth - xGridSize/2);
|
||||
|
||||
ctrl.timeSrv.setTime({
|
||||
from: moment.utc(timeFrom),
|
||||
to: moment.utc(timeTo)
|
||||
});
|
||||
}
|
||||
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
appEvents.emit('graph-hover-clear');
|
||||
clearCrosshair();
|
||||
//annotationTooltip.destroy();
|
||||
}
|
||||
|
||||
function onMouseMove(event) {
|
||||
if (!heatmap) { return; }
|
||||
|
||||
if (selection.active) {
|
||||
// Clear crosshair and tooltip
|
||||
clearCrosshair();
|
||||
tooltip.destroy();
|
||||
annotationTooltip.destroy();
|
||||
|
||||
selection.x2 = limitSelection(event.offsetX);
|
||||
drawSelection(selection.x1, selection.x2);
|
||||
} else {
|
||||
emitGraphHoverEvet(event);
|
||||
drawCrosshair(event.offsetX);
|
||||
tooltip.show(event); //, data);
|
||||
annotationTooltip.show(event);
|
||||
}
|
||||
}
|
||||
|
||||
function emitGraphHoverEvet(event) {
|
||||
let x = xScale.invert(event.offsetX - yAxisWidth - xGridSize/2).valueOf();
|
||||
let y = yScale(event.offsetY);
|
||||
let pos = {
|
||||
pageX: event.pageX,
|
||||
pageY: event.pageY,
|
||||
x: x, x1: x,
|
||||
y: y, y1: y,
|
||||
panelRelY: null
|
||||
};
|
||||
|
||||
// Set minimum offset to prevent showing legend from another panel
|
||||
pos.panelRelY = Math.max(event.offsetY / height, 0.001);
|
||||
|
||||
// broadcast to other graph panels that we are hovering
|
||||
appEvents.emit('graph-hover', {pos: pos, panel: panel});
|
||||
}
|
||||
|
||||
function limitSelection(x2) {
|
||||
x2 = Math.max(x2, yAxisWidth);
|
||||
x2 = Math.min(x2, chartWidth + yAxisWidth);
|
||||
return x2;
|
||||
}
|
||||
|
||||
function drawSelection(posX1, posX2) {
|
||||
if (heatmap) {
|
||||
heatmap.selectAll(".status-heatmap-selection").remove();
|
||||
let selectionX = Math.min(posX1, posX2);
|
||||
let selectionWidth = Math.abs(posX1 - posX2);
|
||||
|
||||
if (selectionWidth > MIN_SELECTION_WIDTH) {
|
||||
heatmap.append("rect")
|
||||
.attr("class", "status-heatmap-selection")
|
||||
.attr("x", selectionX)
|
||||
.attr("width", selectionWidth)
|
||||
.attr("y", chartTop)
|
||||
.attr("height", chartHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selection.x1 = -1;
|
||||
selection.x2 = -1;
|
||||
|
||||
if (heatmap) {
|
||||
heatmap.selectAll(".status-heatmap-selection").remove();
|
||||
}
|
||||
}
|
||||
|
||||
function drawCrosshair(position) {
|
||||
if (heatmap) {
|
||||
heatmap.selectAll(".status-heatmap-crosshair").remove();
|
||||
|
||||
let posX = position;
|
||||
posX = Math.max(posX, yAxisWidth);
|
||||
posX = Math.min(posX, chartWidth + yAxisWidth);
|
||||
|
||||
heatmap.append("g")
|
||||
.attr("class", "status-heatmap-crosshair")
|
||||
.attr("transform", "translate(" + posX + ",0)")
|
||||
.append("line")
|
||||
.attr("x1", 1)
|
||||
.attr("y1", chartTop)
|
||||
.attr("x2", 1)
|
||||
.attr("y2", chartBottom)
|
||||
.attr("stroke-width", 1);
|
||||
}
|
||||
}
|
||||
|
||||
// map time to X
|
||||
function drawSharedCrosshair(pos) {
|
||||
if (heatmap && ctrl.dashboard.graphTooltip !== 0) {
|
||||
let posX = xScale(pos.x) + yAxisWidth;
|
||||
drawCrosshair(posX);
|
||||
}
|
||||
}
|
||||
|
||||
function clearCrosshair() {
|
||||
if (heatmap) {
|
||||
heatmap.selectAll(".status-heatmap-crosshair").remove();
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
data = ctrl.data;
|
||||
panel = ctrl.panel;
|
||||
timeRange = ctrl.range;
|
||||
cardsData = ctrl.cardsData;
|
||||
|
||||
if (!data || !cardsData || !setElementHeight()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw default axes and return if no data
|
||||
if (_.isEmpty(cardsData.cards)) {
|
||||
addHeatmapCanvas();
|
||||
return;
|
||||
}
|
||||
|
||||
addHeatmap();
|
||||
scope.yAxisWidth = yAxisWidth;
|
||||
scope.xAxisHeight = xAxisHeight;
|
||||
scope.chartHeight = chartHeight;
|
||||
scope.chartWidth = chartWidth;
|
||||
scope.chartTop = chartTop;
|
||||
}
|
||||
|
||||
// Register selection listeners
|
||||
$heatmap.on("mousedown", onMouseDown);
|
||||
$heatmap.on("mousemove", onMouseMove);
|
||||
$heatmap.on("mouseleave", onMouseLeave);
|
||||
|
||||
function _renderAnnotations() {
|
||||
if (!ctrl.annotations || ctrl.annotations.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!heatmap) {
|
||||
return;
|
||||
}
|
||||
|
||||
let annoData = _.map(ctrl.annotations, (d,i) => ({"x": Math.floor(yAxisWidth + xScale(d.time)), "id":i, "anno": d.source}));
|
||||
|
||||
|
||||
let anno = heatmap
|
||||
.append("g")
|
||||
.attr("class", "statusmap-annotations")
|
||||
.attr("transform", "translate(0.5,0)")
|
||||
.selectAll(".statusmap-annotations")
|
||||
.data(annoData)
|
||||
.enter().append("g")
|
||||
;
|
||||
anno.append("line")
|
||||
//.attr("class", "statusmap-annotation-tick")
|
||||
.attr("x1", d => d.x)
|
||||
.attr("y1", chartTop)
|
||||
.attr("x2", d => d.x)
|
||||
.attr("y2", chartBottom)
|
||||
.style("stroke", d => d.anno.iconColor)
|
||||
.style("stroke-width", 1)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
;
|
||||
anno.append("polygon")
|
||||
.attr("points", d => [[d.x, chartBottom+1], [d.x-5, chartBottom+6], [d.x+5, chartBottom+6]].join(" "))
|
||||
.style("stroke-width", 0)
|
||||
.style("fill", d => d.anno.iconColor)
|
||||
;
|
||||
// Polygons didn't fire mouseevents
|
||||
anno.append("rect")
|
||||
.attr("x", d => d.x-5)
|
||||
.attr("width", 10)
|
||||
.attr("y", chartBottom+1)
|
||||
.attr("height", 5)
|
||||
.attr("class", "statusmap-annotation-tick")
|
||||
.attr("annoId", d => d.id)
|
||||
.style("opacity", 0)
|
||||
;
|
||||
|
||||
let $ticks = $heatmap.find(".statusmap-annotation-tick");
|
||||
$ticks.on("mouseenter", (event) => {
|
||||
annotationTooltip.mouseOverAnnotationTick = true;
|
||||
})
|
||||
.on("mouseleave", (event) => {
|
||||
annotationTooltip.mouseOverAnnotationTick = false;
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
function grafanaTimeFormat(ticks, min, max) {
|
||||
if (min && max && ticks) {
|
||||
let range = max - min;
|
||||
let secPerTick = (range/ticks) / 1000;
|
||||
let oneDay = 86400000;
|
||||
let oneYear = 31536000000;
|
||||
|
||||
if (secPerTick <= 45) {
|
||||
return "%H:%M:%S";
|
||||
}
|
||||
if (secPerTick <= 7200 || range <= oneDay) {
|
||||
return "%H:%M";
|
||||
}
|
||||
if (secPerTick <= 80000) {
|
||||
return "%m/%d %H:%M";
|
||||
}
|
||||
if (secPerTick <= 2419200 || range <= oneYear) {
|
||||
return "%m/%d";
|
||||
}
|
||||
return "%Y-%m";
|
||||
}
|
||||
|
||||
return "%H:%M";
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
import _ from 'lodash';
|
||||
import $ from 'jquery';
|
||||
import moment from 'moment';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import {appEvents, contextSrv} from 'app/core/core';
|
||||
import {tickStep, getScaledDecimals, getFlotTickSize} from 'app/core/utils/ticks';
|
||||
import * as d3 from 'd3';
|
||||
import * as d3ScaleChromatic from './libs/d3-scale-chromatic/index';
|
||||
import {StatusmapTooltip} from './tooltip';
|
||||
import {AnnotationTooltip} from './annotations';
|
||||
|
||||
let MIN_CARD_SIZE = 5,
|
||||
CARD_H_SPACING = 2,
|
||||
CARD_V_SPACING = 2,
|
||||
CARD_ROUND = 0,
|
||||
DATA_RANGE_WIDING_FACTOR = 1.2,
|
||||
DEFAULT_X_TICK_SIZE_PX = 100,
|
||||
DEFAULT_Y_TICK_SIZE_PX = 50,
|
||||
X_AXIS_TICK_PADDING = 10,
|
||||
Y_AXIS_TICK_PADDING = 5,
|
||||
MIN_SELECTION_WIDTH = 2;
|
||||
|
||||
export default function rendering(scope: any, elem: any, attrs: any, ctrl: any) {
|
||||
return new StatusmapRenderer(scope, elem, attrs, ctrl);
|
||||
}
|
||||
|
||||
export class StatusmapRenderer {
|
||||
width: number = 0;
|
||||
height: number = 0;
|
||||
yScale: any;
|
||||
xScale: any;
|
||||
chartWidth: number = 0;
|
||||
chartHeight: number = 0;
|
||||
chartTop: number = 0;
|
||||
chartBottom: number = 0;
|
||||
yAxisWidth: number = 0;
|
||||
xAxisHeight: number = 0;
|
||||
cardVSpacing: number = 0;
|
||||
cardHSpacing: number = 0;
|
||||
cardRound: number = 0;
|
||||
cardWidth: number = 0;
|
||||
cardHeight: number = 0;
|
||||
colorScale: any;
|
||||
opacityScale: any;
|
||||
mouseUpHandler: any;
|
||||
xGridSize: number = 0;
|
||||
yGridSize: number = 0;
|
||||
data: any;
|
||||
cardsData: any;
|
||||
panel: any;
|
||||
$heatmap: any;
|
||||
tooltip: StatusmapTooltip;
|
||||
annotationTooltip: AnnotationTooltip;
|
||||
heatmap: any;
|
||||
timeRange: any;
|
||||
|
||||
yOffset: number;
|
||||
selection: any;
|
||||
padding: any;
|
||||
margin: any;
|
||||
dataRangeWidingFactor: number = DATA_RANGE_WIDING_FACTOR;
|
||||
|
||||
constructor(private scope: any, private elem: any, attrs: any, private ctrl: any) {
|
||||
// $heatmap is JQuery object, but heatmap is D3
|
||||
this.$heatmap = this.elem.find('.status-heatmap-panel');
|
||||
this.tooltip = new StatusmapTooltip(this.$heatmap, this.scope);
|
||||
this.annotationTooltip = new AnnotationTooltip(this.$heatmap, this.scope);
|
||||
|
||||
this.yOffset = 0;
|
||||
|
||||
this.selection = {
|
||||
active: false,
|
||||
x1: -1,
|
||||
x2: -1,
|
||||
};
|
||||
|
||||
this.padding = { left: 0, right: 0, top: 0, bottom: 0 };
|
||||
this.margin = { left: 25, right: 15, top: 10, bottom: 20 };
|
||||
|
||||
this.ctrl.events.on('render', this.onRender.bind(this));
|
||||
|
||||
this.ctrl.tickValueFormatter = this.tickValueFormatter.bind(this);
|
||||
|
||||
/////////////////////////////
|
||||
// Selection and crosshair //
|
||||
/////////////////////////////
|
||||
|
||||
// Shared crosshair and tooltip
|
||||
appEvents.on('graph-hover', this.onGraphHover.bind(this), this.scope);
|
||||
|
||||
appEvents.on('graph-hover-clear', this.onGraphHoverClear.bind(this), this.scope);
|
||||
|
||||
// Register selection listeners
|
||||
this.$heatmap.on('mousedown', this.onMouseDown.bind(this));
|
||||
this.$heatmap.on('mousemove', this.onMouseMove.bind(this));
|
||||
this.$heatmap.on('mouseleave', this.onMouseLeave.bind(this));
|
||||
}
|
||||
|
||||
onGraphHoverClear() {
|
||||
this.clearCrosshair();
|
||||
}
|
||||
|
||||
onGraphHover(event: { pos: any }) {
|
||||
this.drawSharedCrosshair(event.pos);
|
||||
}
|
||||
|
||||
onRender() {
|
||||
this.render();
|
||||
this.ctrl.renderingCompleted();
|
||||
}
|
||||
|
||||
|
||||
setElementHeight() {
|
||||
try {
|
||||
var height = this.ctrl.height || this.panel.height || this.ctrl.row.height;
|
||||
if (_.isString(height)) {
|
||||
height = parseInt(height.replace('px', ''), 10);
|
||||
}
|
||||
|
||||
height -= this.panel.legend.show ? 32 : 10; // bottom padding and space for legend. Change margin in .status-heatmap-color-legend !
|
||||
|
||||
this.$heatmap.css('height', height + 'px');
|
||||
|
||||
return true;
|
||||
} catch (e) { // IE throws errors sometimes
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getYAxisWidth(elem: any) {
|
||||
const axisText = elem.selectAll(".axis-y text").nodes();
|
||||
const maxTextWidth = _.max(_.map(axisText, text => {
|
||||
// Use SVG getBBox method
|
||||
return text.getBBox().width;
|
||||
}));
|
||||
|
||||
return Math.ceil(maxTextWidth);
|
||||
}
|
||||
|
||||
getXAxisHeight(elem: any) {
|
||||
let axisLine = elem.select(".axis-x line");
|
||||
if (!axisLine.empty()) {
|
||||
let axisLinePosition = parseFloat(elem.select(".axis-x line").attr("y2"));
|
||||
let canvasWidth = parseFloat(elem.attr("height"));
|
||||
return canvasWidth - axisLinePosition;
|
||||
} else {
|
||||
// Default height
|
||||
return 30;
|
||||
}
|
||||
}
|
||||
|
||||
addXAxis() {
|
||||
// Scale timestamps to cards centers
|
||||
this.scope.xScale = this.xScale = d3.scaleTime()
|
||||
.domain([this.timeRange.from, this.timeRange.to])
|
||||
.range([this.xGridSize/2, this.chartWidth-this.xGridSize/2]);
|
||||
|
||||
let ticks = this.chartWidth / DEFAULT_X_TICK_SIZE_PX;
|
||||
let grafanaTimeFormatter = grafanaTimeFormat(ticks, this.timeRange.from, this.timeRange.to);
|
||||
let timeFormat;
|
||||
let dashboardTimeZone = this.ctrl.dashboard.getTimezone();
|
||||
if (dashboardTimeZone === 'utc') {
|
||||
timeFormat = d3.utcFormat(grafanaTimeFormatter);
|
||||
} else {
|
||||
timeFormat = d3.timeFormat(grafanaTimeFormatter);
|
||||
}
|
||||
|
||||
let xAxis = d3
|
||||
.axisBottom(this.xScale)
|
||||
.ticks(ticks)
|
||||
.tickFormat(timeFormat)
|
||||
.tickPadding(X_AXIS_TICK_PADDING)
|
||||
.tickSize(this.chartHeight);
|
||||
|
||||
let posY = this.chartTop; // this.margin.top !
|
||||
let posX = this.yAxisWidth;
|
||||
|
||||
this.heatmap.append("g")
|
||||
.attr("class", "axis axis-x")
|
||||
.attr("transform", "translate(" + posX + "," + posY + ")")
|
||||
.call(xAxis);
|
||||
|
||||
// Remove horizontal line in the top of axis labels (called domain in d3)
|
||||
this.heatmap
|
||||
.select(".axis-x")
|
||||
.select(".domain")
|
||||
.remove();
|
||||
}
|
||||
|
||||
// divide chart height by ticks for cards drawing
|
||||
getYScale(ticks) {
|
||||
let range:any[] = [];
|
||||
let step = this.chartHeight / ticks.length;
|
||||
// svg has y=0 on the top, so top card should have a minimal value in range
|
||||
range.push(step);
|
||||
for (let i = 1; i < ticks.length; i++) {
|
||||
range.push(step * (i+1));
|
||||
}
|
||||
return d3.scaleOrdinal()
|
||||
.domain(ticks)
|
||||
.range(range);
|
||||
}
|
||||
|
||||
// divide chart height by ticks with offset for ticks drawing
|
||||
getYAxisScale(ticks) {
|
||||
let range:any[] = [];
|
||||
let step = this.chartHeight / ticks.length;
|
||||
// svg has y=0 on the top, so top tick should have a minimal value in range
|
||||
range.push(this.yOffset);
|
||||
for (let i = 1; i < ticks.length; i++) {
|
||||
range.push(step * i + this.yOffset);
|
||||
}
|
||||
return d3.scaleOrdinal()
|
||||
.domain(ticks)
|
||||
.range(range);
|
||||
}
|
||||
|
||||
addYAxis() {
|
||||
let ticks = _.uniq(_.map(this.data, d => d.target));
|
||||
|
||||
// Set default Y min and max if no data
|
||||
if (_.isEmpty(this.data)) {
|
||||
ticks = [''];
|
||||
}
|
||||
|
||||
if (this.panel.yAxisSort == 'a → z') {
|
||||
ticks.sort((a, b) => a.localeCompare(b, 'en', {ignorePunctuation: false, numeric: true}));
|
||||
} else if (this.panel.yAxisSort == 'z → a') {
|
||||
ticks.sort((b, a) => a.localeCompare(b, 'en', {ignorePunctuation: false, numeric: true}));
|
||||
}
|
||||
|
||||
let yAxisScale = this.getYAxisScale(ticks);
|
||||
this.scope.yScale = this.yScale = this.getYScale(ticks);
|
||||
|
||||
let yAxis = d3
|
||||
.axisLeft(yAxisScale)
|
||||
.tickValues(ticks)
|
||||
.tickSizeInner(0 - this.width)
|
||||
.tickPadding(Y_AXIS_TICK_PADDING);
|
||||
|
||||
this.heatmap
|
||||
.append("g")
|
||||
.attr("class", "axis axis-y")
|
||||
.call(yAxis);
|
||||
|
||||
// Calculate Y axis width first, then move axis into visible area
|
||||
let posY = this.margin.top;
|
||||
let posX = this.getYAxisWidth(this.heatmap) + Y_AXIS_TICK_PADDING;
|
||||
this.heatmap.select(".axis-y").attr("transform", "translate(" + posX + "," + posY + ")");
|
||||
|
||||
// Remove vertical line in the right of axis labels (called domain in d3)
|
||||
this.heatmap.select(".axis-y").select(".domain").remove();
|
||||
this.heatmap.select(".axis-y").selectAll(".tick line").remove();
|
||||
}
|
||||
|
||||
// Wide Y values range and adjust to bucket size
|
||||
wideYAxisRange(min, max, tickInterval) {
|
||||
let y_widing = (max * (this.dataRangeWidingFactor - 1) - min * (this.dataRangeWidingFactor - 1)) / 2;
|
||||
let y_min, y_max;
|
||||
|
||||
if (tickInterval === 0) {
|
||||
y_max = max * this.dataRangeWidingFactor;
|
||||
y_min = min - min * (this.dataRangeWidingFactor - 1);
|
||||
tickInterval = (y_max - y_min) / 2;
|
||||
} else {
|
||||
y_max = Math.ceil((max + y_widing) / tickInterval) * tickInterval;
|
||||
y_min = Math.floor((min - y_widing) / tickInterval) * tickInterval;
|
||||
}
|
||||
|
||||
// Don't wide axis below 0 if all values are positive
|
||||
if (min >= 0 && y_min < 0) {
|
||||
y_min = 0;
|
||||
}
|
||||
|
||||
return {y_min, y_max};
|
||||
}
|
||||
|
||||
tickValueFormatter(decimals, scaledDecimals = null) {
|
||||
let format = this.panel.yAxis.format;
|
||||
return function(value) {
|
||||
return kbn.valueFormats[format](value, decimals, scaledDecimals);
|
||||
};
|
||||
}
|
||||
|
||||
// Create svg element, add axes and
|
||||
// calculate sizes for cards drawing
|
||||
addHeatmapCanvas() {
|
||||
let heatmap_elem = this.$heatmap[0];
|
||||
|
||||
this.width = Math.floor(this.$heatmap.width()) - this.padding.right;
|
||||
this.height = Math.floor(this.$heatmap.height()) - this.padding.bottom;
|
||||
|
||||
if (this.heatmap) {
|
||||
this.heatmap.remove();
|
||||
}
|
||||
|
||||
this.heatmap = d3.select(heatmap_elem)
|
||||
.append("svg")
|
||||
.attr("width", this.width)
|
||||
.attr("height", this.height);
|
||||
|
||||
this.chartHeight = this.height - this.margin.top - this.margin.bottom;
|
||||
this.chartTop = this.margin.top;
|
||||
this.chartBottom = this.chartTop + this.chartHeight;
|
||||
|
||||
this.cardHSpacing = this.panel.cards.cardHSpacing !== null ? this.panel.cards.cardHSpacing : CARD_H_SPACING;
|
||||
this.cardVSpacing = this.panel.cards.cardVSpacing !== null ? this.panel.cards.cardVSpacing : CARD_V_SPACING;
|
||||
this.cardRound = this.panel.cards.cardRound !== null ? this.panel.cards.cardRound : CARD_ROUND;
|
||||
|
||||
// calculate yOffset for YAxis
|
||||
this.yGridSize = Math.floor(this.chartHeight / this.cardsData.yBucketSize);
|
||||
this.cardHeight = this.yGridSize ? this.yGridSize - this.cardVSpacing : 0;
|
||||
this.yOffset = this.cardHeight / 2;
|
||||
|
||||
this.addYAxis();
|
||||
|
||||
this.yAxisWidth = this.getYAxisWidth(this.heatmap) + Y_AXIS_TICK_PADDING;
|
||||
this.chartWidth = this.width - this.yAxisWidth - this.margin.right;
|
||||
|
||||
// TODO allow per-y cardWidth!
|
||||
// we need to fill chartWidth with xBucketSize cards.
|
||||
this.xGridSize = this.chartWidth / (this.cardsData.xBucketSize+1);
|
||||
this.cardWidth = this.xGridSize - this.cardHSpacing;
|
||||
|
||||
this.addXAxis();
|
||||
this.xAxisHeight = this.getXAxisHeight(this.heatmap);
|
||||
|
||||
if (!this.panel.yAxis.show) {
|
||||
this.heatmap.select(".axis-y").selectAll("line").style("opacity", 0);
|
||||
}
|
||||
|
||||
if (!this.panel.xAxis.show) {
|
||||
this.heatmap.select(".axis-x").selectAll("line").style("opacity", 0);
|
||||
}
|
||||
}
|
||||
|
||||
addHeatmap() {
|
||||
this.addHeatmapCanvas();
|
||||
|
||||
let maxValue = this.panel.color.max || this.cardsData.maxValue;
|
||||
let minValue = this.panel.color.min || this.cardsData.minValue;
|
||||
|
||||
if (this.panel.color.mode !== 'discrete') {
|
||||
this.colorScale = this.getColorScale(maxValue, minValue);
|
||||
}
|
||||
this.setOpacityScale(maxValue);
|
||||
|
||||
let cards = this.heatmap.selectAll(".status-heatmap-card").data(this.cardsData.cards);
|
||||
cards.append("title");
|
||||
cards = cards.enter().append("rect")
|
||||
.attr("cardId", c => c.id)
|
||||
.attr("x", this.getCardX.bind(this))
|
||||
.attr("width", this.getCardWidth.bind(this))
|
||||
.attr("y", this.getCardY.bind(this))
|
||||
.attr("height", this.getCardHeight.bind(this))
|
||||
.attr("rx", this.cardRound)
|
||||
.attr("ry", this.cardRound)
|
||||
.attr("class", "bordered status-heatmap-card")
|
||||
.style("fill", this.getCardColor.bind(this))
|
||||
.style("stroke", this.getCardColor.bind(this))
|
||||
.style("stroke-width", 0)
|
||||
//.style("stroke-width", getCardStrokeWidth)
|
||||
//.style("stroke-dasharray", "3,3")
|
||||
.style("opacity", this.getCardOpacity.bind(this));
|
||||
|
||||
let $cards = this.$heatmap.find(".status-heatmap-card");
|
||||
$cards
|
||||
.on("mouseenter", (event) => {
|
||||
this.tooltip.mouseOverBucket = true;
|
||||
this.highlightCard(event);
|
||||
})
|
||||
.on("mouseleave", (event) => {
|
||||
this.tooltip.mouseOverBucket = false;
|
||||
this.resetCardHighLight(event);
|
||||
});
|
||||
|
||||
this._renderAnnotations();
|
||||
|
||||
this.ctrl.events.emit('render-complete', {
|
||||
"chartWidth": this.chartWidth
|
||||
});
|
||||
}
|
||||
|
||||
highlightCard(event) {
|
||||
const color = d3.select(event.target).style("fill");
|
||||
const highlightColor = d3.color(color).darker(2);
|
||||
const strokeColor = d3.color(color).brighter(4);
|
||||
const current_card = d3.select(event.target);
|
||||
this.tooltip.originalFillColor = color;
|
||||
current_card
|
||||
.style("fill", highlightColor.toString())
|
||||
.style("stroke", strokeColor.toString())
|
||||
.style("stroke-width", 1);
|
||||
}
|
||||
|
||||
resetCardHighLight(event) {
|
||||
d3.select(event.target)
|
||||
.style("fill", this.tooltip.originalFillColor)
|
||||
.style("stroke", this.tooltip.originalFillColor)
|
||||
.style("stroke-width", 0);
|
||||
}
|
||||
|
||||
getColorScale(maxValue, minValue = 0) {
|
||||
let colorScheme = _.find(this.ctrl.colorSchemes, {value: this.panel.color.colorScheme});
|
||||
// if (!colorScheme) {
|
||||
//
|
||||
// }
|
||||
let colorInterpolator = d3ScaleChromatic[colorScheme.value];
|
||||
let colorScaleInverted = colorScheme.invert === 'always' ||
|
||||
(colorScheme.invert === 'dark' && !contextSrv.user.lightTheme);
|
||||
|
||||
if (maxValue == minValue)
|
||||
maxValue = minValue + 1;
|
||||
|
||||
let start = colorScaleInverted ? maxValue : minValue;
|
||||
let end = colorScaleInverted ? minValue : maxValue;
|
||||
|
||||
return d3.scaleSequential(colorInterpolator).domain([start, end]);
|
||||
}
|
||||
|
||||
setOpacityScale(maxValue) {
|
||||
if (this.panel.color.colorScale === 'linear') {
|
||||
this.opacityScale = d3.scaleLinear()
|
||||
.domain([0, maxValue])
|
||||
.range([0, 1]);
|
||||
} else if (this.panel.color.colorScale === 'sqrt') {
|
||||
this.opacityScale = d3.scalePow().exponent(this.panel.color.exponent)
|
||||
.domain([0, maxValue])
|
||||
.range([0, 1]);
|
||||
}
|
||||
}
|
||||
|
||||
getCardX(d) {
|
||||
let x;
|
||||
// cx is the center of the card. Card should be placed to the left.
|
||||
let cx = this.xScale(d.x);
|
||||
|
||||
if (cx - this.cardWidth/2 < 0) {
|
||||
x = this.yAxisWidth + this.cardHSpacing/2;
|
||||
} else {
|
||||
x = this.yAxisWidth + cx - this.cardWidth/2;
|
||||
}
|
||||
|
||||
return x;
|
||||
}
|
||||
|
||||
// xScale returns card center. Adjust cardWidth in case of overlaping.
|
||||
getCardWidth(d) {
|
||||
let w;
|
||||
let cx = this.xScale(d.x);
|
||||
|
||||
if (cx < this.cardWidth/2) {
|
||||
// Center should not exceed half of card.
|
||||
// Cut card to the left to prevent overlay of y axis.
|
||||
let cutted_width = (cx - this.cardHSpacing/2) + this.cardWidth/2;
|
||||
w = cutted_width > 0 ? cutted_width : 0;
|
||||
} else if (this.chartWidth - cx < this.cardWidth/2) {
|
||||
// Cut card to the right to prevent overlay of right graph edge.
|
||||
w = this.cardWidth/2 + (this.chartWidth - cx - this.cardHSpacing/2);
|
||||
} else {
|
||||
w = this.cardWidth;
|
||||
}
|
||||
|
||||
// Card width should be MIN_CARD_SIZE at least
|
||||
w = Math.max(w, MIN_CARD_SIZE);
|
||||
|
||||
if (this.cardHSpacing == 0) {
|
||||
w = w+1;
|
||||
}
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
getCardY(d) {
|
||||
return this.yScale(d.y) + this.chartTop - this.cardHeight - this.cardVSpacing/2;
|
||||
}
|
||||
|
||||
getCardHeight(d) {
|
||||
let ys = this.yScale(d.y);
|
||||
let y = ys + this.chartTop - this.cardHeight - this.cardVSpacing/2;
|
||||
let h = this.cardHeight;
|
||||
|
||||
// Cut card height to prevent overlay
|
||||
if (y < this.chartTop) {
|
||||
h = ys - this.cardVSpacing/2;
|
||||
} else if (ys > this.chartBottom) {
|
||||
h = this.chartBottom - y;
|
||||
} else if (y + this.cardHeight > this.chartBottom) {
|
||||
h = this.chartBottom - y;
|
||||
}
|
||||
|
||||
// Height can't be more than chart height
|
||||
h = Math.min(h, this.chartHeight);
|
||||
// Card height should be MIN_CARD_SIZE at least
|
||||
h = Math.max(h, MIN_CARD_SIZE);
|
||||
|
||||
if (this.cardVSpacing == 0) {
|
||||
h = h+1
|
||||
}
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
getCardColor(d) {
|
||||
if (this.panel.color.mode === 'opacity') {
|
||||
return this.panel.color.cardColor;
|
||||
} else if (this.panel.color.mode === 'spectrum') {
|
||||
return this.colorScale(d.value);
|
||||
} else if (this.panel.color.mode === 'discrete') {
|
||||
return this.ctrl.discreteHelper.getBucketColor(d.values);
|
||||
}
|
||||
}
|
||||
|
||||
getCardOpacity(d) {
|
||||
if (this.panel.nullPointMode === 'as empty' && d.value == null ) {
|
||||
return 0;
|
||||
}
|
||||
if (this.panel.color.mode === 'opacity') {
|
||||
return this.opacityScale(d.value);
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
getCardStrokeWidth(d) {
|
||||
if (this.panel.color.mode === 'discrete') {
|
||||
return '1';
|
||||
}
|
||||
return '0';
|
||||
}
|
||||
|
||||
|
||||
/////////////////////////////
|
||||
// Selection and crosshair //
|
||||
/////////////////////////////
|
||||
|
||||
getEventOffset(event) {
|
||||
const elemOffset = this.$heatmap.offset();
|
||||
const x = Math.floor(event.clientX - elemOffset.left);
|
||||
const y = Math.floor(event.clientY - elemOffset.top);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
onMouseDown(event) {
|
||||
const offset = this.getEventOffset(event);
|
||||
this.selection.active = true;
|
||||
this.selection.x1 = offset.x;
|
||||
|
||||
this.mouseUpHandler = () => {
|
||||
this.onMouseUp();
|
||||
};
|
||||
|
||||
$(document).one("mouseup", this.mouseUpHandler.bind(this));
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
$(document).unbind("mouseup", this.mouseUpHandler.bind(this));
|
||||
this.mouseUpHandler = null;
|
||||
this.selection.active = false;
|
||||
|
||||
let selectionRange = Math.abs(this.selection.x2 - this.selection.x1);
|
||||
if (this.selection.x2 >= 0 && selectionRange > MIN_SELECTION_WIDTH) {
|
||||
let timeFrom = this.xScale.invert(Math.min(this.selection.x1, this.selection.x2) - this.yAxisWidth - this.xGridSize/2);
|
||||
let timeTo = this.xScale.invert(Math.max(this.selection.x1, this.selection.x2) - this.yAxisWidth - this.xGridSize/2);
|
||||
|
||||
this.ctrl.timeSrv.setTime({
|
||||
from: moment.utc(timeFrom),
|
||||
to: moment.utc(timeTo)
|
||||
});
|
||||
}
|
||||
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
appEvents.emit('graph-hover-clear');
|
||||
this.clearCrosshair();
|
||||
//annotationTooltip.destroy();
|
||||
}
|
||||
|
||||
onMouseMove(event) {
|
||||
if (!this.heatmap) { return; }
|
||||
|
||||
const offset = this.getEventOffset(event);
|
||||
if (this.selection.active) {
|
||||
// Clear crosshair and tooltip
|
||||
this.clearCrosshair();
|
||||
this.tooltip.destroy();
|
||||
this.annotationTooltip.destroy();
|
||||
|
||||
this.selection.x2 = this.limitSelection(event.offsetX);
|
||||
this.drawSelection(this.selection.x1, this.selection.x2);
|
||||
} else {
|
||||
//const pos = this.getEventPos(event, offset);
|
||||
this.emitGraphHoverEvent(event);
|
||||
this.drawCrosshair(offset.x);
|
||||
this.tooltip.show(event); //, data); // pos, this.data
|
||||
this.annotationTooltip.show(event);
|
||||
}
|
||||
}
|
||||
|
||||
getEventPos(event, offset) {
|
||||
const x = this.xScale.invert(offset.x - this.yAxisWidth).valueOf();
|
||||
const y = this.yScale.invert(offset.y - this.chartTop);
|
||||
const pos = {
|
||||
pageX: event.pageX,
|
||||
pageY: event.pageY,
|
||||
x: x,
|
||||
x1: x,
|
||||
y: y,
|
||||
y1: y,
|
||||
panelRelY: null,
|
||||
offset,
|
||||
};
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
emitGraphHoverEvent(event) {
|
||||
let x = this.xScale.invert(event.offsetX - this.yAxisWidth - this.xGridSize/2).valueOf();
|
||||
let y = this.yScale(event.offsetY);
|
||||
let pos = {
|
||||
pageX: event.pageX,
|
||||
pageY: event.pageY,
|
||||
x: x, x1: x,
|
||||
y: y, y1: y,
|
||||
panelRelY: 0
|
||||
};
|
||||
|
||||
// Set minimum offset to prevent showing legend from another panel
|
||||
pos.panelRelY = Math.max(event.offsetY / this.height, 0.001);
|
||||
|
||||
// broadcast to other graph panels that we are hovering
|
||||
appEvents.emit('graph-hover', {pos: pos, panel: this.panel});
|
||||
}
|
||||
|
||||
limitSelection(x2) {
|
||||
x2 = Math.max(x2, this.yAxisWidth);
|
||||
x2 = Math.min(x2, this.chartWidth + this.yAxisWidth);
|
||||
return x2;
|
||||
}
|
||||
|
||||
drawSelection(posX1, posX2) {
|
||||
if (this.heatmap) {
|
||||
this.heatmap.selectAll(".status-heatmap-selection").remove();
|
||||
let selectionX = Math.min(posX1, posX2);
|
||||
let selectionWidth = Math.abs(posX1 - posX2);
|
||||
|
||||
if (selectionWidth > MIN_SELECTION_WIDTH) {
|
||||
this.heatmap.append("rect")
|
||||
.attr("class", "status-heatmap-selection")
|
||||
.attr("x", selectionX)
|
||||
.attr("width", selectionWidth)
|
||||
.attr("y", this.chartTop)
|
||||
.attr("height", this.chartHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selection.x1 = -1;
|
||||
this.selection.x2 = -1;
|
||||
|
||||
if (this.heatmap) {
|
||||
this.heatmap.selectAll(".status-heatmap-selection").remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
drawCrosshair(position) {
|
||||
if (this.heatmap) {
|
||||
this.heatmap.selectAll(".status-heatmap-crosshair").remove();
|
||||
|
||||
let posX = position;
|
||||
posX = Math.max(posX, this.yAxisWidth);
|
||||
posX = Math.min(posX, this.chartWidth + this.yAxisWidth);
|
||||
|
||||
this.heatmap.append("g")
|
||||
.attr("class", "status-heatmap-crosshair")
|
||||
.attr("transform", "translate(" + posX + ",0)")
|
||||
.append("line")
|
||||
.attr("x1", 1)
|
||||
.attr("y1", this.chartTop)
|
||||
.attr("x2", 1)
|
||||
.attr("y2", this.chartBottom)
|
||||
.attr("stroke-width", 1);
|
||||
}
|
||||
}
|
||||
|
||||
// map time to X
|
||||
drawSharedCrosshair(pos) {
|
||||
if (this.heatmap && this.ctrl.dashboard.graphTooltip !== 0) {
|
||||
const posX = this.xScale(pos.x) + this.yAxisWidth;
|
||||
this.drawCrosshair(posX);
|
||||
}
|
||||
}
|
||||
|
||||
clearCrosshair() {
|
||||
if (this.heatmap) {
|
||||
this.heatmap.selectAll(".status-heatmap-crosshair").remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
this.data = this.ctrl.data;
|
||||
this.panel = this.ctrl.panel;
|
||||
this.timeRange = this.ctrl.range;
|
||||
this.cardsData = this.ctrl.cardsData;
|
||||
|
||||
if (!this.data || !this.cardsData || !this.setElementHeight()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw default axes and return if no data
|
||||
if (_.isEmpty(this.cardsData.cards)) {
|
||||
this.addHeatmapCanvas();
|
||||
return;
|
||||
}
|
||||
|
||||
this.addHeatmap();
|
||||
this.scope.yAxisWidth = this.yAxisWidth;
|
||||
this.scope.xAxisHeight = this.xAxisHeight;
|
||||
this.scope.chartHeight = this.chartHeight;
|
||||
this.scope.chartWidth = this.chartWidth;
|
||||
this.scope.chartTop = this.chartTop;
|
||||
}
|
||||
|
||||
_renderAnnotations() {
|
||||
if (!this.ctrl.annotations || this.ctrl.annotations.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.heatmap) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
let annoData = _.map(this.ctrl.annotations, (d,i) => ({"x": Math.floor(this.yAxisWidth + this.xScale(d.time)), "id":i, "anno": d.source}));
|
||||
|
||||
//console.log({"ctrl_annotations": this.ctrl.annotations, "annoData": annoData});
|
||||
|
||||
let anno = this.heatmap
|
||||
.append("g")
|
||||
.attr("class", "statusmap-annotations")
|
||||
.attr("transform", "translate(0.5,0)")
|
||||
.selectAll(".statusmap-annotations")
|
||||
.data(annoData)
|
||||
.enter().append("g")
|
||||
;
|
||||
anno.append("line")
|
||||
//.attr("class", "statusmap-annotation-tick")
|
||||
.attr("x1", d => d.x)
|
||||
.attr("y1", this.chartTop)
|
||||
.attr("x2", d => d.x)
|
||||
.attr("y2", this.chartBottom)
|
||||
.style("stroke", d => d.anno.iconColor)
|
||||
.style("stroke-width", 1)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
;
|
||||
anno.append("polygon")
|
||||
.attr("points", d => [[d.x, this.chartBottom+1], [d.x-5, this.chartBottom+6], [d.x+5, this.chartBottom+6]].join(" "))
|
||||
.style("stroke-width", 0)
|
||||
.style("fill", d => d.anno.iconColor)
|
||||
;
|
||||
// Polygons didn't fire mouseevents
|
||||
anno.append("rect")
|
||||
.attr("x", d => d.x-5)
|
||||
.attr("width", 10)
|
||||
.attr("y", this.chartBottom+1)
|
||||
.attr("height", 5)
|
||||
.attr("class", "statusmap-annotation-tick")
|
||||
.attr("annoId", d => d.id)
|
||||
.style("opacity", 0)
|
||||
;
|
||||
|
||||
let $ticks = this.$heatmap.find(".statusmap-annotation-tick");
|
||||
$ticks
|
||||
.on("mouseenter", (event) => {
|
||||
this.annotationTooltip.mouseOverAnnotationTick = true;
|
||||
})
|
||||
.on("mouseleave", (event) => {
|
||||
this.annotationTooltip.mouseOverAnnotationTick = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function grafanaTimeFormat(ticks, min, max) {
|
||||
if (min && max && ticks) {
|
||||
let range = max - min;
|
||||
let secPerTick = (range/ticks) / 1000;
|
||||
let oneDay = 86400000;
|
||||
let oneYear = 31536000000;
|
||||
|
||||
if (secPerTick <= 45) {
|
||||
return "%H:%M:%S";
|
||||
}
|
||||
if (secPerTick <= 7200 || range <= oneDay) {
|
||||
return "%H:%M";
|
||||
}
|
||||
if (secPerTick <= 80000) {
|
||||
return "%m/%d %H:%M";
|
||||
}
|
||||
if (secPerTick <= 2419200 || range <= oneYear) {
|
||||
return "%m/%d";
|
||||
}
|
||||
return "%Y-%m";
|
||||
}
|
||||
|
||||
return "%H:%M";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
|
||||
|
||||
// A holder of values
|
||||
class Card {
|
||||
// uniq
|
||||
id: number = 0;
|
||||
// Array of values in this bucket
|
||||
values: any[] = [];
|
||||
// card has multiple values
|
||||
multipleValues: boolean = false;
|
||||
// card has values that has no color
|
||||
noColorDefined: boolean = false;
|
||||
//
|
||||
y: string = "";
|
||||
//
|
||||
x: number = 0;
|
||||
//
|
||||
minValue: number = 0;
|
||||
maxValue: number = 0;
|
||||
value: number = 0;
|
||||
|
||||
constructor() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
class CardsStorage {
|
||||
cards: Card[] = [];
|
||||
xBucketSize: number = 0;
|
||||
yBucketSize: number = 0;
|
||||
maxValue: number;
|
||||
minValue: number;
|
||||
multipleValues: boolean = false;
|
||||
noColorDefined: boolean = false;
|
||||
targets: string[] = [];
|
||||
targetIndex: any;
|
||||
|
||||
constructor() {
|
||||
this.maxValue = 0;
|
||||
this.minValue = 0;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export {CardsStorage, Card};
|
||||
@@ -1,12 +1,21 @@
|
||||
import d3 from 'd3';
|
||||
import $ from 'jquery';
|
||||
import _ from 'lodash';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import {StatusHeatmapCtrl} from "./status_heatmap_ctrl";
|
||||
|
||||
let TOOLTIP_PADDING_X = 30;
|
||||
let TOOLTIP_PADDING_Y = 5;
|
||||
|
||||
export class StatusHeatmapTooltip {
|
||||
export class StatusmapTooltip {
|
||||
tooltip: any;
|
||||
scope: any;
|
||||
dashboard: any;
|
||||
panelCtrl: StatusHeatmapCtrl;
|
||||
panel: any;
|
||||
heatmapPanel: any;
|
||||
mouseOverBucket: any;
|
||||
originalFillColor: any;
|
||||
|
||||
constructor(elem, scope) {
|
||||
this.scope = scope;
|
||||
this.dashboard = scope.ctrl.dashboard;
|
||||
Reference in New Issue
Block a user