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