Compare commits

...

2 Commits

9 changed files with 293 additions and 5 deletions

View File

@@ -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;

View File

@@ -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
*/

View File

@@ -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,

View File

@@ -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({

View File

@@ -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;

View File

@@ -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]);
});
});
});

View File

@@ -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]);
});
});

View File

@@ -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;
}

View 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;
}