Files
openobserve/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.ts
T

939 lines
31 KiB
TypeScript

// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
ConversionContext,
PromQLChartConverter,
ProcessedPromQLData,
TOOLTIP_SCROLL_STYLE,
} from "./shared/types";
import { getUnitValue, formatUnitValue } from "../convertDataIntoUnitValue";
import { chartAxisTextColor, chartColor } from "@/utils/chartTheme";
import { escapeHtml } from "@/utils/html";
import {
deaccumulateHistogramSeries,
groupHistogramSeries,
HistogramSeriesOutput,
PROMETHEUS_HISTOGRAM_MODE,
} from "./shared/histogramBuckets";
import { formatTimestamps } from "./shared/dataProcessor";
import { formatDate } from "../dateTimeUtils";
import {
HEATMAP_MAX_CELLS,
HEATMAP_SPLIT_AREA,
addHeatmapWarning,
cutHeatmapToBudget,
cutHistogramsToBudget,
heatmapBudgetWarning,
heatmapCellItemStyle,
heatmapColorRange,
heatmapColorScale,
heatmapLargeGridDefaults,
heatmapValueLabel,
histogramBudgetWarnings,
visualMapRangeOf,
} from "../heatmapDefaults";
import { sortHeatmapCategories } from "../heatmapYAxis";
import {
applyHeatmapTimePointer,
heatmapZoomToolbox,
promqlHeatmapTimeAxis,
} from "../heatmapTimeAxis";
import type { HeatmapExemplarGroup, HeatmapExemplarLayout } from "../exemplars/heatmapExemplars";
import { gt } from "@/types/i18n";
import { addCommasToNumber } from "@/utils/formatters";
export { PROMETHEUS_HISTOGRAM_MODE };
const STACK_TITLE_PX = 20;
const STACK_BOTTOM_PX = 60;
const STACK_GAP_PX = 6;
const STACK_X_LABEL_PX = 24;
const STACK_MIN_GRID_PX = 30;
const UNMEASURED_PANEL_PX = 400;
interface PromQLTimeGrid {
startSec: number;
endSec: number;
stepSec: number;
}
interface HeatmapGrid {
data: any[];
rows: number[];
timeAxis: Array<[any, any]>;
min: number;
max: number;
}
interface HeatmapCells {
data: any[];
min: number;
max: number;
}
interface HistogramGroupBuckets {
label: string;
source: Omit<HeatmapExemplarGroup, "bounds"> | null;
buckets: HistogramSeriesOutput[];
}
interface HistogramGrid {
label: string;
bucketLabels: string[];
bucketValues: string[];
data: any[];
}
/**
* The X axis for a heatmap: every timestamp any query reported, ascending.
*
* A union rather than `processedData[0].timestamps`: if the first query returned
* no data the axis would be empty (blank panel), and if two queries land on
* different grids, later samples get looked up against query 0's timestamps and
* read as 0. The union degrades to the truth instead of to blankness.
*/
function buildTimeAxis(
processedData: ProcessedPromQLData[],
grid?: PromQLTimeGrid | null,
timezone?: string,
): Array<[any, any]> {
const byTs = new Map<string, [any, any]>();
for (const queryData of processedData ?? []) {
for (const entry of queryData?.timestamps ?? []) {
// Keep the first formatting seen for a timestamp; two queries that report
// the same instant format it identically anyway.
const key = String(entry[0]);
if (!byTs.has(key)) byTs.set(key, entry);
}
}
if (grid && byTs.size > 0) {
const missing = new Set<number>();
for (const ts of gridTimestamps(Number(byTs.keys().next().value), grid)) {
if (!byTs.has(String(ts))) missing.add(ts);
}
for (const entry of formatTimestamps(missing, timezone ?? "UTC")) {
byTs.set(String(entry[0]), entry);
}
}
return [...byTs.values()].sort((a, b) => Number(a[0]) - Number(b[0]));
}
/** Every evaluation instant of `grid` on the step lattice through `anchor`, capped to the cell budget's column count. */
function gridTimestamps(anchor: number, grid: PromQLTimeGrid): number[] {
const { startSec, endSec, stepSec } = grid;
let first = anchor - Math.floor((anchor - startSec) / stepSec) * stepSec;
const last = anchor + Math.floor((endSec - anchor) / stepSec) * stepSec;
if ((last - first) / stepSec + 1 > HEATMAP_MAX_CELLS) {
first = last - (HEATMAP_MAX_CELLS - 1) * stepSec;
}
const timestamps: number[] = [];
for (let ts = first; ts <= last; ts += stepSec) timestamps.push(ts);
return timestamps;
}
function timeGridOf(context?: ConversionContext): PromQLTimeGrid | null {
const startSec = Number(context?.metadata?.queries?.[0]?.startTime) / 1_000_000;
const endSec = Number(context?.metadata?.queries?.[0]?.endTime) / 1_000_000;
const stepSec = Number(context?.stepSeconds);
if (!(stepSec > 0) || !Number.isFinite(startSec) || !Number.isFinite(endSec)) return null;
return endSec >= startSec ? { startSec, endSec, stepSec } : null;
}
/** The summed magnitude of a series' samples, which ranks its row for the cell budget so strongly negative rows count as strong. */
function sampleMagnitude(data: Record<string, unknown>): number {
let total = 0;
for (const raw of Object.values(data ?? {})) {
const value = sampleValue(raw);
if (value !== "-") total += Math.abs(value);
}
return total;
}
/** A sample as a cell value; a missing or non-numeric sample is an empty cell, never 0. */
function sampleValue(raw: unknown): number | "-" {
if (raw === null || raw === undefined || raw === "") return "-";
const value = typeof raw === "number" ? raw : parseFloat(String(raw));
return Number.isFinite(value) ? value : "-";
}
/** The cells of a `rowCount` x `timeAxis` grid, cut to the cell budget before any cell is allocated. */
function buildHeatmapGrid(
rowTotals: number[],
timeAxis: Array<[any, any]>,
valueAt: (row: number, ts: any) => number | "-",
extras: any,
): HeatmapGrid {
const cut = cutHeatmapToBudget(timeAxis.length, rowTotals);
if (cut) addHeatmapWarning(extras, heatmapBudgetWarning(cut, timeAxis.length, rowTotals.length));
const rows = cut ? cut.rows : rowTotals.map((_total, row) => row);
const keptAxis = cut ? timeAxis.slice(cut.firstColumn) : timeAxis;
return { ...fillHeatmapCells(rows, keptAxis, valueAt), rows, timeAxis: keptAxis };
}
function fillHeatmapCells(
rows: number[],
timeAxis: Array<[any, any]>,
valueAt: (row: number, ts: any) => number | "-",
): HeatmapCells {
const data: any[] = [];
let min = Infinity;
let max = -Infinity;
rows.forEach((row, rowIndex) => {
timeAxis.forEach(([ts], timeIndex) => {
const value = valueAt(row, ts);
data.push([timeIndex, rowIndex, value]);
if (value === "-") return;
if (value > max) max = value;
if (value < min) min = value;
});
});
return { data, min, max };
}
/** Each query's histograms, split by their non-`le` labels and de-accumulated on their own, in query order; titles name the query once there are several. */
function histogramGroupsOf(processedData: ProcessedPromQLData[]): HistogramGroupBuckets[] {
return processedData.flatMap((queryData, queryPosition) =>
groupHistogramSeries(queryData.series ?? []).map((group) => ({
label: [
[
processedData.length > 1
? gt("common.queryNumber", { index: (queryData.queryIndex ?? queryPosition) + 1 })
: "",
group.label,
]
.filter(Boolean)
.join(", "),
queryData.nameSuffix ? `(${queryData.nameSuffix})` : "",
]
.filter(Boolean)
.join(" "),
source:
queryData.seriesRole === "shifted"
? null
: { queryIndex: queryData.queryIndex ?? queryPosition, labels: group.labels },
buckets: deaccumulateHistogramSeries(
group.series.map((seriesData) => ({
le: extractLeLabel(seriesData),
data: seriesData.data ?? {},
})),
),
})),
);
}
/** The sum of every bucket of a histogram, which ranks it against the others for the cell budget. */
function histogramTotal(group: HistogramGroupBuckets): number {
let total = 0;
for (const bucket of group.buckets) {
for (const value of Object.values(bucket.data)) total += value;
}
return total;
}
/** Y-axis labels for a histogram's buckets in ascending `le` order, widened until unique as ECharts category axes require. */
function uniqueBucketLabels(buckets: HistogramSeriesOutput[], config: any): string[] {
const usedLabels = new Set<string>();
return buckets.map((bucket) => {
const base = formatBucketLabel(bucket.le, bucket.leValue, config);
let label = base;
if (usedLabels.has(label)) label = `${base} (${bucket.le})`;
for (let n = 2; usedLabels.has(label); n++) {
label = `${base} (${bucket.le}) #${n}`;
}
usedLabels.add(label);
return label;
});
}
/** How many stacked histogram grids fit a `heightPx` panel with every grid at least STACK_MIN_GRID_PX tall. */
function maxStackedGrids(heightPx: number): number {
const usable = heightPx - STACK_BOTTOM_PX - STACK_X_LABEL_PX;
return Math.max(1, Math.floor(usable / (STACK_TITLE_PX + STACK_MIN_GRID_PX + STACK_GAP_PX)));
}
function stackedGridLayout(
count: number,
heightPx: number,
): Array<{ titleTop: number; gridTop: number; height: number }> {
const pct = (px: number) => (px / heightPx) * 100;
const slot = (heightPx - STACK_BOTTOM_PX - STACK_X_LABEL_PX) / count;
const gridPx = Math.max(slot - STACK_TITLE_PX - STACK_GAP_PX, STACK_MIN_GRID_PX);
// Only the bottom grid shows time labels, and containLabel takes their height out of its own.
return Array.from({ length: count }, (_unused, index) => ({
titleTop: pct(index * slot),
gridTop: pct(index * slot + STACK_TITLE_PX),
height: pct(gridPx + (index === count - 1 ? STACK_X_LABEL_PX : 0)),
}));
}
/** The groups (indices, in order) a panel can stack: the busiest ones that fit its height. */
function groupsThatFit(groups: HistogramGroupBuckets[], heightPx: number): number[] {
const all = groups.map((_group, index) => index);
const fit = maxStackedGrids(heightPx);
if (all.length <= fit) return all;
return all
.sort((a, b) => histogramTotal(groups[b]) - histogramTotal(groups[a]) || a - b)
.slice(0, fit)
.sort((a, b) => a - b);
}
function warnDroppedHistograms(
processedData: ProcessedPromQLData[],
shownForHeight: number,
groupCount: number,
extras: any,
): void {
let shown = 0;
let total = 0;
for (const queryData of processedData) {
if (!queryData.histogramsLimited) continue;
shown += queryData.histogramsLimited.shown;
total += queryData.histogramsLimited.total;
}
if (total > shown) {
addHeatmapWarning(
extras,
gt("dashboard.utils.heatmapHistogramsSeriesLimited", {
shown: addCommasToNumber(shown),
total: addCommasToNumber(total),
}),
);
}
if (shownForHeight < groupCount) {
addHeatmapWarning(
extras,
gt("dashboard.utils.heatmapHistogramsHeightCapped", {
shown: addCommasToNumber(shownForHeight),
total: addCommasToNumber(groupCount),
}),
);
}
}
/** The heatmap series for one histogram grid; `cellCount` is the whole chart's, which decides chunked rendering. */
function histogramSeries(data: any[], cellCount: number, store: any): any {
return {
type: "heatmap",
data,
...heatmapLargeGridDefaults(cellCount),
// Histogram heatmaps are far too dense for per-cell labels.
label: {
show: false,
},
itemStyle: heatmapCellItemStyle(store),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, 0.5)",
},
},
};
}
function histogramVisualMap(
config: any,
range: { min: number; max: number },
grids: HistogramGrid[],
): any {
const { color, ...scaledRange } = heatmapColorScale(
config,
range,
grids.map((grid) => grid.data),
);
return {
...scaledRange,
calculable: true,
orient: "horizontal",
left: "center",
inRange: { color },
};
}
/** The histogram tooltip; `rowTitle` names the hovered cell's row, as HTML-safe text. */
function histogramTooltip(config: any, rowTitle: (params: any) => string): any {
return {
position: "top",
// Rendered into <body> so the panel's overflow does not clip it.
appendToBody: true,
// ChartRenderer's mouseleave skips this class, so entering the tooltip does not reset every panel's hover.
className: "o2-echarts-tooltip",
textStyle: {
color: chartColor("--color-tooltip-text"),
fontSize: 12,
},
enterable: true,
backgroundColor: chartColor("--color-tooltip-bg"),
extraCssText: TOOLTIP_SCROLL_STYLE,
formatter: (params: any) => {
try {
// config.unit remains the CELL INTENSITY unit (e.g. count/s).
const value =
formatUnitValue(
getUnitValue(params?.value?.[2], config?.unit, config?.unit_custom, config?.decimals),
) || params?.value?.[2];
return `${rowTitle(params)} <br/> ${params?.marker} ${escapeHtml(params?.name)} : ${escapeHtml(value)}`;
} catch (error) {
return "";
}
},
};
}
/** A single histogram grid, the layout every one-histogram panel has always had. */
function singleHistogramOptions(
grid: HistogramGrid,
xAxisData: string[],
visualMapRange: { min: number; max: number },
config: any,
store: any,
): any {
return {
series: [histogramSeries(grid.data, grid.data.length, store)],
xAxis: [
{
type: "category",
data: xAxisData,
splitArea: HEATMAP_SPLIT_AREA,
axisLabel: { color: chartAxisTextColor() },
},
],
yAxis: {
type: "category",
data: grid.bucketLabels,
splitArea: HEATMAP_SPLIT_AREA,
axisLabel: {
color: chartAxisTextColor(),
overflow: "truncate",
width: config.axis_width || 150,
},
},
visualMap: histogramVisualMap(config, visualMapRange, [grid]),
tooltip: histogramTooltip(
config,
(params) => `le ${escapeHtml(grid.bucketLabels[params?.value?.[1]] ?? params?.seriesName)}`,
),
grid: {
left: "3%",
right: "4%",
bottom: 60,
containLabel: true,
},
};
}
function stackedHistogramOptions(
grids: HistogramGrid[],
xAxisData: string[],
visualMapRange: { min: number; max: number },
config: any,
store: any,
heightPx: number,
): any {
const layout = stackedGridLayout(grids.length, heightPx);
const cellCount = grids.reduce((count, grid) => count + grid.data.length, 0);
const last = grids.length - 1;
return {
title: grids.map((grid, index) => ({
text: grid.label,
top: `${layout[index].titleTop}%`,
left: "3%",
textStyle: { fontSize: 12, fontWeight: "bold" },
})),
grid: layout.map((cell) => ({
left: "3%",
right: "4%",
top: `${cell.gridTop}%`,
height: `${cell.height}%`,
containLabel: true,
})),
xAxis: grids.map((_grid, index) => ({
type: "category",
gridIndex: index,
data: xAxisData,
splitArea: HEATMAP_SPLIT_AREA,
// The grids share one time axis, so only the bottom one spends height on its labels.
axisLabel: { show: index === last, color: chartAxisTextColor() },
})),
yAxis: grids.map((grid, index) => ({
type: "category",
gridIndex: index,
data: grid.bucketLabels,
splitArea: HEATMAP_SPLIT_AREA,
axisLabel: {
color: chartAxisTextColor(),
overflow: "truncate",
width: config.axis_width || 150,
},
})),
series: grids.map((grid, index) => ({
// Unnamed, so no legend entry overlaps the colour bar; each grid's title names it.
...histogramSeries(grid.data, cellCount, store),
xAxisIndex: index,
yAxisIndex: index,
})),
visualMap: histogramVisualMap(config, visualMapRange, grids),
tooltip: histogramTooltip(config, (params) => {
const grid = grids[params?.seriesIndex];
const bucket = grid?.bucketLabels[params?.value?.[1]] ?? params?.seriesName;
return `${escapeHtml(grid?.label ?? "")} <br/> le ${escapeHtml(bucket)}`;
}),
};
}
/** The calendar day of a formatted axis timestamp, in the zone it was formatted for. */
function dayOf(formatted: Date | string): string {
if (formatted instanceof Date) {
return `${formatted.getFullYear()}-${formatted.getMonth()}-${formatted.getDate()}`;
}
return String(formatted).slice(0, 10);
}
/** X labels: the time of day, or the full date and time when the axis spans more than one calendar day. */
function buildXAxisData(timestamps: Array<[any, any]>): string[] {
const days = new Set((timestamps ?? []).map(([, formatted]) => dayOf(formatted)));
const withDate = days.size > 1;
return (
timestamps?.map(([, formatted]) => {
if (formatted instanceof Date) {
return withDate ? formatDate(formatted) : formatDate(formatted).slice(11);
}
const dateStr = formatted.toString();
const match = dateStr.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})/);
if (match) return withDate ? `${match[1]} ${match[2]}` : match[2];
return dateStr.match(/(\d{2}:\d{2}:\d{2})/)?.[1] ?? dateStr;
}) || []
);
}
/** True while another panel is hovered, when a synced tooltip shows only its axis pointer. */
function otherPanelHovered(extras: any, panelId: unknown): boolean {
const state = extras?.hoveredSeriesState?.value;
return !!(state && panelId && state.panelId !== panelId);
}
function applyTimeInteraction(
options: any,
extras: any,
panelId: unknown,
timestamps: Array<[any, any]>,
grid: PromQLTimeGrid | null,
context: ConversionContext | undefined,
store: any,
): any {
const axis = promqlHeatmapTimeAxis(
timestamps,
grid?.stepSec ?? Number(context?.stepSeconds),
grid?.startSec ?? Number(context?.metadata?.queries?.[0]?.startTime) / 1_000_000,
store?.state?.timezone ?? "UTC",
);
extras.panelId = panelId;
if (!axis) return options;
extras.heatmapTimeAxis = axis;
extras.isTimeSeries = true;
options.toolbox = heatmapZoomToolbox();
const formatter = options.tooltip?.formatter;
options.tooltip = {
...options.tooltip,
formatter: (params: any) => (otherPanelHovered(extras, panelId) ? "" : formatter?.(params)),
};
applyHeatmapTimePointer(options);
return options;
}
/** Labels that identify a series' row, without the metric name a function result drops. */
function rowLabels(metric: Record<string, string> | undefined): Record<string, string> {
const { __name__: _name, ...labels } = metric ?? {};
return labels;
}
/**
* Extract the `le` label for a series.
* Prefers the parsed `metric.le` label; when the metric labels are absent
* (some callers only carry a rendered legend name), fall back to pulling
* `le="..."` out of the series name, and finally to the whole name if it
* looks like a bare bound.
*/
export function extractLeLabel(seriesData: any): string {
const fromMetric = seriesData?.metric?.le;
if (fromMetric !== undefined && fromMetric !== null) {
return String(fromMetric);
}
const name = seriesData?.name;
if (typeof name !== "string") return "";
const labelMatch = name.match(/\ble\s*=\s*"([^"]*)"/);
if (labelMatch) return labelMatch[1];
return name.trim();
}
/**
* Render a bucket bound as a y-axis category label.
* `+Inf` renders as the literal "+Inf"; finite bounds are formatted with the
* bucket unit (the unit of the OBSERVATION, e.g. seconds) — distinct from
* `config.unit`, which describes the cell intensity (count/s).
*/
function formatBucketLabel(le: string, leValue: number, config: any): string {
if (!Number.isFinite(leValue)) {
return leValue < 0 ? "-Inf" : "+Inf";
}
try {
const formatted = formatUnitValue(
getUnitValue(leValue, config?.bucket_unit, config?.bucket_unit_custom, config?.decimals),
);
return formatted || le;
} catch (error) {
return le;
}
}
/**
* Card-sized heatmap. The metrics explorer's preview cards are a few cm tall,
* where the default colour bar and full-size axis labels swamp the plot (the
* cells collapse to a thin line and only one bucket label survives). Shrinks the
* visual map, thins the bucket labels — always keeping the top `+Inf` row — and
* drops the font sizes. Mutates and returns `options`. Only run when the card
* sets `config.compact_preview`.
*/
function applyCompactPreview(options: any): any {
if (options.visualMap) {
options.visualMap = {
...options.visualMap,
show: true,
orient: "horizontal",
left: "center",
bottom: 0,
itemWidth: 10,
itemHeight: 90,
precision: 2,
textStyle: { fontSize: 9 },
};
if (!Array.isArray(options.grid))
options.grid = { ...(options.grid ?? {}), top: 8, bottom: 48 };
}
for (const yAxis of Array.isArray(options.yAxis) ? options.yAxis : [options.yAxis]) {
const rowCount = yAxis?.data?.length ?? 0;
if (!yAxis || !rowCount) continue;
// Thin the bucket labels to what a card can render, but ALWAYS keep the top
// row: a histogram's `+Inf` bucket is the "everything slower than this" row,
// and ECharts' hideOverlap would otherwise drop it. Count DOWN from the top
// so `+Inf` is always labelled and the spacing stays even.
const step = Math.max(1, Math.ceil(rowCount / 8));
yAxis.axisLabel = {
...(yAxis.axisLabel ?? {}),
fontSize: 9,
width: 46,
overflow: "truncate",
hideOverlap: false,
interval: (index: number) => (rowCount - 1 - index) % step === 0,
};
}
for (const xAxis of Array.isArray(options.xAxis) ? options.xAxis : [options.xAxis]) {
if (!xAxis) continue;
xAxis.axisLabel = {
...(xAxis.axisLabel ?? {}),
fontSize: 9,
hideOverlap: true,
};
}
return options;
}
/**
* Converter for heatmap charts
* Displays time-series data as a 2D heatmap with color intensity
*/
export class HeatmapConverter implements PromQLChartConverter {
supportedTypes = ["heatmap"];
convert(
processedData: ProcessedPromQLData[],
panelSchema: any,
store: any,
extras: any,
chartPanelRef?: any,
context?: ConversionContext,
) {
const config = panelSchema.config || {};
const grid = timeGridOf(context);
// Opt-in only: Prometheus classic-histogram mode. Generic heatmaps keep
// their existing behavior untouched.
if (config?.heatmap_mode === PROMETHEUS_HISTOGRAM_MODE) {
return this.convertHistogram(
processedData,
panelSchema,
store,
extras,
grid,
chartPanelRef,
context,
);
}
// Rows span every query because per-query series indices overlap.
const sourceOf = new Map<object, ProcessedPromQLData>();
const allSeries = sortHeatmapCategories(
processedData.flatMap((queryData) => {
for (const series of queryData.series) sourceOf.set(series, queryData);
return queryData.series;
}),
(series) => series.name,
config?.heatmap_y_sort,
);
const {
data,
rows,
timeAxis,
min: minValue,
max: maxValue,
} = buildHeatmapGrid(
allSeries.map((series) => sampleMagnitude(series.data)),
buildTimeAxis(processedData, grid, store?.state?.timezone),
(row, ts) => sampleValue(allSeries[row].data[ts]),
extras,
);
const seriesNames = rows.map((row) => allSeries[row].name);
seriesNames.forEach((name) => extras.legends.push(name));
extras.heatmapRowValues = [seriesNames];
extras.heatmapExemplarLayout = {
mode: "series",
rows: rows.map((row) => {
const source = sourceOf.get(allSeries[row]);
if (!source || source.seriesRole === "shifted") return null;
return { queryIndex: source.queryIndex, labels: rowLabels(allSeries[row].metric) };
}),
} satisfies HeatmapExemplarLayout;
const { color, ...visualMapRange } = heatmapColorScale(
config,
heatmapColorRange(config, visualMapRangeOf(minValue, maxValue)),
[data],
);
// Format timestamps to extract time portion (consistent with bar charts)
const xAxisData = buildXAxisData(timeAxis);
const options: any = {
series: [
{
type: "heatmap",
data,
// Per-cell labels only while the grid is small enough to read them,
// and chunked rendering when it is not — a `sum by (le) (rate(...))`
// over a full-size panel is thousands of cells, and a text element +
// a unit formatter per cell is what hangs the tab. See heatmapDefaults.
...heatmapLargeGridDefaults(data.length),
label: heatmapValueLabel(data.length, (params: any) => {
try {
return (
formatUnitValue(
getUnitValue(
params?.value?.[2],
config?.unit,
config?.unit_custom,
config?.decimals,
),
) || params?.value?.[2]
);
} catch (error) {
return params?.value?.[2]?.toString() ?? "";
}
}),
// Shared heatmap defaults, so a heatmap looks the same wherever it
// is drawn (see heatmapDefaults.ts).
itemStyle: heatmapCellItemStyle(store),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, 0.5)",
},
},
},
],
xAxis: [
{
type: "category",
data: xAxisData,
splitArea: HEATMAP_SPLIT_AREA,
axisLabel: { color: chartAxisTextColor() },
},
],
yAxis: {
type: "category",
data: seriesNames,
splitArea: HEATMAP_SPLIT_AREA,
axisLabel: {
color: chartAxisTextColor(),
overflow: "truncate",
width: config.axis_width || 150,
},
},
visualMap: {
...visualMapRange,
calculable: true,
orient: "horizontal",
left: "center",
inRange: {
color,
},
},
tooltip: {
position: "top",
// render into <body> so the tooltip is not clipped by the panel's
// overflow — same container treatment as the other PromQL charts
appendToBody: true,
// ChartRenderer's mouseleave skips this class, so entering the tooltip does not reset every panel's hover.
className: "o2-echarts-tooltip",
textStyle: {
color: chartColor("--color-tooltip-text"),
fontSize: 12,
},
enterable: true,
backgroundColor: chartColor("--color-tooltip-bg"),
extraCssText: TOOLTIP_SCROLL_STYLE,
formatter: (params: any) => {
try {
const seriesName = seriesNames[params?.value[1]] || params?.seriesName;
const value =
formatUnitValue(
getUnitValue(
params?.value?.[2],
config?.unit,
config?.unit_custom,
config?.decimals,
),
) || params?.value?.[2];
return `${escapeHtml(seriesName)} <br/> ${params?.marker} ${escapeHtml(params?.name)} : ${escapeHtml(value)}`;
} catch (error) {
return "";
}
},
},
grid: {
left: "3%",
right: "4%",
bottom: 60,
containLabel: true,
},
};
applyTimeInteraction(options, extras, panelSchema.id, timeAxis, grid, context, store);
return config?.compact_preview ? applyCompactPreview(options) : options;
}
/**
* Prometheus classic-histogram heatmap.
*
* Assumes each series is one cumulative bucket of a classic histogram
* (typically `sum by(le) (rate(x_bucket[W]))`). The cumulative buckets are
* de-accumulated into per-bucket values, and the y-axis becomes the sorted
* `le` bounds rather than raw series names.
*/
private convertHistogram(
processedData: ProcessedPromQLData[],
panelSchema: any,
store: any,
extras: any,
grid: PromQLTimeGrid | null,
chartPanelRef?: any,
context?: ConversionContext,
) {
const config = panelSchema.config || {};
const groups = histogramGroupsOf(processedData);
const timeAxis = buildTimeAxis(processedData, grid, store?.state?.timezone);
const heightPx = chartPanelRef?.value?.offsetHeight || UNMEASURED_PANEL_PX;
const fitting = groupsThatFit(groups, heightPx).map((index) => groups[index]);
warnDroppedHistograms(processedData, fitting.length, groups.length, extras);
const cut = cutHistogramsToBudget(
timeAxis.length,
fitting.map((group) => group.buckets.length),
fitting.map(histogramTotal),
);
if (cut) {
for (const warning of histogramBudgetWarnings(cut, timeAxis.length, fitting.length)) {
addHeatmapWarning(extras, warning);
}
}
const kept = cut ? cut.groups.map((index) => fitting[index]) : fitting;
const timestamps = cut ? timeAxis.slice(cut.firstColumn) : timeAxis;
let minValue = Infinity;
let maxValue = -Infinity;
const grids: HistogramGrid[] = kept.map((group) => {
const cells = fillHeatmapCells(
group.buckets.map((_bucket, row) => row),
timestamps,
(row, ts) => sampleValue(group.buckets[row].data[String(ts)]),
);
minValue = Math.min(minValue, cells.min);
maxValue = Math.max(maxValue, cells.max);
return {
label: group.label,
bucketLabels: uniqueBucketLabels(group.buckets, config),
bucketValues: group.buckets.map((bucket) => bucket.le),
data: cells.data,
};
});
for (const histogram of grids) {
for (const label of histogram.bucketLabels) extras.legends.push(label);
}
extras.heatmapRowValues = grids.map((histogram) => histogram.bucketValues);
extras.heatmapExemplarLayout = {
mode: "histogram",
groups: kept.map((group) =>
group.source
? { ...group.source, bounds: group.buckets.map((bucket) => bucket.leValue) }
: null,
),
} satisfies HeatmapExemplarLayout;
// [0, max] for clamped counts, unless heatmaps drawn side by side share a range so colours compare.
const visualMapRange = heatmapColorRange(
config,
config?.visual_map_range ?? visualMapRangeOf(minValue, maxValue),
);
const xAxisData = buildXAxisData(timestamps);
const options =
grids.length > 1
? stackedHistogramOptions(grids, xAxisData, visualMapRange, config, store, heightPx)
: singleHistogramOptions(
grids[0] ?? { label: "", bucketLabels: [], bucketValues: [], data: [] },
xAxisData,
visualMapRange,
config,
store,
);
applyTimeInteraction(options, extras, panelSchema.id, timestamps, grid, context, store);
return config?.compact_preview ? applyCompactPreview(options) : options;
}
}