diff --git a/web/ui/mantine-ui/src/pages/query/UPlotChart.tsx b/web/ui/mantine-ui/src/pages/query/UPlotChart.tsx
index 7274046b82..46092335f0 100644
--- a/web/ui/mantine-ui/src/pages/query/UPlotChart.tsx
+++ b/web/ui/mantine-ui/src/pages/query/UPlotChart.tsx
@@ -2,454 +2,14 @@ import { FC, useEffect, useState } from "react";
import { RangeSamples } from "../../api/responseTypes/query";
import classes from "./Graph.module.css";
import { GraphDisplayMode } from "../../state/queryPageSlice";
-import { formatSeries } from "../../lib/formatSeries";
-import uPlot, { Series } from "uplot";
+import uPlot from "uplot";
import UplotReact from "uplot-react";
-import "uplot/dist/uPlot.min.css";
-import "./uplot.css";
-import { formatTimestamp } from "../../lib/formatTime";
-import { computePosition, shift, flip, offset } from "@floating-ui/dom";
-import { getSeriesColor } from "./ColorPool";
import { useSettings } from "../../state/settingsSlice";
import { useComputedColorScheme } from "@mantine/core";
-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 } = {
- "&": "&",
- "<": "<",
- ">": ">",
- '"': """,
- "'": "'",
- "/": "/",
- };
-
- return String(str).replace(/[&<>"'/]/g, function (s) {
- return entityMap[s];
- });
-};
-
-const formatLabels = (labels: { [key: string]: string }): string => `
-
- ${Object.keys(labels).length === 0 ? '
no labels
' : ""}
- ${labels["__name__"] ? `
${labels["__name__"]}
` : ""}
- ${Object.keys(labels)
- .filter((k) => k !== "__name__")
- .map(
- (k) =>
- `
${k}: ${escapeHTML(labels[k])}
`
- )
- .join("")}
-
`;
-
-const tooltipPlugin = (useLocalTime: boolean) => {
- 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 = u.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.style.borderColor = color;
-
- // TODO: Use local time in formatTimestamp!
- overlay.innerHTML = `
- ${formatTimestamp(ts, useLocalTime)}
-
-
- ${labels.__name__ ? labels.__name__ + ": " : " "}${value}
-
- ${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;
-};
-
-const getOptions = (
- 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)],
- 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).
- {
- labelSize: 20,
- stroke: light ? "#333" : "#eee",
- ticks: {
- stroke: light ? "#00000010" : "#ffffff20",
- },
- grid: {
- show: false,
- stroke: light ? "#eee" : "#333",
- width: 2,
- dash: [],
- },
- },
- // 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) => {
- onSelectRange(
- self.posToVal(self.select.left, "x"),
- self.posToVal(self.select.left + self.select.width, "x")
- );
- },
- ],
- },
-});
-
-const normalizeData = (
- 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 }) => {
- // Insert nulls for all missing steps.
- const data: (number | null)[] = [];
- let valuePos = 0;
- let histogramPos = 0;
-
- for (let t = startTime; t <= endTime; t += resolution) {
- // Allow for floating point inaccuracy.
- const currentValue = values && values[valuePos];
- const currentHistogram = histograms && histograms[histogramPos];
- 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 {
- data.push(null);
- }
- }
- return data;
- });
-
- return [timeData, ...values];
-};
-
-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;
-};
+import "uplot/dist/uPlot.min.css";
+import "./uplot.css";
+import { getUPlotData, getUPlotOptions } from "./uPlotChartHelpers";
export interface UPlotChartRange {
startTime: number;
@@ -482,11 +42,17 @@ const UPlotChart: FC = ({
}
setOptions(
- getOptions(width, data, useLocalTime, theme === "light", onSelectRange)
+ getUPlotOptions(
+ width,
+ data,
+ useLocalTime,
+ theme === "light",
+ onSelectRange
+ )
);
}, [width, data, useLocalTime, theme, onSelectRange]);
- const seriesData: uPlot.AlignedData = normalizeData(
+ const seriesData: uPlot.AlignedData = getUPlotData(
data,
startTime,
endTime,
diff --git a/web/ui/mantine-ui/src/pages/query/ColorPool.ts b/web/ui/mantine-ui/src/pages/query/colorPool.ts
similarity index 100%
rename from web/ui/mantine-ui/src/pages/query/ColorPool.ts
rename to web/ui/mantine-ui/src/pages/query/colorPool.ts
diff --git a/web/ui/mantine-ui/src/pages/query/uPlotChartHelpers.ts b/web/ui/mantine-ui/src/pages/query/uPlotChartHelpers.ts
new file mode 100644
index 0000000000..d612bcd084
--- /dev/null
+++ b/web/ui/mantine-ui/src/pages/query/uPlotChartHelpers.ts
@@ -0,0 +1,442 @@
+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, { 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 } = {
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+ "/": "/",
+ };
+
+ return String(str).replace(/[&<>"'/]/g, function (s) {
+ return entityMap[s];
+ });
+};
+
+const formatLabels = (labels: { [key: string]: string }): string => `
+
+ ${Object.keys(labels).length === 0 ? '
no labels
' : ""}
+ ${labels["__name__"] ? `
${escapeHTML(labels["__name__"])}
` : ""}
+ ${Object.keys(labels)
+ .filter((k) => k !== "__name__")
+ .map(
+ (k) =>
+ `
${escapeHTML(k)}: ${escapeHTML(labels[k])}
`
+ )
+ .join("")}
+
`;
+
+const tooltipPlugin = (useLocalTime: boolean) => {
+ 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 = u.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;
+
+ // TODO: Use local time in formatTimestamp!
+ overlay.innerHTML = `
+ ${formatTimestamp(ts, useLocalTime)}
+
+
+ ${labels.__name__ ? labels.__name__ + ": " : " "}${value}
+
+ ${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 = (
+ 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)],
+ 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).
+ {
+ labelSize: 20,
+ stroke: light ? "#333" : "#eee",
+ ticks: {
+ stroke: light ? "#00000010" : "#ffffff20",
+ },
+ grid: {
+ show: false,
+ stroke: light ? "#eee" : "#333",
+ width: 2,
+ dash: [],
+ },
+ },
+ // 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) => {
+ onSelectRange(
+ self.posToVal(self.select.left, "x"),
+ self.posToVal(self.select.left + self.select.width, "x")
+ );
+ },
+ ],
+ },
+});
+
+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 }) => {
+ // Insert nulls for all missing steps.
+ const data: (number | null)[] = [];
+ let valuePos = 0;
+ let histogramPos = 0;
+
+ for (let t = startTime; t <= endTime; t += resolution) {
+ // Allow for floating point inaccuracy.
+ const currentValue = values && values[valuePos];
+ const currentHistogram = histograms && histograms[histogramPos];
+ 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 {
+ data.push(null);
+ }
+ }
+ return data;
+ });
+
+ return [timeData, ...values];
+};