mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-13 00:33:17 +00:00
Compare commits
2 Commits
refactor/r
...
feat/gaps-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd8d236647 | ||
|
|
ec35ef86cf |
@@ -7,6 +7,7 @@ import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFall
|
||||
import uPlot, { AlignedData, Options } from 'uplot';
|
||||
|
||||
import { usePlotContext } from '../context/PlotContext';
|
||||
import { applySpanGapsToAlignedData } from '../utils/dataUtils';
|
||||
import { UPlotChartProps } from './types';
|
||||
|
||||
/**
|
||||
@@ -84,7 +85,13 @@ export default function UPlotChart({
|
||||
} as Options;
|
||||
|
||||
// Create new plot instance
|
||||
const plot = new uPlot(plotConfig, data as AlignedData, containerRef.current);
|
||||
const seriesSpanGaps = config.getSeriesSpanGapsOptions();
|
||||
const preparedData =
|
||||
seriesSpanGaps.length > 0
|
||||
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
|
||||
: (data as AlignedData);
|
||||
|
||||
const plot = new uPlot(plotConfig, preparedData, containerRef.current);
|
||||
|
||||
if (plotRef) {
|
||||
plotRef(plot);
|
||||
@@ -162,7 +169,13 @@ export default function UPlotChart({
|
||||
}
|
||||
// Update data if only data changed
|
||||
else if (!sameData(prevProps, currentProps) && plotInstanceRef.current) {
|
||||
plotInstanceRef.current.setData(data as AlignedData);
|
||||
const seriesSpanGaps = config.getSeriesSpanGapsOptions?.() ?? [];
|
||||
const preparedData =
|
||||
seriesSpanGaps.length > 0
|
||||
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
|
||||
: (data as AlignedData);
|
||||
|
||||
plotInstanceRef.current.setData(preparedData as AlignedData);
|
||||
}
|
||||
|
||||
prevPropsRef.current = currentProps;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
STEP_INTERVAL_MULTIPLIER,
|
||||
} from '../constants';
|
||||
import { calculateWidthBasedOnStepInterval } from '../utils';
|
||||
import { SeriesSpanGapsOption } from '../utils/dataUtils';
|
||||
import {
|
||||
ConfigBuilder,
|
||||
ConfigBuilderProps,
|
||||
@@ -161,6 +162,13 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
this.series.push(new UPlotSeriesBuilder(props));
|
||||
}
|
||||
|
||||
getSeriesSpanGapsOptions(): SeriesSpanGapsOption[] {
|
||||
return this.series.map((s) => {
|
||||
const { spanGaps } = s.props;
|
||||
return { spanGaps };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a hook for extensibility
|
||||
*/
|
||||
|
||||
@@ -205,7 +205,12 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
return {
|
||||
scale: scaleKey,
|
||||
label,
|
||||
spanGaps: typeof spanGaps === 'boolean' ? spanGaps : false,
|
||||
// When spanGaps is numeric, we always disable uPlot's internal
|
||||
// spanGaps behavior and rely on data-prep to implement the
|
||||
// threshold-based null handling. When spanGaps is boolean we
|
||||
// map it directly. When spanGaps is undefined we fall back to
|
||||
// the default of false.
|
||||
spanGaps: typeof spanGaps === 'number' ? false : !!spanGaps,
|
||||
value: (): string => '',
|
||||
pxAlign: true,
|
||||
show,
|
||||
|
||||
@@ -40,6 +40,37 @@ describe('UPlotSeriesBuilder', () => {
|
||||
expect(typeof config.value).toBe('function');
|
||||
});
|
||||
|
||||
it('maps boolean spanGaps directly to uPlot spanGaps', () => {
|
||||
const trueBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
spanGaps: true,
|
||||
}),
|
||||
);
|
||||
const falseBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
spanGaps: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const trueConfig = trueBuilder.getConfig();
|
||||
const falseConfig = falseBuilder.getConfig();
|
||||
|
||||
expect(trueConfig.spanGaps).toBe(true);
|
||||
expect(falseConfig.spanGaps).toBe(false);
|
||||
});
|
||||
|
||||
it('disables uPlot spanGaps when spanGaps is a number', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
spanGaps: 10000,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.spanGaps).toBe(false);
|
||||
});
|
||||
|
||||
it('uses explicit lineColor when provided, regardless of mapping', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
|
||||
@@ -175,7 +175,16 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
pointsFilter?: Series.Points.Filter;
|
||||
pointsBuilder?: Series.Points.Show;
|
||||
show?: boolean;
|
||||
spanGaps?: boolean;
|
||||
/**
|
||||
* Controls how nulls are treated for this series.
|
||||
*
|
||||
* - boolean: mapped directly to uPlot's spanGaps behavior
|
||||
* - number: interpreted as an X-axis threshold (same unit as ref values),
|
||||
* where gaps smaller than this threshold are spanned by
|
||||
* converting short null runs to undefined during data prep
|
||||
* while uPlot's internal spanGaps is kept disabled.
|
||||
*/
|
||||
spanGaps?: boolean | number;
|
||||
fillColor?: string;
|
||||
fillMode?: FillMode;
|
||||
isDarkMode?: boolean;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { isInvalidPlotValue, normalizePlotValue } from '../dataUtils';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
applySpanGapsToAlignedData,
|
||||
isInvalidPlotValue,
|
||||
normalizePlotValue,
|
||||
SeriesSpanGapsOption,
|
||||
} from '../dataUtils';
|
||||
|
||||
describe('dataUtils', () => {
|
||||
describe('isInvalidPlotValue', () => {
|
||||
@@ -59,4 +66,56 @@ describe('dataUtils', () => {
|
||||
expect(normalizePlotValue(42.5)).toBe(42.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyspanGapsToAlignedData', () => {
|
||||
const xs: uPlot.AlignedData[0] = [0, 10, 20, 30];
|
||||
|
||||
it('returns original data when there are no series', () => {
|
||||
const data: uPlot.AlignedData = [xs];
|
||||
const result = applySpanGapsToAlignedData(data, []);
|
||||
|
||||
expect(result).toBe(data);
|
||||
});
|
||||
|
||||
it('leaves data unchanged when spanGaps is undefined', () => {
|
||||
const ys = [1, null, 2, null];
|
||||
const data: uPlot.AlignedData = [xs, ys];
|
||||
const options: SeriesSpanGapsOption[] = [{}];
|
||||
|
||||
const result = applySpanGapsToAlignedData(data, options);
|
||||
|
||||
expect(result[1]).toEqual(ys);
|
||||
});
|
||||
|
||||
it('converts nulls to undefined when spanGaps is true', () => {
|
||||
const ys = [1, null, 2, null];
|
||||
const data: uPlot.AlignedData = [xs, ys];
|
||||
const options: SeriesSpanGapsOption[] = [{ spanGaps: true }];
|
||||
|
||||
const result = applySpanGapsToAlignedData(data, options);
|
||||
|
||||
expect(result[1]).toEqual([1, undefined, 2, undefined]);
|
||||
});
|
||||
|
||||
it('leaves data unchanged when spanGaps is false', () => {
|
||||
const ys = [1, null, 2, null];
|
||||
const data: uPlot.AlignedData = [xs, ys];
|
||||
const options: SeriesSpanGapsOption[] = [{ spanGaps: false }];
|
||||
|
||||
const result = applySpanGapsToAlignedData(data, options);
|
||||
|
||||
expect(result[1]).toEqual(ys);
|
||||
});
|
||||
|
||||
it('runs threshold-based null handling when spanGaps is numeric', () => {
|
||||
const ys = [1, null, null, 2];
|
||||
const data: uPlot.AlignedData = [xs, ys];
|
||||
const options: SeriesSpanGapsOption[] = [{ spanGaps: 25 }];
|
||||
|
||||
const result = applySpanGapsToAlignedData(data, options);
|
||||
|
||||
// gap between x=0 and x=30 is 30, so with threshold 25 it should stay null
|
||||
expect(result[1]).toEqual([1, null, null, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { nullToUndefThreshold } from '../nullHandling';
|
||||
|
||||
describe('nullToUndefThreshold', () => {
|
||||
it('converts short null gaps to undefined', () => {
|
||||
const xs = [0, 10, 20, 30, 40];
|
||||
const ys: Array<number | null | undefined> = [1, null, null, 2, 3];
|
||||
|
||||
const result = nullToUndefThreshold(xs, ys, 25);
|
||||
|
||||
expect(result).toEqual([1, undefined, undefined, 2, 3]);
|
||||
});
|
||||
|
||||
it('keeps long null gaps as null', () => {
|
||||
const xs = [0, 10, 100, 200];
|
||||
const ys: Array<number | null | undefined> = [1, null, null, 2];
|
||||
|
||||
const result = nullToUndefThreshold(xs, ys, 50);
|
||||
|
||||
expect(result).toEqual([1, null, null, 2]);
|
||||
});
|
||||
|
||||
it('leaves leading and trailing nulls as-is', () => {
|
||||
const xs = [0, 10, 20, 30];
|
||||
const ys: Array<number | null | undefined> = [null, null, 1, null];
|
||||
|
||||
const result = nullToUndefThreshold(xs, ys, 50);
|
||||
|
||||
expect(result).toEqual([null, null, 1, null]);
|
||||
});
|
||||
|
||||
it('is a no-op when there are no nulls', () => {
|
||||
const xs = [0, 10, 20];
|
||||
const ys: Array<number | null | undefined> = [1, 2, 3];
|
||||
|
||||
const result = nullToUndefThreshold(xs, ys, 50);
|
||||
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { nullToUndefThreshold } from './nullHandling';
|
||||
|
||||
/**
|
||||
* Checks if a value is invalid for plotting
|
||||
*
|
||||
@@ -51,3 +53,52 @@ export function normalizePlotValue(
|
||||
// Already a valid number
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export interface SeriesSpanGapsOption {
|
||||
spanGaps?: boolean | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply per-series spanGaps (boolean | threshold) handling to an aligned dataset.
|
||||
*
|
||||
* The input data is expected to be of the form:
|
||||
* [xValues, series1Values, series2Values, ...]
|
||||
*/
|
||||
export function applySpanGapsToAlignedData(
|
||||
data: uPlot.AlignedData,
|
||||
seriesOptions: SeriesSpanGapsOption[],
|
||||
): uPlot.AlignedData {
|
||||
const [xValues, ...seriesValues] = data;
|
||||
|
||||
if (!Array.isArray(xValues) || seriesValues.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const transformedSeries = seriesValues.map((ys, idx) => {
|
||||
const { spanGaps } = seriesOptions[idx] || {};
|
||||
|
||||
if (spanGaps === undefined) {
|
||||
return ys;
|
||||
}
|
||||
|
||||
if (typeof spanGaps === 'boolean') {
|
||||
if (!spanGaps) {
|
||||
return ys;
|
||||
}
|
||||
|
||||
// spanGaps === true -> treat nulls as soft gaps (convert to undefined)
|
||||
return (ys as Array<number | null | undefined>).map((v) =>
|
||||
v === null ? undefined : v,
|
||||
) as uPlot.AlignedData[0];
|
||||
}
|
||||
|
||||
// Numeric spanGaps: threshold-based null handling
|
||||
return nullToUndefThreshold(
|
||||
xValues as uPlot.AlignedData[0],
|
||||
ys as Array<number | null | undefined>,
|
||||
spanGaps,
|
||||
);
|
||||
});
|
||||
|
||||
return [xValues, ...transformedSeries] as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
73
frontend/src/lib/uPlotV2/utils/nullHandling.ts
Normal file
73
frontend/src/lib/uPlotV2/utils/nullHandling.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
/**
|
||||
* Convert short runs of nulls between two defined points into undefined so that
|
||||
* uPlot treats them as "no point" but keeps the line continuous for gaps
|
||||
* smaller than the provided time threshold.
|
||||
*/
|
||||
|
||||
type AlignedXValues = AlignedData[0];
|
||||
type YValues = Array<number | null | undefined>;
|
||||
|
||||
interface GapArgs {
|
||||
xValues: AlignedXValues;
|
||||
yValues: YValues;
|
||||
maxGapThreshold: number;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
function spanShortGap(args: GapArgs): void {
|
||||
const { xValues, yValues, maxGapThreshold, startIndex, endIndex } = args;
|
||||
|
||||
const gapSize = xValues[endIndex] - xValues[startIndex];
|
||||
if (gapSize >= maxGapThreshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = startIndex + 1; index < endIndex; index += 1) {
|
||||
if (yValues[index] === null || yValues[index] === undefined) {
|
||||
// Use undefined to indicate "no sample" so the line can span
|
||||
yValues[index] = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function nullToUndefThreshold(
|
||||
xValues: AlignedXValues,
|
||||
yValues: YValues,
|
||||
maxGapThreshold: number,
|
||||
): YValues {
|
||||
if (!Array.isArray(xValues) || !Array.isArray(yValues)) {
|
||||
return yValues;
|
||||
}
|
||||
|
||||
const length = Math.min(xValues.length, yValues.length);
|
||||
if (length === 0 || maxGapThreshold <= 0) {
|
||||
return yValues;
|
||||
}
|
||||
|
||||
let previousDefinedIndex: number | null = null;
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const value = yValues[index];
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previousDefinedIndex !== null && index - previousDefinedIndex > 1) {
|
||||
spanShortGap({
|
||||
xValues,
|
||||
yValues,
|
||||
maxGapThreshold,
|
||||
startIndex: previousDefinedIndex,
|
||||
endIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
previousDefinedIndex = index;
|
||||
}
|
||||
|
||||
return yValues;
|
||||
}
|
||||
Reference in New Issue
Block a user