heatmap_tooltip.ts 8.2 KB

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