ui: heatmap visualization for histogram buckets (#13096)
ui: heatmap visualization for histogram buckets Signed-off-by: Yury Moladau <yurymolodov@gmail.com> --------- Signed-off-by: Yury Moladau <yurymolodov@gmail.com>pull/13189/head
parent
eda73dd3e5
commit
2e205ee95c
@ -0,0 +1,56 @@ |
||||
import { GraphProps, GraphSeries } from './Graph'; |
||||
|
||||
export function isHeatmapData(data: GraphProps['data']) { |
||||
if (!data?.result?.length || data?.result?.length < 2) { |
||||
return false; |
||||
} |
||||
const result = data.result; |
||||
const firstLabels = Object.keys(result[0].metric).filter((label) => label !== 'le'); |
||||
return result.every(({ metric }) => { |
||||
const labels = Object.keys(metric).filter((label) => label !== 'le'); |
||||
const allLabelsMatch = labels.every((label) => metric[label] === result[0].metric[label]); |
||||
return metric.le && labels.length === firstLabels.length && allLabelsMatch; |
||||
}); |
||||
} |
||||
|
||||
export function prepareHeatmapData(buckets: GraphSeries[]) { |
||||
if (!buckets.every((a) => a.labels.le)) { |
||||
return buckets; |
||||
} |
||||
|
||||
const sortedBuckets = buckets.sort((a, b) => promValueToNumber(a.labels.le) - promValueToNumber(b.labels.le)); |
||||
const result: GraphSeries[] = []; |
||||
|
||||
for (let i = 0; i < sortedBuckets.length; i++) { |
||||
const values = []; |
||||
const { data, labels, color } = sortedBuckets[i]; |
||||
|
||||
for (const [timestamp, value] of data) { |
||||
const prevVal = sortedBuckets[i - 1]?.data.find((v) => v[0] === timestamp)?.[1] || 0; |
||||
const newVal = Number(value) - prevVal; |
||||
values.push([Number(timestamp), newVal]); |
||||
} |
||||
|
||||
result.push({ |
||||
data: values, |
||||
labels, |
||||
color, |
||||
index: i, |
||||
}); |
||||
} |
||||
return result; |
||||
} |
||||
|
||||
export function promValueToNumber(s: string) { |
||||
switch (s) { |
||||
case 'NaN': |
||||
return NaN; |
||||
case 'Inf': |
||||
case '+Inf': |
||||
return Infinity; |
||||
case '-Inf': |
||||
return -Infinity; |
||||
default: |
||||
return parseFloat(s); |
||||
} |
||||
} |
||||
@ -0,0 +1,195 @@ |
||||
/* Flot plugin for rendering heatmap charts. |
||||
|
||||
Inspired by a similar feature in VictoriaMetrics. |
||||
See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3384 for more details.
|
||||
*/ |
||||
|
||||
import moment from 'moment-timezone'; |
||||
import {formatValue} from "../../pages/graph/GraphHelpers"; |
||||
|
||||
const TOOLTIP_ID = 'heatmap-tooltip'; |
||||
const GRADIENT_STEPS = 16; |
||||
|
||||
(function ($) { |
||||
let mouseMoveHandler = null; |
||||
|
||||
function init(plot) { |
||||
plot.hooks.draw.push((plot, ctx) => { |
||||
const options = plot.getOptions(); |
||||
if (!options.series.heatmap) { |
||||
return; |
||||
} |
||||
|
||||
const series = plot.getData(); |
||||
const fillPalette = generateGradient("#FDF4EB", "#752E12", GRADIENT_STEPS); |
||||
const fills = countsToFills(series.flatMap(s => s.data.map(d => d[1])), fillPalette); |
||||
series.forEach((s, i) => drawHeatmap(s, plot, ctx, i, fills)); |
||||
}); |
||||
|
||||
plot.hooks.bindEvents.push((plot, eventHolder) => { |
||||
const options = plot.getOptions(); |
||||
if (!options.series.heatmap || !options.tooltip.show) { |
||||
return; |
||||
} |
||||
|
||||
mouseMoveHandler = (e) => { |
||||
removeTooltip(); |
||||
const {left: xOffset, top: yOffset} = plot.offset(); |
||||
const pos = plot.c2p({left: e.pageX - xOffset, top: e.pageY - yOffset}); |
||||
const seriesIdx = Math.floor(pos.y); |
||||
const series = plot.getData(); |
||||
|
||||
for (let i = 0; i < series.length; i++) { |
||||
if (seriesIdx !== i) { |
||||
continue; |
||||
} |
||||
|
||||
const s = series[i]; |
||||
const label = s?.labels?.le || "" |
||||
const prevLabel = series[i - 1]?.labels?.le || "" |
||||
for (let j = 0; j < s.data.length - 1; j++) { |
||||
const [xStartVal, yStartVal] = s.data[j]; |
||||
const [xEndVal] = s.data[j + 1]; |
||||
const isIncluded = pos.x >= xStartVal && pos.x <= xEndVal; |
||||
if (yStartVal && isIncluded) { |
||||
showTooltip({ |
||||
cssClass: options.tooltip.cssClass, |
||||
x: e.pageX, |
||||
y: e.pageY, |
||||
value: formatValue(yStartVal), |
||||
dateTime: [xStartVal, xEndVal].map(t => moment(t).format('YYYY-MM-DD HH:mm:ss Z')), |
||||
label: `${prevLabel} - ${label}`, |
||||
}); |
||||
|
||||
break; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
$(eventHolder).on('mousemove', mouseMoveHandler); |
||||
}); |
||||
|
||||
plot.hooks.shutdown.push((_plot, eventHolder) => { |
||||
removeTooltip(); |
||||
$(eventHolder).off("mousemove", mouseMoveHandler); |
||||
}); |
||||
} |
||||
|
||||
function showTooltip({x, y, cssClass, value, dateTime, label}) { |
||||
const tooltip = document.createElement('div'); |
||||
tooltip.id = TOOLTIP_ID |
||||
tooltip.className = cssClass; |
||||
|
||||
const timeHtml = `<div class="date">${dateTime.join('<br>')}</div>` |
||||
const labelHtml = `<div>Bucket: ${label || 'value'}</div>` |
||||
const valueHtml = `<div>Value: <strong>${value}</strong></div>` |
||||
tooltip.innerHTML = `<div>${timeHtml}<div>${labelHtml}${valueHtml}</div></div>`; |
||||
|
||||
tooltip.style.position = 'absolute'; |
||||
tooltip.style.top = y + 5 + 'px'; |
||||
tooltip.style.left = x + 5 + 'px'; |
||||
tooltip.style.display = 'none'; |
||||
document.body.appendChild(tooltip); |
||||
|
||||
const totalTipWidth = $(tooltip).outerWidth(); |
||||
const totalTipHeight = $(tooltip).outerHeight(); |
||||
|
||||
if (x > ($(window).width() - totalTipWidth)) { |
||||
x -= totalTipWidth; |
||||
tooltip.style.left = x + 'px'; |
||||
} |
||||
|
||||
if (y > ($(window).height() - totalTipHeight)) { |
||||
y -= totalTipHeight; |
||||
tooltip.style.top = y + 'px'; |
||||
} |
||||
|
||||
tooltip.style.display = 'block'; // This will trigger a re-render, allowing fadeIn to work
|
||||
tooltip.style.opacity = '1'; |
||||
} |
||||
|
||||
function removeTooltip() { |
||||
let tooltip = document.getElementById(TOOLTIP_ID); |
||||
if (tooltip) { |
||||
document.body.removeChild(tooltip); |
||||
} |
||||
} |
||||
|
||||
function drawHeatmap(series, plot, ctx, seriesIndex, fills) { |
||||
const {data: dataPoints} = series; |
||||
const {left: xOffset, top: yOffset} = plot.getPlotOffset(); |
||||
const plotHeight = plot.height(); |
||||
const xaxis = plot.getXAxes()[0]; |
||||
const cellHeight = plotHeight / plot.getData().length; |
||||
|
||||
ctx.save(); |
||||
ctx.translate(xOffset, yOffset); |
||||
|
||||
for (let i = 0, len = dataPoints.length - 1; i < len; i++) { |
||||
const [xStartVal, countStart] = dataPoints[i]; |
||||
const [xEndVal] = dataPoints[i + 1]; |
||||
|
||||
const xStart = xaxis.p2c(xStartVal); |
||||
const xEnd = xaxis.p2c(xEndVal); |
||||
const cellWidth = xEnd - xStart; |
||||
const yStart = plotHeight - (seriesIndex + 1) * cellHeight; |
||||
|
||||
ctx.fillStyle = fills[countStart]; |
||||
ctx.fillRect(xStart + 0.5, yStart + 0.5, cellWidth - 1, cellHeight - 1); |
||||
} |
||||
|
||||
ctx.restore(); |
||||
} |
||||
|
||||
function countsToFills(counts, fillPalette) { |
||||
const hideThreshold = 0; |
||||
const minCount = Math.min(...counts.filter(count => count > hideThreshold)); |
||||
const maxCount = Math.max(...counts); |
||||
const range = maxCount - minCount; |
||||
const paletteSize = fillPalette.length; |
||||
|
||||
return counts.reduce((acc, count) => { |
||||
const index = count === 0 |
||||
? -1 |
||||
: Math.min(paletteSize - 1, Math.floor((paletteSize * (count - minCount)) / range)); |
||||
acc[count] = fillPalette[index] || "transparent"; |
||||
return acc; |
||||
}, {}); |
||||
} |
||||
|
||||
function generateGradient(color1, color2, steps) { |
||||
function interpolateColor(startColor, endColor, step) { |
||||
let r = startColor[0] + step * (endColor[0] - startColor[0]); |
||||
let g = startColor[1] + step * (endColor[1] - startColor[1]); |
||||
let b = startColor[2] + step * (endColor[2] - startColor[2]); |
||||
|
||||
return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`; |
||||
} |
||||
|
||||
function hexToRgb(hex) { |
||||
const bigint = parseInt(hex.slice(1), 16); |
||||
const r = (bigint >> 16) & 255; |
||||
const g = (bigint >> 8) & 255; |
||||
const b = bigint & 255; |
||||
|
||||
return [r, g, b]; |
||||
} |
||||
|
||||
return new Array(steps).fill("").map((_el, i) => { |
||||
return interpolateColor(hexToRgb(color1), hexToRgb(color2), i / (steps - 1)); |
||||
}); |
||||
} |
||||
|
||||
|
||||
jQuery.plot.plugins.push({ |
||||
init, |
||||
options: { |
||||
series: { |
||||
heatmap: false |
||||
} |
||||
}, |
||||
name: 'heatmap', |
||||
version: '1.0' |
||||
}); |
||||
})(jQuery); |
||||
Loading…
Reference in new issue