rendering.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  1. import _ from 'lodash';
  2. import $ from 'jquery';
  3. import * as d3 from 'd3';
  4. import { appEvents, contextSrv } from 'app/core/core';
  5. import * as ticksUtils from 'app/core/utils/ticks';
  6. import { HeatmapTooltip } from './heatmap_tooltip';
  7. import { mergeZeroBuckets } from './heatmap_data_converter';
  8. import { getColorScale, getOpacityScale } from './color_scale';
  9. import { GrafanaThemeType, getColorFromHexRgbOrName, getValueFormat } from '@grafana/ui';
  10. import { toUtc } from '@grafana/ui/src/utils/moment_wrapper';
  11. const MIN_CARD_SIZE = 1,
  12. CARD_PADDING = 1,
  13. CARD_ROUND = 0,
  14. DATA_RANGE_WIDING_FACTOR = 1.2,
  15. DEFAULT_X_TICK_SIZE_PX = 100,
  16. DEFAULT_Y_TICK_SIZE_PX = 50,
  17. X_AXIS_TICK_PADDING = 10,
  18. Y_AXIS_TICK_PADDING = 5,
  19. MIN_SELECTION_WIDTH = 2;
  20. export default function rendering(scope, elem, attrs, ctrl) {
  21. return new HeatmapRenderer(scope, elem, attrs, ctrl);
  22. }
  23. export class HeatmapRenderer {
  24. width: number;
  25. height: number;
  26. yScale: any;
  27. xScale: any;
  28. chartWidth: number;
  29. chartHeight: number;
  30. chartTop: number;
  31. chartBottom: number;
  32. yAxisWidth: number;
  33. xAxisHeight: number;
  34. cardPadding: number;
  35. cardRound: number;
  36. cardWidth: number;
  37. cardHeight: number;
  38. colorScale: any;
  39. opacityScale: any;
  40. mouseUpHandler: any;
  41. data: any;
  42. panel: any;
  43. $heatmap: any;
  44. tooltip: HeatmapTooltip;
  45. heatmap: any;
  46. timeRange: any;
  47. selection: any;
  48. padding: any;
  49. margin: any;
  50. dataRangeWidingFactor: number;
  51. constructor(private scope, private elem, attrs, private ctrl) {
  52. // $heatmap is JQuery object, but heatmap is D3
  53. this.$heatmap = this.elem.find('.heatmap-panel');
  54. this.tooltip = new HeatmapTooltip(this.$heatmap, this.scope);
  55. this.selection = {
  56. active: false,
  57. x1: -1,
  58. x2: -1,
  59. };
  60. this.padding = { left: 0, right: 0, top: 0, bottom: 0 };
  61. this.margin = { left: 25, right: 15, top: 10, bottom: 20 };
  62. this.dataRangeWidingFactor = DATA_RANGE_WIDING_FACTOR;
  63. this.ctrl.events.on('render', this.onRender.bind(this));
  64. this.ctrl.tickValueFormatter = this.tickValueFormatter.bind(this);
  65. /////////////////////////////
  66. // Selection and crosshair //
  67. /////////////////////////////
  68. // Shared crosshair and tooltip
  69. appEvents.on('graph-hover', this.onGraphHover.bind(this), this.scope);
  70. appEvents.on('graph-hover-clear', this.onGraphHoverClear.bind(this), this.scope);
  71. // Register selection listeners
  72. this.$heatmap.on('mousedown', this.onMouseDown.bind(this));
  73. this.$heatmap.on('mousemove', this.onMouseMove.bind(this));
  74. this.$heatmap.on('mouseleave', this.onMouseLeave.bind(this));
  75. }
  76. onGraphHoverClear() {
  77. this.clearCrosshair();
  78. }
  79. onGraphHover(event) {
  80. this.drawSharedCrosshair(event.pos);
  81. }
  82. onRender() {
  83. this.render();
  84. this.ctrl.renderingCompleted();
  85. }
  86. setElementHeight() {
  87. try {
  88. let height = this.ctrl.height || this.panel.height || this.ctrl.row.height;
  89. if (_.isString(height)) {
  90. height = parseInt(height.replace('px', ''), 10);
  91. }
  92. height -= this.panel.legend.show ? 28 : 11; // bottom padding and space for legend
  93. this.$heatmap.css('height', height + 'px');
  94. return true;
  95. } catch (e) {
  96. // IE throws errors sometimes
  97. return false;
  98. }
  99. }
  100. getYAxisWidth(elem) {
  101. const axisText = elem.selectAll('.axis-y text').nodes();
  102. const maxTextWidth = _.max(
  103. _.map(axisText, text => {
  104. // Use SVG getBBox method
  105. return text.getBBox().width;
  106. })
  107. );
  108. return maxTextWidth;
  109. }
  110. getXAxisHeight(elem) {
  111. const axisLine = elem.select('.axis-x line');
  112. if (!axisLine.empty()) {
  113. const axisLinePosition = parseFloat(elem.select('.axis-x line').attr('y2'));
  114. const canvasWidth = parseFloat(elem.attr('height'));
  115. return canvasWidth - axisLinePosition;
  116. } else {
  117. // Default height
  118. return 30;
  119. }
  120. }
  121. addXAxis() {
  122. this.scope.xScale = this.xScale = d3
  123. .scaleTime()
  124. .domain([this.timeRange.from, this.timeRange.to])
  125. .range([0, this.chartWidth]);
  126. const ticks = this.chartWidth / DEFAULT_X_TICK_SIZE_PX;
  127. const grafanaTimeFormatter = ticksUtils.grafanaTimeFormat(ticks, this.timeRange.from, this.timeRange.to);
  128. let timeFormat;
  129. const dashboardTimeZone = this.ctrl.dashboard.getTimezone();
  130. if (dashboardTimeZone === 'utc') {
  131. timeFormat = d3.utcFormat(grafanaTimeFormatter);
  132. } else {
  133. timeFormat = d3.timeFormat(grafanaTimeFormatter);
  134. }
  135. const xAxis = d3
  136. .axisBottom(this.xScale)
  137. .ticks(ticks)
  138. .tickFormat(timeFormat)
  139. .tickPadding(X_AXIS_TICK_PADDING)
  140. .tickSize(this.chartHeight);
  141. const posY = this.margin.top;
  142. const posX = this.yAxisWidth;
  143. this.heatmap
  144. .append('g')
  145. .attr('class', 'axis axis-x')
  146. .attr('transform', 'translate(' + posX + ',' + posY + ')')
  147. .call(xAxis);
  148. // Remove horizontal line in the top of axis labels (called domain in d3)
  149. this.heatmap
  150. .select('.axis-x')
  151. .select('.domain')
  152. .remove();
  153. }
  154. addYAxis() {
  155. let ticks = Math.ceil(this.chartHeight / DEFAULT_Y_TICK_SIZE_PX);
  156. let tickInterval = ticksUtils.tickStep(this.data.heatmapStats.min, this.data.heatmapStats.max, ticks);
  157. let { yMin, yMax } = this.wideYAxisRange(this.data.heatmapStats.min, this.data.heatmapStats.max, tickInterval);
  158. // Rewrite min and max if it have been set explicitly
  159. yMin = this.panel.yAxis.min !== null ? this.panel.yAxis.min : yMin;
  160. yMax = this.panel.yAxis.max !== null ? this.panel.yAxis.max : yMax;
  161. // Adjust ticks after Y range widening
  162. tickInterval = ticksUtils.tickStep(yMin, yMax, ticks);
  163. ticks = Math.ceil((yMax - yMin) / tickInterval);
  164. const decimalsAuto = ticksUtils.getPrecision(tickInterval);
  165. let decimals = this.panel.yAxis.decimals === null ? decimalsAuto : this.panel.yAxis.decimals;
  166. // Calculate scaledDecimals for log scales using tick size (as in jquery.flot.js)
  167. const flotTickSize = ticksUtils.getFlotTickSize(yMin, yMax, ticks, decimalsAuto);
  168. const scaledDecimals = ticksUtils.getScaledDecimals(decimals, flotTickSize);
  169. this.ctrl.decimals = decimals;
  170. this.ctrl.scaledDecimals = scaledDecimals;
  171. // Set default Y min and max if no data
  172. if (_.isEmpty(this.data.buckets)) {
  173. yMax = 1;
  174. yMin = -1;
  175. ticks = 3;
  176. decimals = 1;
  177. }
  178. this.data.yAxis = {
  179. min: yMin,
  180. max: yMax,
  181. ticks: ticks,
  182. };
  183. this.scope.yScale = this.yScale = d3
  184. .scaleLinear()
  185. .domain([yMin, yMax])
  186. .range([this.chartHeight, 0]);
  187. const yAxis = d3
  188. .axisLeft(this.yScale)
  189. .ticks(ticks)
  190. .tickFormat(this.tickValueFormatter(decimals, scaledDecimals))
  191. .tickSizeInner(0 - this.width)
  192. .tickSizeOuter(0)
  193. .tickPadding(Y_AXIS_TICK_PADDING);
  194. this.heatmap
  195. .append('g')
  196. .attr('class', 'axis axis-y')
  197. .call(yAxis);
  198. // Calculate Y axis width first, then move axis into visible area
  199. const posY = this.margin.top;
  200. const posX = this.getYAxisWidth(this.heatmap) + Y_AXIS_TICK_PADDING;
  201. this.heatmap.select('.axis-y').attr('transform', 'translate(' + posX + ',' + posY + ')');
  202. // Remove vertical line in the right of axis labels (called domain in d3)
  203. this.heatmap
  204. .select('.axis-y')
  205. .select('.domain')
  206. .remove();
  207. }
  208. // Wide Y values range and anjust to bucket size
  209. wideYAxisRange(min, max, tickInterval) {
  210. const yWiding = (max * (this.dataRangeWidingFactor - 1) - min * (this.dataRangeWidingFactor - 1)) / 2;
  211. let yMin, yMax;
  212. if (tickInterval === 0) {
  213. yMax = max * this.dataRangeWidingFactor;
  214. yMin = min - min * (this.dataRangeWidingFactor - 1);
  215. } else {
  216. yMax = Math.ceil((max + yWiding) / tickInterval) * tickInterval;
  217. yMin = Math.floor((min - yWiding) / tickInterval) * tickInterval;
  218. }
  219. // Don't wide axis below 0 if all values are positive
  220. if (min >= 0 && yMin < 0) {
  221. yMin = 0;
  222. }
  223. return { yMin, yMax };
  224. }
  225. addLogYAxis() {
  226. const logBase = this.panel.yAxis.logBase;
  227. let { yMin, yMax } = this.adjustLogRange(this.data.heatmapStats.minLog, this.data.heatmapStats.max, logBase);
  228. yMin =
  229. this.panel.yAxis.min && this.panel.yAxis.min !== '0' ? this.adjustLogMin(this.panel.yAxis.min, logBase) : yMin;
  230. yMax = this.panel.yAxis.max !== null ? this.adjustLogMax(this.panel.yAxis.max, logBase) : yMax;
  231. // Set default Y min and max if no data
  232. if (_.isEmpty(this.data.buckets)) {
  233. yMax = Math.pow(logBase, 2);
  234. yMin = 1;
  235. }
  236. this.scope.yScale = this.yScale = d3
  237. .scaleLog()
  238. .base(this.panel.yAxis.logBase)
  239. .domain([yMin, yMax])
  240. .range([this.chartHeight, 0]);
  241. const domain = this.yScale.domain();
  242. const tickValues = this.logScaleTickValues(domain, logBase);
  243. const decimalsAuto = ticksUtils.getPrecision(yMin);
  244. const decimals = this.panel.yAxis.decimals || decimalsAuto;
  245. // Calculate scaledDecimals for log scales using tick size (as in jquery.flot.js)
  246. const flotTickSize = ticksUtils.getFlotTickSize(yMin, yMax, tickValues.length, decimalsAuto);
  247. const scaledDecimals = ticksUtils.getScaledDecimals(decimals, flotTickSize);
  248. this.ctrl.decimals = decimals;
  249. this.ctrl.scaledDecimals = scaledDecimals;
  250. this.data.yAxis = {
  251. min: yMin,
  252. max: yMax,
  253. ticks: tickValues.length,
  254. };
  255. const yAxis = d3
  256. .axisLeft(this.yScale)
  257. .tickValues(tickValues)
  258. .tickFormat(this.tickValueFormatter(decimals, scaledDecimals))
  259. .tickSizeInner(0 - this.width)
  260. .tickSizeOuter(0)
  261. .tickPadding(Y_AXIS_TICK_PADDING);
  262. this.heatmap
  263. .append('g')
  264. .attr('class', 'axis axis-y')
  265. .call(yAxis);
  266. // Calculate Y axis width first, then move axis into visible area
  267. const posY = this.margin.top;
  268. const posX = this.getYAxisWidth(this.heatmap) + Y_AXIS_TICK_PADDING;
  269. this.heatmap.select('.axis-y').attr('transform', 'translate(' + posX + ',' + posY + ')');
  270. // Set first tick as pseudo 0
  271. if (yMin < 1) {
  272. this.heatmap
  273. .select('.axis-y')
  274. .select('.tick text')
  275. .text('0');
  276. }
  277. // Remove vertical line in the right of axis labels (called domain in d3)
  278. this.heatmap
  279. .select('.axis-y')
  280. .select('.domain')
  281. .remove();
  282. }
  283. addYAxisFromBuckets() {
  284. const tsBuckets = this.data.tsBuckets;
  285. this.scope.yScale = this.yScale = d3
  286. .scaleLinear()
  287. .domain([0, tsBuckets.length - 1])
  288. .range([this.chartHeight, 0]);
  289. const tickValues = _.map(tsBuckets, (b, i) => i);
  290. const decimalsAuto = _.max(_.map(tsBuckets, ticksUtils.getStringPrecision));
  291. const decimals = this.panel.yAxis.decimals === null ? decimalsAuto : this.panel.yAxis.decimals;
  292. this.ctrl.decimals = decimals;
  293. const tickValueFormatter = this.tickValueFormatter.bind(this);
  294. function tickFormatter(valIndex) {
  295. let valueFormatted = tsBuckets[valIndex];
  296. if (!_.isNaN(_.toNumber(valueFormatted)) && valueFormatted !== '') {
  297. // Try to format numeric tick labels
  298. valueFormatted = tickValueFormatter(decimals)(_.toNumber(valueFormatted));
  299. }
  300. return valueFormatted;
  301. }
  302. const tsBucketsFormatted = _.map(tsBuckets, (v, i) => tickFormatter(i));
  303. this.data.tsBucketsFormatted = tsBucketsFormatted;
  304. const yAxis = d3
  305. .axisLeft(this.yScale)
  306. .tickValues(tickValues)
  307. .tickFormat(tickFormatter)
  308. .tickSizeInner(0 - this.width)
  309. .tickSizeOuter(0)
  310. .tickPadding(Y_AXIS_TICK_PADDING);
  311. this.heatmap
  312. .append('g')
  313. .attr('class', 'axis axis-y')
  314. .call(yAxis);
  315. // Calculate Y axis width first, then move axis into visible area
  316. const posY = this.margin.top;
  317. const posX = this.getYAxisWidth(this.heatmap) + Y_AXIS_TICK_PADDING;
  318. this.heatmap.select('.axis-y').attr('transform', 'translate(' + posX + ',' + posY + ')');
  319. if (this.panel.yBucketBound === 'middle' && tickValues && tickValues.length) {
  320. // Shift Y axis labels to the middle of bucket
  321. const tickShift = 0 - this.chartHeight / (tickValues.length - 1) / 2;
  322. this.heatmap.selectAll('.axis-y text').attr('transform', 'translate(' + 0 + ',' + tickShift + ')');
  323. }
  324. // Remove vertical line in the right of axis labels (called domain in d3)
  325. this.heatmap
  326. .select('.axis-y')
  327. .select('.domain')
  328. .remove();
  329. }
  330. // Adjust data range to log base
  331. adjustLogRange(min, max, logBase) {
  332. let yMin = this.data.heatmapStats.minLog;
  333. if (this.data.heatmapStats.minLog > 1 || !this.data.heatmapStats.minLog) {
  334. yMin = 1;
  335. } else {
  336. yMin = this.adjustLogMin(this.data.heatmapStats.minLog, logBase);
  337. }
  338. // Adjust max Y value to log base
  339. const yMax = this.adjustLogMax(this.data.heatmapStats.max, logBase);
  340. return { yMin, yMax };
  341. }
  342. adjustLogMax(max, base) {
  343. return Math.pow(base, Math.ceil(ticksUtils.logp(max, base)));
  344. }
  345. adjustLogMin(min, base) {
  346. return Math.pow(base, Math.floor(ticksUtils.logp(min, base)));
  347. }
  348. logScaleTickValues(domain, base) {
  349. const domainMin = domain[0];
  350. const domainMax = domain[1];
  351. const tickValues = [];
  352. if (domainMin < 1) {
  353. const underOneTicks = Math.floor(ticksUtils.logp(domainMin, base));
  354. for (let i = underOneTicks; i < 0; i++) {
  355. const tickValue = Math.pow(base, i);
  356. tickValues.push(tickValue);
  357. }
  358. }
  359. const ticks = Math.ceil(ticksUtils.logp(domainMax, base));
  360. for (let i = 0; i <= ticks; i++) {
  361. const tickValue = Math.pow(base, i);
  362. tickValues.push(tickValue);
  363. }
  364. return tickValues;
  365. }
  366. tickValueFormatter(decimals, scaledDecimals = null) {
  367. const format = this.panel.yAxis.format;
  368. return value => {
  369. try {
  370. return format !== 'none' ? getValueFormat(format)(value, decimals, scaledDecimals) : value;
  371. } catch (err) {
  372. console.error(err.message || err);
  373. return value;
  374. }
  375. };
  376. }
  377. fixYAxisTickSize() {
  378. this.heatmap
  379. .select('.axis-y')
  380. .selectAll('.tick line')
  381. .attr('x2', this.chartWidth);
  382. }
  383. addAxes() {
  384. this.chartHeight = this.height - this.margin.top - this.margin.bottom;
  385. this.chartTop = this.margin.top;
  386. this.chartBottom = this.chartTop + this.chartHeight;
  387. if (this.panel.dataFormat === 'tsbuckets') {
  388. this.addYAxisFromBuckets();
  389. } else {
  390. if (this.panel.yAxis.logBase === 1) {
  391. this.addYAxis();
  392. } else {
  393. this.addLogYAxis();
  394. }
  395. }
  396. this.yAxisWidth = this.getYAxisWidth(this.heatmap) + Y_AXIS_TICK_PADDING;
  397. this.chartWidth = this.width - this.yAxisWidth - this.margin.right;
  398. this.fixYAxisTickSize();
  399. this.addXAxis();
  400. this.xAxisHeight = this.getXAxisHeight(this.heatmap);
  401. if (!this.panel.yAxis.show) {
  402. this.heatmap
  403. .select('.axis-y')
  404. .selectAll('line')
  405. .style('opacity', 0);
  406. }
  407. if (!this.panel.xAxis.show) {
  408. this.heatmap
  409. .select('.axis-x')
  410. .selectAll('line')
  411. .style('opacity', 0);
  412. }
  413. }
  414. addHeatmapCanvas() {
  415. const heatmapElem = this.$heatmap[0];
  416. this.width = Math.floor(this.$heatmap.width()) - this.padding.right;
  417. this.height = Math.floor(this.$heatmap.height()) - this.padding.bottom;
  418. this.cardPadding = this.panel.cards.cardPadding !== null ? this.panel.cards.cardPadding : CARD_PADDING;
  419. this.cardRound = this.panel.cards.cardRound !== null ? this.panel.cards.cardRound : CARD_ROUND;
  420. if (this.heatmap) {
  421. this.heatmap.remove();
  422. }
  423. this.heatmap = d3
  424. .select(heatmapElem)
  425. .append('svg')
  426. .attr('width', this.width)
  427. .attr('height', this.height);
  428. }
  429. addHeatmap() {
  430. this.addHeatmapCanvas();
  431. this.addAxes();
  432. if (this.panel.yAxis.logBase !== 1 && this.panel.dataFormat !== 'tsbuckets') {
  433. const logBase = this.panel.yAxis.logBase;
  434. const domain = this.yScale.domain();
  435. const tickValues = this.logScaleTickValues(domain, logBase);
  436. this.data.buckets = mergeZeroBuckets(this.data.buckets, _.min(tickValues));
  437. }
  438. const cardsData = this.data.cards;
  439. const cardStats = this.data.cardStats;
  440. const maxValueAuto = cardStats.max;
  441. const minValueAuto = Math.min(cardStats.min, 0);
  442. const maxValue = _.isNil(this.panel.color.max) ? maxValueAuto : this.panel.color.max;
  443. const minValue = _.isNil(this.panel.color.min) ? minValueAuto : this.panel.color.min;
  444. const colorScheme: any = _.find(this.ctrl.colorSchemes, {
  445. value: this.panel.color.colorScheme,
  446. });
  447. this.colorScale = getColorScale(colorScheme, contextSrv.user.lightTheme, maxValue, minValue);
  448. this.opacityScale = getOpacityScale(this.panel.color, maxValue, minValue);
  449. this.setCardSize();
  450. let cards = this.heatmap.selectAll('.heatmap-card').data(cardsData);
  451. cards.append('title');
  452. cards = cards
  453. .enter()
  454. .append('rect')
  455. .attr('x', this.getCardX.bind(this))
  456. .attr('width', this.getCardWidth.bind(this))
  457. .attr('y', this.getCardY.bind(this))
  458. .attr('height', this.getCardHeight.bind(this))
  459. .attr('rx', this.cardRound)
  460. .attr('ry', this.cardRound)
  461. .attr('class', 'bordered heatmap-card')
  462. .style('fill', this.getCardColor.bind(this))
  463. .style('stroke', this.getCardColor.bind(this))
  464. .style('stroke-width', 0)
  465. .style('opacity', this.getCardOpacity.bind(this));
  466. const $cards = this.$heatmap.find('.heatmap-card');
  467. $cards
  468. .on('mouseenter', event => {
  469. this.tooltip.mouseOverBucket = true;
  470. this.highlightCard(event);
  471. })
  472. .on('mouseleave', event => {
  473. this.tooltip.mouseOverBucket = false;
  474. this.resetCardHighLight(event);
  475. });
  476. }
  477. highlightCard(event) {
  478. const color = d3.select(event.target).style('fill');
  479. const highlightColor = d3.color(color).darker(2);
  480. const strokeColor = d3.color(color).brighter(4);
  481. const currentCard = d3.select(event.target);
  482. this.tooltip.originalFillColor = color;
  483. currentCard
  484. .style('fill', highlightColor.toString())
  485. .style('stroke', strokeColor.toString())
  486. .style('stroke-width', 1);
  487. }
  488. resetCardHighLight(event) {
  489. d3.select(event.target)
  490. .style('fill', this.tooltip.originalFillColor)
  491. .style('stroke', this.tooltip.originalFillColor)
  492. .style('stroke-width', 0);
  493. }
  494. setCardSize() {
  495. const xGridSize = Math.floor(this.xScale(this.data.xBucketSize) - this.xScale(0));
  496. let yGridSize = Math.floor(this.yScale(this.yScale.invert(0) - this.data.yBucketSize));
  497. if (this.panel.yAxis.logBase !== 1) {
  498. const base = this.panel.yAxis.logBase;
  499. const splitFactor = this.data.yBucketSize || 1;
  500. yGridSize = Math.floor((this.yScale(1) - this.yScale(base)) / splitFactor);
  501. }
  502. const cardWidth = xGridSize - this.cardPadding * 2;
  503. this.cardWidth = Math.max(cardWidth, MIN_CARD_SIZE);
  504. this.cardHeight = yGridSize ? yGridSize - this.cardPadding * 2 : 0;
  505. }
  506. getCardX(d) {
  507. let x;
  508. if (this.xScale(d.x) < 0) {
  509. // Cut card left to prevent overlay
  510. x = this.yAxisWidth + this.cardPadding;
  511. } else {
  512. x = this.xScale(d.x) + this.yAxisWidth + this.cardPadding;
  513. }
  514. return x;
  515. }
  516. getCardWidth(d) {
  517. let w = this.cardWidth;
  518. if (this.xScale(d.x) < 0) {
  519. // Cut card left to prevent overlay
  520. w = this.xScale(d.x) + this.cardWidth;
  521. } else if (this.xScale(d.x) + this.cardWidth > this.chartWidth) {
  522. // Cut card right to prevent overlay
  523. w = this.chartWidth - this.xScale(d.x) - this.cardPadding;
  524. }
  525. // Card width should be MIN_CARD_SIZE at least, but cut cards shouldn't be displayed
  526. w = w > 0 ? Math.max(w, MIN_CARD_SIZE) : 0;
  527. return w;
  528. }
  529. getCardY(d) {
  530. let y = this.yScale(d.y) + this.chartTop - this.cardHeight - this.cardPadding;
  531. if (this.panel.yAxis.logBase !== 1 && d.y === 0) {
  532. y = this.chartBottom - this.cardHeight - this.cardPadding;
  533. } else {
  534. if (y < this.chartTop) {
  535. y = this.chartTop;
  536. }
  537. }
  538. return y;
  539. }
  540. getCardHeight(d) {
  541. const y = this.yScale(d.y) + this.chartTop - this.cardHeight - this.cardPadding;
  542. let h = this.cardHeight;
  543. if (this.panel.yAxis.logBase !== 1 && d.y === 0) {
  544. return this.cardHeight;
  545. }
  546. // Cut card height to prevent overlay
  547. if (y < this.chartTop) {
  548. h = this.yScale(d.y) - this.cardPadding;
  549. } else if (this.yScale(d.y) > this.chartBottom) {
  550. h = this.chartBottom - y;
  551. } else if (y + this.cardHeight > this.chartBottom) {
  552. h = this.chartBottom - y;
  553. }
  554. // Height can't be more than chart height
  555. h = Math.min(h, this.chartHeight);
  556. // Card height should be MIN_CARD_SIZE at least
  557. h = Math.max(h, MIN_CARD_SIZE);
  558. return h;
  559. }
  560. getCardColor(d) {
  561. if (this.panel.color.mode === 'opacity') {
  562. return getColorFromHexRgbOrName(
  563. this.panel.color.cardColor,
  564. contextSrv.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark
  565. );
  566. } else {
  567. return this.colorScale(d.count);
  568. }
  569. }
  570. getCardOpacity(d) {
  571. if (this.panel.color.mode === 'opacity') {
  572. return this.opacityScale(d.count);
  573. } else {
  574. return 1;
  575. }
  576. }
  577. getEventOffset(event) {
  578. const elemOffset = this.$heatmap.offset();
  579. const x = Math.floor(event.clientX - elemOffset.left);
  580. const y = Math.floor(event.clientY - elemOffset.top);
  581. return { x, y };
  582. }
  583. onMouseDown(event) {
  584. const offset = this.getEventOffset(event);
  585. this.selection.active = true;
  586. this.selection.x1 = offset.x;
  587. this.mouseUpHandler = () => {
  588. this.onMouseUp();
  589. };
  590. $(document).one('mouseup', this.mouseUpHandler.bind(this));
  591. }
  592. onMouseUp() {
  593. $(document).unbind('mouseup', this.mouseUpHandler.bind(this));
  594. this.mouseUpHandler = null;
  595. this.selection.active = false;
  596. const selectionRange = Math.abs(this.selection.x2 - this.selection.x1);
  597. if (this.selection.x2 >= 0 && selectionRange > MIN_SELECTION_WIDTH) {
  598. const timeFrom = this.xScale.invert(Math.min(this.selection.x1, this.selection.x2) - this.yAxisWidth);
  599. const timeTo = this.xScale.invert(Math.max(this.selection.x1, this.selection.x2) - this.yAxisWidth);
  600. this.ctrl.timeSrv.setTime({
  601. from: toUtc(timeFrom),
  602. to: toUtc(timeTo),
  603. });
  604. }
  605. this.clearSelection();
  606. }
  607. onMouseLeave() {
  608. appEvents.emit('graph-hover-clear');
  609. this.clearCrosshair();
  610. }
  611. onMouseMove(event) {
  612. if (!this.heatmap) {
  613. return;
  614. }
  615. const offset = this.getEventOffset(event);
  616. if (this.selection.active) {
  617. // Clear crosshair and tooltip
  618. this.clearCrosshair();
  619. this.tooltip.destroy();
  620. this.selection.x2 = this.limitSelection(offset.x);
  621. this.drawSelection(this.selection.x1, this.selection.x2);
  622. } else {
  623. const pos = this.getEventPos(event, offset);
  624. this.drawCrosshair(offset.x);
  625. this.tooltip.show(pos, this.data);
  626. this.emitGraphHoverEvent(pos);
  627. }
  628. }
  629. getEventPos(event, offset) {
  630. const x = this.xScale.invert(offset.x - this.yAxisWidth).valueOf();
  631. const y = this.yScale.invert(offset.y - this.chartTop);
  632. const pos = {
  633. pageX: event.pageX,
  634. pageY: event.pageY,
  635. x: x,
  636. x1: x,
  637. y: y,
  638. y1: y,
  639. panelRelY: null,
  640. offset,
  641. };
  642. return pos;
  643. }
  644. emitGraphHoverEvent(pos) {
  645. // Set minimum offset to prevent showing legend from another panel
  646. pos.panelRelY = Math.max(pos.offset.y / this.height, 0.001);
  647. // broadcast to other graph panels that we are hovering
  648. appEvents.emit('graph-hover', { pos: pos, panel: this.panel });
  649. }
  650. limitSelection(x2) {
  651. x2 = Math.max(x2, this.yAxisWidth);
  652. x2 = Math.min(x2, this.chartWidth + this.yAxisWidth);
  653. return x2;
  654. }
  655. drawSelection(posX1, posX2) {
  656. if (this.heatmap) {
  657. this.heatmap.selectAll('.heatmap-selection').remove();
  658. const selectionX = Math.min(posX1, posX2);
  659. const selectionWidth = Math.abs(posX1 - posX2);
  660. if (selectionWidth > MIN_SELECTION_WIDTH) {
  661. this.heatmap
  662. .append('rect')
  663. .attr('class', 'heatmap-selection')
  664. .attr('x', selectionX)
  665. .attr('width', selectionWidth)
  666. .attr('y', this.chartTop)
  667. .attr('height', this.chartHeight);
  668. }
  669. }
  670. }
  671. clearSelection() {
  672. this.selection.x1 = -1;
  673. this.selection.x2 = -1;
  674. if (this.heatmap) {
  675. this.heatmap.selectAll('.heatmap-selection').remove();
  676. }
  677. }
  678. drawCrosshair(position) {
  679. if (this.heatmap) {
  680. this.heatmap.selectAll('.heatmap-crosshair').remove();
  681. let posX = position;
  682. posX = Math.max(posX, this.yAxisWidth);
  683. posX = Math.min(posX, this.chartWidth + this.yAxisWidth);
  684. this.heatmap
  685. .append('g')
  686. .attr('class', 'heatmap-crosshair')
  687. .attr('transform', 'translate(' + posX + ',0)')
  688. .append('line')
  689. .attr('x1', 1)
  690. .attr('y1', this.chartTop)
  691. .attr('x2', 1)
  692. .attr('y2', this.chartBottom)
  693. .attr('stroke-width', 1);
  694. }
  695. }
  696. drawSharedCrosshair(pos) {
  697. if (this.heatmap && this.ctrl.dashboard.graphTooltip !== 0) {
  698. const posX = this.xScale(pos.x) + this.yAxisWidth;
  699. this.drawCrosshair(posX);
  700. }
  701. }
  702. clearCrosshair() {
  703. if (this.heatmap) {
  704. this.heatmap.selectAll('.heatmap-crosshair').remove();
  705. }
  706. }
  707. render() {
  708. this.data = this.ctrl.data;
  709. this.panel = this.ctrl.panel;
  710. this.timeRange = this.ctrl.range;
  711. if (!this.setElementHeight() || !this.data) {
  712. return;
  713. }
  714. // Draw default axes and return if no data
  715. if (_.isEmpty(this.data.buckets)) {
  716. this.addHeatmapCanvas();
  717. this.addAxes();
  718. return;
  719. }
  720. this.addHeatmap();
  721. this.scope.yAxisWidth = this.yAxisWidth;
  722. this.scope.xAxisHeight = this.xAxisHeight;
  723. this.scope.chartHeight = this.chartHeight;
  724. this.scope.chartWidth = this.chartWidth;
  725. this.scope.chartTop = this.chartTop;
  726. }
  727. }