Move uPlot plugins and glue code to separate file

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-07-23 21:21:34 +02:00
parent 89d18bf681
commit 90cf8b3082
3 changed files with 454 additions and 446 deletions

View file

@ -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 } = {
"&": "&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>${labels["__name__"]}</strong></div>` : ""}
${Object.keys(labels)
.filter((k) => k !== "__name__")
.map(
(k) =>
`<div><strong>${k}</strong>: ${escapeHTML(labels[k])}</div>`
)
.join("")}
</div>`;
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 = `
<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;
};
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<UPlotChartProps> = ({
}
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,

View file

@ -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 } = {
"&": "&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) => {
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 = `
<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 = (
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];
};