1import * as d3 from 'd3';
2import $ from 'jquery';
3import { filter, find, isNumber, map, reduce } from 'lodash';
4import { getValueBucketBound } from './heatmap_data_converter';
5import { getValueFormat, formattedValueToString } from '@grafana/data';
6
7const TOOLTIP_PADDING_X = 30;
8const TOOLTIP_PADDING_Y = 5;
9const HISTOGRAM_WIDTH = 160;
10const HISTOGRAM_HEIGHT = 40;
11
12export class HeatmapTooltip {
13  tooltip: any;
14  scope: any;
15  dashboard: any;
16  panelCtrl: any;
17  panel: any;
18  heatmapPanel: any;
19  mouseOverBucket: boolean;
20  originalFillColor: any;
21
22  constructor(elem: JQuery, scope: any) {
23    this.scope = scope;
24    this.dashboard = scope.ctrl.dashboard;
25    this.panelCtrl = scope.ctrl;
26    this.panel = scope.ctrl.panel;
27    this.heatmapPanel = elem;
28    this.mouseOverBucket = false;
29    this.originalFillColor = null;
30
31    elem.on('mouseleave', this.onMouseLeave.bind(this));
32  }
33
34  onMouseLeave() {
35    this.destroy();
36  }
37
38  onMouseMove(e: any) {
39    if (!this.panel.tooltip.show) {
40      return;
41    }
42
43    this.move(e);
44  }
45
46  add() {
47    this.tooltip = d3.select('body').append('div').attr('class', 'heatmap-tooltip graph-tooltip grafana-tooltip');
48  }
49
50  destroy() {
51    if (this.tooltip) {
52      this.tooltip.remove();
53    }
54
55    this.tooltip = null;
56  }
57
58  show(pos: { panelRelY: any }, data: any) {
59    if (!this.panel.tooltip.show || !data) {
60      return;
61    }
62    // shared tooltip mode
63    if (pos.panelRelY) {
64      return;
65    }
66
67    const { xBucketIndex, yBucketIndex } = this.getBucketIndexes(pos, data);
68
69    if (!data.buckets[xBucketIndex]) {
70      this.destroy();
71      return;
72    }
73
74    if (!this.tooltip) {
75      this.add();
76    }
77
78    let boundBottom, boundTop, valuesNumber;
79    const xData = data.buckets[xBucketIndex];
80    // Search in special 'zero' bucket also
81    const yData: any = find(xData.buckets, (bucket, bucketIndex) => {
82      return bucket.bounds.bottom === yBucketIndex || bucketIndex === yBucketIndex.toString();
83    });
84
85    const tooltipTimeFormat = 'YYYY-MM-DD HH:mm:ss';
86    const time = this.dashboard.formatDate(xData.x, tooltipTimeFormat);
87
88    // Decimals override. Code from panel/graph/graph.ts
89    let countValueFormatter, bucketBoundFormatter;
90    if (isNumber(this.panel.tooltipDecimals)) {
91      countValueFormatter = this.countValueFormatter(this.panel.tooltipDecimals, null);
92      bucketBoundFormatter = this.panelCtrl.tickValueFormatter(this.panelCtrl.decimals, null);
93    } else {
94      // auto decimals
95      // legend and tooltip gets one more decimal precision
96      // than graph legend ticks
97      const decimals = (this.panelCtrl.decimals || -1) + 1;
98      countValueFormatter = this.countValueFormatter(decimals, this.panelCtrl.scaledDecimals + 2);
99      bucketBoundFormatter = this.panelCtrl.tickValueFormatter(decimals, this.panelCtrl.scaledDecimals + 2);
100    }
101
102    let tooltipHtml = `<div class="graph-tooltip-time">${time}</div>
103      <div class="heatmap-histogram"></div>`;
104
105    if (yData) {
106      if (yData.bounds) {
107        if (data.tsBuckets) {
108          // Use Y-axis labels
109          const tickFormatter = (valIndex: string | number) => {
110            return data.tsBucketsFormatted ? data.tsBucketsFormatted[valIndex] : data.tsBuckets[valIndex];
111          };
112
113          boundBottom = tickFormatter(yBucketIndex);
114          if (this.panel.yBucketBound !== 'middle') {
115            boundTop = yBucketIndex < data.tsBuckets.length - 1 ? tickFormatter(yBucketIndex + 1) : '';
116          }
117        } else {
118          // Display 0 if bucket is a special 'zero' bucket
119          const bottom = yData.y ? yData.bounds.bottom : 0;
120          boundBottom = bucketBoundFormatter(bottom);
121          boundTop = bucketBoundFormatter(yData.bounds.top);
122        }
123        valuesNumber = countValueFormatter(yData.count);
124        const boundStr = boundTop && boundBottom ? `${boundBottom} - ${boundTop}` : boundBottom || boundTop;
125        tooltipHtml += `<div>
126          bucket: <b>${boundStr}</b> <br>
127          count: <b>${valuesNumber}</b> <br>
128        </div>`;
129      } else {
130        // currently no bounds for pre bucketed data
131        tooltipHtml += `<div>count: <b>${yData.count}</b><br></div>`;
132      }
133    } else {
134      if (!this.panel.tooltip.showHistogram) {
135        this.destroy();
136        return;
137      }
138      boundBottom = yBucketIndex;
139      boundTop = '';
140      valuesNumber = 0;
141    }
142
143    this.tooltip.html(tooltipHtml);
144
145    if (this.panel.tooltip.showHistogram) {
146      this.addHistogram(xData);
147    }
148
149    this.move(pos);
150  }
151
152  getBucketIndexes(pos: { panelRelY?: any; x?: any; y?: any }, data: any) {
153    const xBucketIndex = this.getXBucketIndex(pos.x, data);
154    const yBucketIndex = this.getYBucketIndex(pos.y, data);
155    return { xBucketIndex, yBucketIndex };
156  }
157
158  getXBucketIndex(x: number, data: { buckets: any; xBucketSize: number }) {
159    // First try to find X bucket by checking x pos is in the
160    // [bucket.x, bucket.x + xBucketSize] interval
161    const xBucket: any = find(data.buckets, (bucket) => {
162      return x > bucket.x && x - bucket.x <= data.xBucketSize;
163    });
164    return xBucket ? xBucket.x : getValueBucketBound(x, data.xBucketSize, 1);
165  }
166
167  getYBucketIndex(y: number, data: { tsBuckets: any; yBucketSize: number }) {
168    if (data.tsBuckets) {
169      return Math.floor(y);
170    }
171    const yBucketIndex = getValueBucketBound(y, data.yBucketSize, this.panel.yAxis.logBase);
172    return yBucketIndex;
173  }
174
175  getSharedTooltipPos(pos: { pageX: any; x: any; pageY: any; panelRelY: number }) {
176    // get pageX from position on x axis and pageY from relative position in original panel
177    pos.pageX = this.heatmapPanel.offset().left + this.scope.xScale(pos.x);
178    pos.pageY = this.heatmapPanel.offset().top + this.scope.chartHeight * pos.panelRelY;
179    return pos;
180  }
181
182  addHistogram(data: { x: string | number }) {
183    const xBucket = this.scope.ctrl.data.buckets[data.x];
184    const yBucketSize = this.scope.ctrl.data.yBucketSize;
185    let min: number, max: number, ticks: number;
186    if (this.scope.ctrl.data.tsBuckets) {
187      min = 0;
188      max = this.scope.ctrl.data.tsBuckets.length - 1;
189      ticks = this.scope.ctrl.data.tsBuckets.length;
190    } else {
191      min = this.scope.ctrl.data.yAxis.min;
192      max = this.scope.ctrl.data.yAxis.max;
193      ticks = this.scope.ctrl.data.yAxis.ticks;
194    }
195    let histogramData = map(xBucket.buckets, (bucket) => {
196      const count = bucket.count !== undefined ? bucket.count : bucket.values.length;
197      return [bucket.bounds.bottom, count];
198    });
199    histogramData = filter(histogramData, (d) => {
200      return d[0] >= min && d[0] <= max;
201    });
202
203    const scale = this.scope.yScale.copy();
204    const histXScale = scale.domain([min, max]).range([0, HISTOGRAM_WIDTH]);
205
206    let barWidth: number;
207    if (this.panel.yAxis.logBase === 1) {
208      barWidth = Math.floor((HISTOGRAM_WIDTH / (max - min)) * yBucketSize * 0.9);
209    } else {
210      const barNumberFactor = yBucketSize ? yBucketSize : 1;
211      barWidth = Math.floor((HISTOGRAM_WIDTH / ticks / barNumberFactor) * 0.9);
212    }
213    barWidth = Math.max(barWidth, 1);
214
215    // Normalize histogram Y axis
216    const histogramDomain = reduce(
217      map(histogramData, (d) => d[1]),
218      (sum, val) => sum + val,
219      0
220    );
221    const histYScale = d3.scaleLinear().domain([0, histogramDomain]).range([0, HISTOGRAM_HEIGHT]);
222
223    const histogram = this.tooltip
224      .select('.heatmap-histogram')
225      .append('svg')
226      .attr('width', HISTOGRAM_WIDTH)
227      .attr('height', HISTOGRAM_HEIGHT);
228
229    histogram
230      .selectAll('.bar')
231      .data(histogramData)
232      .enter()
233      .append('rect')
234      .attr('x', (d: any[]) => {
235        return histXScale(d[0]);
236      })
237      .attr('width', barWidth)
238      .attr('y', (d: any[]) => {
239        return HISTOGRAM_HEIGHT - histYScale(d[1]);
240      })
241      .attr('height', (d: any[]) => {
242        return histYScale(d[1]);
243      });
244  }
245
246  move(pos: { panelRelY?: any; pageX?: any; pageY?: any }) {
247    if (!this.tooltip) {
248      return;
249    }
250
251    const elem = $(this.tooltip.node())[0];
252    const tooltipWidth = elem.clientWidth;
253    const tooltipHeight = elem.clientHeight;
254
255    let left = pos.pageX + TOOLTIP_PADDING_X;
256    let top = pos.pageY + TOOLTIP_PADDING_Y;
257
258    if (pos.pageX + tooltipWidth + 40 > window.innerWidth) {
259      left = pos.pageX - tooltipWidth - TOOLTIP_PADDING_X;
260    }
261
262    if (pos.pageY - window.pageYOffset + tooltipHeight + 20 > window.innerHeight) {
263      top = pos.pageY - tooltipHeight - TOOLTIP_PADDING_Y;
264    }
265
266    return this.tooltip.style('left', left + 'px').style('top', top + 'px');
267  }
268
269  countValueFormatter(decimals: number, scaledDecimals: any = null) {
270    const fmt = getValueFormat('short');
271    return (value: number) => {
272      return formattedValueToString(fmt(value, decimals, scaledDecimals));
273    };
274  }
275}
276