1import { find, isEmpty, isNil, sortBy, uniq } from 'lodash';
2import $ from 'jquery';
3import * as d3 from 'd3';
4import { contextSrv } from 'app/core/core';
5import { tickStep } from 'app/core/utils/ticks';
6import { getColorScale, getOpacityScale } from './color_scale';
7import coreModule from 'app/angular/core_module';
8import { PanelEvents, getColorForTheme } from '@grafana/data';
9import { config } from 'app/core/config';
10
11const LEGEND_HEIGHT_PX = 6;
12const LEGEND_WIDTH_PX = 100;
13const LEGEND_TICK_SIZE = 0;
14const LEGEND_VALUE_MARGIN = 0;
15const LEGEND_PADDING_LEFT = 10;
16const LEGEND_SEGMENT_WIDTH = 10;
17
18/**
19 * Color legend for heatmap editor.
20 */
21coreModule.directive('colorLegend', () => {
22  return {
23    restrict: 'E',
24    template: '<div class="heatmap-color-legend"><svg width="16.5rem" height="24px"></svg></div>',
25    link: (scope: any, elem, attrs) => {
26      const ctrl = scope.ctrl;
27      const panel = scope.ctrl.panel;
28
29      render();
30
31      ctrl.events.on(PanelEvents.render, () => {
32        render();
33      });
34
35      function render() {
36        const legendElem = $(elem).find('svg');
37        const legendWidth = Math.floor(legendElem.outerWidth() ?? 10);
38
39        if (panel.color.mode === 'spectrum') {
40          const colorScheme: any = find(ctrl.colorSchemes, {
41            value: panel.color.colorScheme,
42          });
43          const colorScale = getColorScale(colorScheme, contextSrv.user.lightTheme, legendWidth);
44          drawSimpleColorLegend(elem, colorScale);
45        } else if (panel.color.mode === 'opacity') {
46          const colorOptions = panel.color;
47          drawSimpleOpacityLegend(elem, colorOptions);
48        }
49      }
50    },
51  };
52});
53
54/**
55 * Heatmap legend with scale values.
56 */
57coreModule.directive('heatmapLegend', () => {
58  return {
59    restrict: 'E',
60    template: `<div class="heatmap-color-legend"><svg width="${LEGEND_WIDTH_PX}px" height="${LEGEND_HEIGHT_PX}px"></svg></div>`,
61    link: (scope: any, elem, attrs) => {
62      const ctrl = scope.ctrl;
63      const panel = scope.ctrl.panel;
64
65      render();
66      ctrl.events.on(PanelEvents.render, () => {
67        render();
68      });
69
70      function render() {
71        clearLegend(elem);
72        if (!isEmpty(ctrl.data) && !isEmpty(ctrl.data.cards)) {
73          const cardStats = ctrl.data.cardStats;
74          const rangeFrom = isNil(panel.color.min) ? Math.max(cardStats.min, 0) : panel.color.min;
75          const rangeTo = isNil(panel.color.max) ? cardStats.max : panel.color.max;
76          const maxValue = cardStats.max;
77          const minValue = cardStats.min;
78
79          if (panel.color.mode === 'spectrum') {
80            const colorScheme: any = find(ctrl.colorSchemes, {
81              value: panel.color.colorScheme,
82            });
83            drawColorLegend(elem, colorScheme, rangeFrom, rangeTo, maxValue, minValue);
84          } else if (panel.color.mode === 'opacity') {
85            const colorOptions = panel.color;
86            drawOpacityLegend(elem, colorOptions, rangeFrom, rangeTo, maxValue, minValue);
87          }
88        }
89      }
90    },
91  };
92});
93
94function drawColorLegend(
95  elem: JQuery,
96  colorScheme: any,
97  rangeFrom: number,
98  rangeTo: number,
99  maxValue: number,
100  minValue: number
101) {
102  const legendElem = $(elem).find('svg');
103  const legend = d3.select(legendElem.get(0));
104  clearLegend(elem);
105
106  const legendWidth = Math.floor(legendElem.outerWidth() ?? 10) - 30;
107  const legendHeight = legendElem.attr('height') as any;
108
109  const rangeStep = ((rangeTo - rangeFrom) / legendWidth) * LEGEND_SEGMENT_WIDTH;
110  const widthFactor = legendWidth / (rangeTo - rangeFrom);
111  const valuesRange = d3.range(rangeFrom, rangeTo, rangeStep);
112
113  const colorScale = getColorScale(colorScheme, contextSrv.user.lightTheme, rangeTo, rangeFrom);
114  legend
115    .append('g')
116    .attr('class', 'legend-color-bar')
117    .attr('transform', 'translate(' + LEGEND_PADDING_LEFT + ',0)')
118    .selectAll('.heatmap-color-legend-rect')
119    .data(valuesRange)
120    .enter()
121    .append('rect')
122    .attr('x', (d) => Math.round((d - rangeFrom) * widthFactor))
123    .attr('y', 0)
124    .attr('width', Math.round(rangeStep * widthFactor + 1)) // Overlap rectangles to prevent gaps
125    .attr('height', legendHeight)
126    .attr('stroke-width', 0)
127    .attr('fill', (d) => colorScale(d));
128
129  drawLegendValues(elem, rangeFrom, rangeTo, maxValue, minValue, legendWidth, valuesRange);
130}
131
132function drawOpacityLegend(
133  elem: JQuery,
134  options: { cardColor: null },
135  rangeFrom: number,
136  rangeTo: number,
137  maxValue: any,
138  minValue: number
139) {
140  const legendElem = $(elem).find('svg');
141  const legend = d3.select(legendElem.get(0));
142  clearLegend(elem);
143
144  const legendWidth = Math.floor(legendElem.outerWidth() ?? 30) - 30;
145  const legendHeight = legendElem.attr('height') as any;
146
147  const rangeStep = ((rangeTo - rangeFrom) / legendWidth) * LEGEND_SEGMENT_WIDTH;
148  const widthFactor = legendWidth / (rangeTo - rangeFrom);
149  const valuesRange = d3.range(rangeFrom, rangeTo, rangeStep);
150
151  const opacityScale = getOpacityScale(options, rangeTo, rangeFrom);
152  legend
153    .append('g')
154    .attr('class', 'legend-color-bar')
155    .attr('transform', 'translate(' + LEGEND_PADDING_LEFT + ',0)')
156    .selectAll('.heatmap-opacity-legend-rect')
157    .data(valuesRange)
158    .enter()
159    .append('rect')
160    .attr('x', (d) => Math.round((d - rangeFrom) * widthFactor))
161    .attr('y', 0)
162    .attr('width', Math.round(rangeStep * widthFactor))
163    .attr('height', legendHeight)
164    .attr('stroke-width', 0)
165    .attr('fill', options.cardColor)
166    .style('opacity', (d) => opacityScale(d));
167
168  drawLegendValues(elem, rangeFrom, rangeTo, maxValue, minValue, legendWidth, valuesRange);
169}
170
171function drawLegendValues(
172  elem: JQuery,
173  rangeFrom: number,
174  rangeTo: number,
175  maxValue: any,
176  minValue: any,
177  legendWidth: number,
178  valuesRange: number[]
179) {
180  const legendElem = $(elem).find('svg');
181  const legend = d3.select(legendElem.get(0));
182
183  if (legendWidth <= 0 || legendElem.get(0).childNodes.length === 0) {
184    return;
185  }
186
187  const legendValueScale = d3.scaleLinear().domain([rangeFrom, rangeTo]).range([0, legendWidth]);
188
189  const ticks = buildLegendTicks(rangeFrom, rangeTo, maxValue, minValue);
190  const xAxis = d3.axisBottom(legendValueScale).tickValues(ticks).tickSize(LEGEND_TICK_SIZE);
191
192  const colorRect = legendElem.find(':first-child');
193  const posY = getSvgElemHeight(legendElem) + LEGEND_VALUE_MARGIN;
194  const posX = getSvgElemX(colorRect) + LEGEND_PADDING_LEFT;
195
196  d3.select(legendElem.get(0))
197    .append('g')
198    .attr('class', 'axis')
199    .attr('transform', 'translate(' + posX + ',' + posY + ')')
200    .call(xAxis);
201
202  legend.select('.axis').select('.domain').remove();
203}
204
205function drawSimpleColorLegend(elem: JQuery, colorScale: any) {
206  const legendElem = $(elem).find('svg');
207  clearLegend(elem);
208
209  const legendWidth = Math.floor(legendElem.outerWidth() ?? 30);
210  const legendHeight = legendElem.attr('height') as any;
211
212  if (legendWidth) {
213    const valuesNumber = Math.floor(legendWidth / 2);
214    const rangeStep = Math.floor(legendWidth / valuesNumber);
215    const valuesRange = d3.range(0, legendWidth, rangeStep);
216
217    const legend = d3.select(legendElem.get(0));
218    const legendRects = legend.selectAll('.heatmap-color-legend-rect').data(valuesRange);
219
220    legendRects
221      .enter()
222      .append('rect')
223      .attr('x', (d) => d)
224      .attr('y', 0)
225      .attr('width', rangeStep + 1) // Overlap rectangles to prevent gaps
226      .attr('height', legendHeight)
227      .attr('stroke-width', 0)
228      .attr('fill', (d) => colorScale(d));
229  }
230}
231
232function drawSimpleOpacityLegend(elem: JQuery, options: { colorScale: string; exponent: number; cardColor: string }) {
233  const legendElem = $(elem).find('svg');
234  clearLegend(elem);
235
236  const legend = d3.select(legendElem.get(0));
237  const legendWidth = Math.floor(legendElem.outerWidth() ?? 30);
238  const legendHeight = legendElem.attr('height') as any;
239
240  if (legendWidth) {
241    let legendOpacityScale: any;
242    if (options.colorScale === 'linear') {
243      legendOpacityScale = d3.scaleLinear().domain([0, legendWidth]).range([0, 1]);
244    } else if (options.colorScale === 'sqrt') {
245      legendOpacityScale = d3.scalePow().exponent(options.exponent).domain([0, legendWidth]).range([0, 1]);
246    }
247
248    const rangeStep = 10;
249    const valuesRange = d3.range(0, legendWidth, rangeStep);
250    const legendRects = legend.selectAll('.heatmap-opacity-legend-rect').data(valuesRange);
251
252    legendRects
253      .enter()
254      .append('rect')
255      .attr('x', (d) => d)
256      .attr('y', 0)
257      .attr('width', rangeStep)
258      .attr('height', legendHeight)
259      .attr('stroke-width', 0)
260      .attr('fill', getColorForTheme(options.cardColor, config.theme))
261      .style('opacity', (d) => legendOpacityScale(d));
262  }
263}
264
265function clearLegend(elem: JQuery) {
266  const legendElem = $(elem).find('svg');
267  legendElem.empty();
268}
269
270function getSvgElemX(elem: JQuery) {
271  const svgElem: any = elem.get(0) as any;
272  if (svgElem && svgElem.x && svgElem.x.baseVal) {
273    return svgElem.x.baseVal.value;
274  } else {
275    return 0;
276  }
277}
278
279function getSvgElemHeight(elem: JQuery<any>) {
280  const svgElem: any = elem.get(0);
281  if (svgElem && svgElem.height && svgElem.height.baseVal) {
282    return svgElem.height.baseVal.value;
283  } else {
284    return 0;
285  }
286}
287
288function buildLegendTicks(rangeFrom: number, rangeTo: number, maxValue: number, minValue: number) {
289  const range = rangeTo - rangeFrom;
290  const tickStepSize = tickStep(rangeFrom, rangeTo, 3);
291  const ticksNum = Math.ceil(range / tickStepSize);
292  const firstTick = getFirstCloseTick(rangeFrom, tickStepSize);
293  let ticks = [];
294
295  for (let i = 0; i < ticksNum; i++) {
296    const current = firstTick + tickStepSize * i;
297    // Add user-defined min and max if it had been set
298    if (isValueCloseTo(minValue, current, tickStepSize)) {
299      ticks.push(minValue);
300      continue;
301    } else if (minValue < current) {
302      ticks.push(minValue);
303    }
304    if (isValueCloseTo(maxValue, current, tickStepSize)) {
305      ticks.push(maxValue);
306      continue;
307    } else if (maxValue < current) {
308      ticks.push(maxValue);
309    }
310    ticks.push(current);
311  }
312  if (!isValueCloseTo(maxValue, rangeTo, tickStepSize)) {
313    ticks.push(maxValue);
314  }
315  ticks.push(rangeTo);
316  ticks = sortBy(uniq(ticks));
317  return ticks;
318}
319
320function isValueCloseTo(val: number, valueTo: number, step: number) {
321  const diff = Math.abs(val - valueTo);
322  return diff < step * 0.3;
323}
324
325function getFirstCloseTick(minValue: number, step: number) {
326  if (minValue < 0) {
327    return Math.floor(minValue / step) * step;
328  }
329  return 0;
330}
331