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]; +};