prometheus/web/ui/mantine-ui/src/pages/query/uPlotChartHelpers.ts
Julius Volz 4ae1effb62 Bring back vertical grid lines
Addressing part of https://github.com/prometheus/prometheus/issues/16162

Signed-off-by: Julius Volz <julius.volz@gmail.com>
2025-03-04 23:38:25 +01:00

476 lines
15 KiB
TypeScript

import { RangeSamples } from "../../api/responseTypes/query";
import { formatSeries } from "../../lib/formatSeries";
import { formatTimestamp } from "../../lib/formatTime";
import { getSeriesColor } from "./colorPool";
import { computePosition, shift, flip, offset } from "@floating-ui/dom";
import uPlot, { AlignedData, Series } from "uplot";
const formatYAxisTickValue = (y: number | null): string => {
if (y === null) {
return "null";
}
const absY = Math.abs(y);
if (absY >= 1e24) {
return (y / 1e24).toFixed(2) + "Y";
} else if (absY >= 1e21) {
return (y / 1e21).toFixed(2) + "Z";
} else if (absY >= 1e18) {
return (y / 1e18).toFixed(2) + "E";
} else if (absY >= 1e15) {
return (y / 1e15).toFixed(2) + "P";
} else if (absY >= 1e12) {
return (y / 1e12).toFixed(2) + "T";
} else if (absY >= 1e9) {
return (y / 1e9).toFixed(2) + "G";
} else if (absY >= 1e6) {
return (y / 1e6).toFixed(2) + "M";
} else if (absY >= 1e3) {
return (y / 1e3).toFixed(2) + "k";
} else if (absY >= 1) {
return y.toFixed(2);
} else if (absY === 0) {
return y.toFixed(2);
} else if (absY < 1e-23) {
return (y / 1e-24).toFixed(2) + "y";
} else if (absY < 1e-20) {
return (y / 1e-21).toFixed(2) + "z";
} else if (absY < 1e-17) {
return (y / 1e-18).toFixed(2) + "a";
} else if (absY < 1e-14) {
return (y / 1e-15).toFixed(2) + "f";
} else if (absY < 1e-11) {
return (y / 1e-12).toFixed(2) + "p";
} else if (absY < 1e-8) {
return (y / 1e-9).toFixed(2) + "n";
} else if (absY < 1e-5) {
return (y / 1e-6).toFixed(2) + "µ";
} else if (absY < 1e-2) {
return (y / 1e-3).toFixed(2) + "m";
} else if (absY <= 1) {
return y.toFixed(2);
}
throw Error("couldn't format a value, this is a bug");
};
const escapeHTML = (str: string): string => {
const entityMap: { [key: string]: string } = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
"/": "&#x2F;",
};
return String(str).replace(/[&<>"'/]/g, function (s) {
return entityMap[s];
});
};
const formatLabels = (labels: { [key: string]: string }): string => `
<div class="labels">
${Object.keys(labels).length === 0 ? '<div class="no-labels">no labels</div>' : ""}
${labels["__name__"] ? `<div><strong>${escapeHTML(labels["__name__"])}</strong></div>` : ""}
${Object.keys(labels)
.filter((k) => k !== "__name__")
.map(
(k) =>
`<div><strong>${escapeHTML(k)}</strong>: ${escapeHTML(labels[k])}</div>`
)
.join("")}
</div>`;
const tooltipPlugin = (useLocalTime: boolean, data: AlignedData) => {
let over: HTMLDivElement;
let boundingLeft: number;
let boundingTop: number;
let selectedSeriesIdx: number | null = null;
const overlay = document.createElement("div");
overlay.className = "u-tooltip";
overlay.style.display = "none";
return {
hooks: {
// Set up event handlers and append overlay.
init: (u: uPlot) => {
over = u.over;
over.addEventListener("mouseenter", () => {
overlay.style.display = "block";
});
over.addEventListener("mouseleave", () => {
overlay.style.display = "none";
});
document.body.appendChild(overlay);
},
// When the chart is destroyed, remove the overlay from the DOM.
destroy: () => {
overlay.remove();
},
// When the chart is resized, store the bounding box of the overlay.
setSize: () => {
const bbox = over.getBoundingClientRect();
boundingLeft = bbox.left;
boundingTop = bbox.top;
},
// When a series is selected by hovering close to it, store the
// index of the selected series, so we can update the hover tooltip
// in setCursor.
setSeries: (_u: uPlot, seriesIdx: number | null, _opts: Series) => {
selectedSeriesIdx = seriesIdx;
},
// When the cursor is moved, update the tooltip with the current
// series value and position it near the cursor.
setCursor: (u: uPlot) => {
const { left, top, idx } = u.cursor;
if (
idx === null ||
idx === undefined ||
left === null ||
left === undefined ||
top === null ||
top === undefined ||
selectedSeriesIdx === null
) {
return;
}
const ts = u.data[0][idx];
const value = data[selectedSeriesIdx][idx];
const series = u.series[selectedSeriesIdx];
// @ts-expect-error - uPlot doesn't have a field for labels, but we just attach some anyway.
const labels = series.labels;
if (typeof series.stroke !== "function") {
throw new Error("series.stroke is not a function");
}
const color = series.stroke(u, selectedSeriesIdx);
const x = left + boundingLeft;
const y = top + boundingTop;
overlay.innerHTML = `
<div class="date">${formatTimestamp(ts, useLocalTime)}</div>
<div class="series-value">
<span class="detail-swatch" style="background-color: ${color}"></span>
<span>${labels.__name__ ? labels.__name__ + ": " : " "}<strong>${value}</strong></span>
</div>
${formatLabels(labels)}
`.trimEnd();
const virtualEl = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: x,
y: y,
left: x,
right: x,
top: y,
bottom: y,
};
},
};
computePosition(virtualEl, overlay, {
placement: "right-start",
middleware: [offset(5), flip(), shift()],
}).then(({ x, y }) => {
Object.assign(overlay.style, {
top: `${y}px`,
left: `${x}px`,
});
});
},
},
};
};
// A helper function to automatically create enough space for the Y axis
// ticket labels depending on their length.
const autoPadLeft = (
u: uPlot,
values: string[],
axisIdx: number,
cycleNum: number
) => {
const axis = u.axes[axisIdx];
// bail out, force convergence
if (cycleNum > 1) {
// @ts-expect-error - got this from a uPlot demo example, not sure if it's correct.
return axis._size;
}
let axisSize = axis.ticks!.size! + axis.gap!;
// Find longest tick text.
const longestVal = (values ?? []).reduce(
(acc, val) => (val.length > acc.length ? val : acc),
""
);
if (longestVal != "") {
u.ctx.font = axis.font![0];
axisSize += u.ctx.measureText(longestVal).width / devicePixelRatio;
}
return Math.ceil(axisSize);
};
// This filter functions ensures that only points that are disconnected
// from their neighbors are drawn. Otherwise, we just draw line segments
// without dots on them.
//
// Adapted from https://github.com/leeoniya/uPlot/blob/91de800538ee5d6f45f448d98b660a4a658e587b/demos/points.html#L15-L64
const onlyDrawPointsForDisconnectedSamplesFilter = (
u: uPlot,
seriesIdx: number,
show: boolean,
gaps?: null | number[][]
) => {
const filtered = [];
const series = u.series[seriesIdx];
if (!show && gaps && gaps.length) {
const [firstIdx, lastIdx] = series.idxs!;
const xData = u.data[0];
const yData = u.data[seriesIdx];
const firstPos = Math.round(u.valToPos(xData[firstIdx], "x", true));
const lastPos = Math.round(u.valToPos(xData[lastIdx], "x", true));
if (gaps[0][0] === firstPos) {
filtered.push(firstIdx);
}
// show single points between consecutive gaps that share end/start
for (let i = 0; i < gaps.length; i++) {
const thisGap = gaps[i];
const nextGap = gaps[i + 1];
if (nextGap && thisGap[1] === nextGap[0]) {
// approx when data density is > 1pt/px, since gap start/end pixels are rounded
let approxIdx = u.posToIdx(thisGap[1], true);
if (yData[approxIdx] == null) {
// scan left/right alternating to find closest index with non-null value
for (let j = 1; j < 100; j++) {
if (yData[approxIdx + j] != null) {
approxIdx += j;
break;
}
if (yData[approxIdx - j] != null) {
approxIdx -= j;
break;
}
}
}
filtered.push(approxIdx);
}
}
if (gaps[gaps.length - 1][1] === lastPos) {
filtered.push(lastIdx);
}
}
return filtered.length ? filtered : null;
};
export const getUPlotOptions = (
data: AlignedData,
width: number,
result: RangeSamples[],
useLocalTime: boolean,
light: boolean,
onSelectRange: (_start: number, _end: number) => void
): uPlot.Options => ({
width: width - 30,
height: 550,
cursor: {
focus: {
prox: 1000,
},
// Whether dragging on the chart should select a zoom area.
drag: {
x: true,
// Don't zoom into the existing data via uPlot. We want to load new
// (finer-grained) data instead, which we do via a setSelect hook.
setScale: false,
},
},
tzDate: useLocalTime
? undefined
: (ts) => uPlot.tzDate(new Date(ts * 1e3), "Etc/UTC"),
plugins: [tooltipPlugin(useLocalTime, data)],
legend: {
show: true,
live: false,
markers: {
fill: (
_u: uPlot,
seriesIdx: number
): CSSStyleDeclaration["borderColor"] =>
// Because the index here is coming from uPlot, we need to subtract 1. Series 0
// represents the X axis, so we need to skip it.
getSeriesColor(seriesIdx - 1, light),
},
},
// @ts-expect-error - uPlot enum types don't work across module boundaries,
// see https://github.com/leeoniya/uPlot/issues/973.
drawOrder: ["series", "axes"],
focus: {
alpha: 1,
},
axes: [
// X axis (time).
{
space: 80,
labelSize: 20,
stroke: light ? "#333" : "#eee",
ticks: {
stroke: light ? "#00000010" : "#ffffff20",
},
grid: {
show: true,
stroke: light ? "#00000010" : "#ffffff20",
width: 2,
dash: [],
},
values: [
// See https://github.com/leeoniya/uPlot/tree/master/docs#axis--grid-opts and https://github.com/leeoniya/uPlot/issues/83.
//
// We want to achieve 24h-based time formatting instead of the default AM/PM-based time formatting.
// We also want to render dates in an unambiguous format that uses the abbreviated month name instead of a US-centric DD/MM/YYYY format.
//
// The "tick incr" column defines the breakpoint in seconds at which the format changes.
// The "default" column defines the default format for a tick at this breakpoint.
// The "year"/"month"/"day"/"hour"/"min"/"sec" columns define additional values to display for year/month/day/... rollovers occurring around a tick.
// The "mode" column value "1" means that rollover values will be concatenated with the default format (instead of replacing it).
//
// tick incr default year month day hour min sec mode
// prettier-ignore
[3600 * 24 * 365, "{YYYY}", null, null, null, null, null, null, 1],
// prettier-ignore
[3600 * 24 * 28, "{MMM}", "\n{YYYY}", null, null, null, null, null, 1],
// prettier-ignore
[3600 * 24, "{MMM} {D}", "\n{YYYY}", null, null, null, null, null, 1],
// prettier-ignore
[3600, "{HH}:{mm}", "\n{MMM} {D} '{YY}", null, "\n{MMM} {D}", null, null, null, 1],
// prettier-ignore
[60, "{HH}:{mm}", "\n{MMM} {D} '{YY}", null, "\n{MMM} {D}", null, null, null, 1],
// prettier-ignore
[1, "{HH}:{mm}:{ss}", "\n{MMM} {D} '{YY}", null, "\n{MMM} {D}", null, null, null, 1],
// prettier-ignore
[0.001, "{HH}:{mm}:{ss}.{fff}", "\n{MMM} {D} '{YY}", null, "\n{MMM} {D}", null, null, null, 1],
],
},
// Y axis (sample value).
{
values: (_u: uPlot, splits: number[]) => splits.map(formatYAxisTickValue),
ticks: {
stroke: light ? "#00000010" : "#ffffff20",
},
grid: {
show: true,
stroke: light ? "#00000010" : "#ffffff20",
width: 2,
dash: [],
},
labelGap: 8,
labelSize: 8 + 12 + 8,
stroke: light ? "#333" : "#eee",
size: autoPadLeft,
},
],
series: [
{},
...result.map(
(r, idx): uPlot.Series => ({
points: {
filter: onlyDrawPointsForDisconnectedSamplesFilter,
},
label: formatSeries(r.metric),
width: 1.5,
// @ts-expect-error - uPlot doesn't have a field for labels, but we just attach some anyway.
labels: r.metric,
stroke: getSeriesColor(idx, light),
})
),
],
hooks: {
setSelect: [
(self: uPlot) => {
// Disallow sub-second zoom as this cause inconsistenices in the X axis in uPlot.
const leftVal = self.posToVal(self.select.left, "x");
const rightVal = Math.max(
self.posToVal(self.select.left + self.select.width, "x"),
leftVal + 1
);
onSelectRange(leftVal, rightVal);
},
],
},
});
const parseValue = (value: string): null | number => {
const val = parseFloat(value);
// "+Inf", "-Inf", "+Inf" will be parsed into NaN by parseFloat(). They
// can't be graphed, so show them as gaps (null).
return isNaN(val) ? null : val;
};
export const getUPlotData = (
inputData: RangeSamples[],
startTime: number,
endTime: number,
resolution: number
): uPlot.AlignedData => {
const timeData: number[] = [];
for (let t = startTime; t <= endTime; t += resolution) {
timeData.push(t);
}
const values = inputData.map(({ values, histograms }) => {
const data: (number | null)[] = [];
let valuePos = 0;
let histogramPos = 0;
for (let t = startTime; t <= endTime; t += resolution) {
const currentValue = values && values[valuePos];
const currentHistogram = histograms && histograms[histogramPos];
// Allow for floating point inaccuracy.
if (
currentValue &&
values.length > valuePos &&
currentValue[0] < t + resolution / 100
) {
data.push(parseValue(currentValue[1]));
valuePos++;
} else if (
currentHistogram &&
histograms.length > histogramPos &&
currentHistogram[0] < t + resolution / 100
) {
data.push(parseValue(currentHistogram[1].sum));
histogramPos++;
} else {
// Insert nulls for all missing steps.
data.push(null);
}
}
return data;
});
return [timeData, ...values];
};