Skip to content

Commit ba8ca7c

Browse files
committed
fix: singleton points diappear when connect sparse data is enabled (#9107)
* fix: sngleton points diappear when connect sparse data is enabled * remove stale docstring
1 parent e46adc2 commit ba8ca7c

3 files changed

Lines changed: 118 additions & 14 deletions

File tree

web-common/src/components/time-series-chart/TimeSeriesChart.svelte

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,11 @@
5656
5757
$: primaryRealSegments = primaryBridgeResult.inputSegments;
5858
$: primarySegments = primaryBridgeResult.bridgedSegments;
59-
$: primarySingletons =
60-
primarySeries && !connectNulls
61-
? primarySegments
62-
.filter((s) => s.startIndex === s.endIndex)
63-
.map((s) => s.startIndex)
64-
: [];
59+
$: primarySingletons = primarySeries
60+
? primarySegments
61+
.filter((s) => s.startIndex === s.endIndex)
62+
.map((s) => s.startIndex)
63+
: [];
6564
6665
$: secondarySeries = series.slice(1).map((s) => {
6766
const bridged = bridgeSmallGaps(
@@ -71,11 +70,9 @@
7170
scales.x,
7271
connectNulls,
7372
);
74-
const singletons = !connectNulls
75-
? bridged.bridgedSegments
76-
.filter((seg) => seg.startIndex === seg.endIndex)
77-
.map((seg) => seg.startIndex)
78-
: [];
73+
const singletons = bridged.bridgedSegments
74+
.filter((seg) => seg.startIndex === seg.endIndex)
75+
.map((seg) => seg.startIndex);
7976
return { ...s, bridgedValues: bridged.values, singletons };
8077
});
8178
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { describe, it, expect } from "vitest";
2+
import { computeSegments, bridgeSmallGaps } from "./sparse-data-utils";
3+
4+
const identity = (d: number | null) => d;
5+
const cloneWith = (_d: number | null, v: number): number | null => v;
6+
// 1:1 pixel mapping so gap width = index difference
7+
const xPixel = (i: number) => i;
8+
9+
describe("computeSegments", () => {
10+
it("finds contiguous non-null segments", () => {
11+
const data = [1, null, null, 2, 3, null, 4];
12+
expect(computeSegments(data, identity)).toEqual([
13+
{ startIndex: 0, endIndex: 0 },
14+
{ startIndex: 3, endIndex: 4 },
15+
{ startIndex: 6, endIndex: 6 },
16+
]);
17+
});
18+
19+
it("returns empty for all-null data", () => {
20+
expect(computeSegments([null, null], identity)).toEqual([]);
21+
});
22+
23+
it("returns single segment for all-non-null data", () => {
24+
expect(computeSegments([1, 2, 3], identity)).toEqual([
25+
{ startIndex: 0, endIndex: 2 },
26+
]);
27+
});
28+
});
29+
30+
describe("bridgeSmallGaps", () => {
31+
it("bridges small gaps when connectNulls is true", () => {
32+
// Gap of 2 indices (< default 36px threshold with 1:1 pixel mapping)
33+
const data: (number | null)[] = [10, null, 20];
34+
const result = bridgeSmallGaps(data, identity, cloneWith, xPixel, true);
35+
expect(result.values[1]).toBe(15); // linearly interpolated
36+
expect(result.bridgedSegments).toEqual([{ startIndex: 0, endIndex: 2 }]);
37+
});
38+
39+
it("does not bridge when connectNulls is false", () => {
40+
const data: (number | null)[] = [10, null, 20];
41+
const result = bridgeSmallGaps(data, identity, cloneWith, xPixel, false);
42+
expect(result.values[1]).toBeNull();
43+
expect(result.bridgedSegments).toEqual([
44+
{ startIndex: 0, endIndex: 0 },
45+
{ startIndex: 2, endIndex: 2 },
46+
]);
47+
});
48+
49+
it("does not bridge gaps wider than maxGapPx", () => {
50+
// With 1:1 pixel mapping and maxGapPx=2, a gap of 3 indices won't bridge
51+
const data: (number | null)[] = [10, null, null, 20];
52+
const result = bridgeSmallGaps(data, identity, cloneWith, xPixel, true, 2);
53+
expect(result.values[1]).toBeNull();
54+
expect(result.values[2]).toBeNull();
55+
expect(result.bridgedSegments).toEqual([
56+
{ startIndex: 0, endIndex: 0 },
57+
{ startIndex: 3, endIndex: 3 },
58+
]);
59+
});
60+
61+
describe("singleton detection with connectNulls on", () => {
62+
it("singleton surrounded by wide gaps remains a singleton in bridgedSegments", () => {
63+
// Gap too wide to bridge (> maxGapPx=2): singleton at index 5 stays isolated
64+
const data: (number | null)[] = [
65+
10,
66+
null,
67+
null,
68+
null,
69+
null,
70+
50,
71+
null,
72+
null,
73+
null,
74+
null,
75+
100,
76+
];
77+
const result = bridgeSmallGaps(
78+
data,
79+
identity,
80+
cloneWith,
81+
xPixel,
82+
true,
83+
2,
84+
);
85+
86+
// The singleton at index 5 should still appear as a singleton segment
87+
const singletons = result.bridgedSegments
88+
.filter((s) => s.startIndex === s.endIndex)
89+
.map((s) => s.startIndex);
90+
91+
expect(singletons).toContain(0);
92+
expect(singletons).toContain(5);
93+
expect(singletons).toContain(10);
94+
});
95+
96+
it("singleton gets merged when gap is small enough to bridge", () => {
97+
// Gap of 2 (within maxGapPx=36): singleton should get bridged into neighbors
98+
const data: (number | null)[] = [10, null, 20, null, 30];
99+
const result = bridgeSmallGaps(data, identity, cloneWith, xPixel, true);
100+
101+
// All gaps bridged: single continuous segment
102+
expect(result.bridgedSegments).toEqual([{ startIndex: 0, endIndex: 4 }]);
103+
104+
const singletons = result.bridgedSegments
105+
.filter((s) => s.startIndex === s.endIndex)
106+
.map((s) => s.startIndex);
107+
expect(singletons).toEqual([]);
108+
});
109+
});
110+
});

web-common/src/components/time-series-chart/sparse-data-utils.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,6 @@
1515
* Secondary series have no area fill, so they rely on the line
1616
* generator's `defined` callback and only use `scrub-clip`.
1717
*
18-
* 3. Singletons: When `connectNulls` is off, isolated points (no adjacent
19-
* non-null neighbors) are drawn as circles since there's no line
20-
* segment to render.
2118
*/
2219

2320
/**

0 commit comments

Comments
 (0)