Skip to content

Commit 66f0888

Browse files
rahmanunvergjulivan
authored andcommitted
feat(charts-web): add aggregation tooltip
1 parent e9cabbf commit 66f0888

File tree

2 files changed

+278
-19
lines changed

2 files changed

+278
-19
lines changed
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { aggregateDataPoints, AggregationType } from "../aggregations";
2+
import { PlotChartDataPoints } from "../../hooks/usePlotChartDataSeries";
3+
4+
describe("aggregateDataPoints", () => {
5+
const createMockDataPoints = (
6+
x: Array<string | number | Date | null>,
7+
y: Array<number | null>,
8+
hovertext?: string[]
9+
): PlotChartDataPoints => ({
10+
x,
11+
y,
12+
hovertext,
13+
hoverinfo: hovertext ? "text" : "none",
14+
dataSourceItems: []
15+
});
16+
17+
describe("when aggregationType is 'none'", () => {
18+
it("should return the original data points unchanged", () => {
19+
const points = createMockDataPoints(["A", "B", "C"], [1, 2, 3], ["hover1", "hover2", "hover3"]);
20+
const result = aggregateDataPoints("none", points);
21+
expect(result).toBe(points);
22+
});
23+
});
24+
25+
describe("when aggregating data points", () => {
26+
describe("with string x values", () => {
27+
it("should aggregate points with the same x value using sum", () => {
28+
const points = createMockDataPoints(
29+
["A", "B", "A", "C", "B"],
30+
[10, 20, 15, 30, 25],
31+
["hover1", "hover2", "hover3", "hover4", "hover5"]
32+
);
33+
const result = aggregateDataPoints("sum", points);
34+
35+
expect(result.x).toEqual(["A", "B", "C"]);
36+
expect(result.y).toEqual([25, 45, 30]);
37+
expect(result.hovertext).toEqual(["SUM: 25 (2 values)", "SUM: 45 (2 values)", "hover4"]);
38+
});
39+
40+
it("should preserve original hover text for single values", () => {
41+
const points = createMockDataPoints(
42+
["A", "B", "C"],
43+
[10, 20, 30],
44+
["custom hover A", "custom hover B", "custom hover C"]
45+
);
46+
const result = aggregateDataPoints("avg", points);
47+
48+
expect(result.x).toEqual(["A", "B", "C"]);
49+
expect(result.y).toEqual([10, 20, 30]);
50+
expect(result.hovertext).toEqual(["custom hover A", "custom hover B", "custom hover C"]);
51+
});
52+
});
53+
54+
describe("with number x values", () => {
55+
it("should convert numbers to strings for grouping", () => {
56+
const points = createMockDataPoints([1, 2, 1, 3], [10, 20, 15, 30]);
57+
const result = aggregateDataPoints("sum", points);
58+
59+
expect(result.x).toEqual(["1", "2", "3"]);
60+
expect(result.y).toEqual([25, 20, 30]);
61+
});
62+
});
63+
64+
describe("with Date x values", () => {
65+
it("should convert dates to ISO strings for grouping", () => {
66+
const date1 = new Date("2023-01-01");
67+
const date2 = new Date("2023-01-02");
68+
const points = createMockDataPoints([date1, date2, date1], [10, 20, 15]);
69+
const result = aggregateDataPoints("sum", points);
70+
71+
expect(result.x).toEqual([date1.toISOString(), date2.toISOString()]);
72+
expect(result.y).toEqual([25, 20]);
73+
});
74+
});
75+
76+
describe("with null values", () => {
77+
it("should handle null x values by grouping them together", () => {
78+
const points = createMockDataPoints([null, "A", null, "B"], [10, 20, 15, 30]);
79+
const result = aggregateDataPoints("sum", points);
80+
81+
expect(result.x).toEqual(["", "A", "B"]);
82+
expect(result.y).toEqual([25, 20, 30]);
83+
});
84+
85+
it("should skip null y values", () => {
86+
const points = createMockDataPoints(["A", "A", "B"], [10, null, 20]);
87+
const result = aggregateDataPoints("sum", points);
88+
89+
expect(result.x).toEqual(["A", "B"]);
90+
expect(result.y).toEqual([10, 20]);
91+
});
92+
});
93+
94+
describe("without hover text", () => {
95+
it("should generate aggregation hovertext if items are grouped, even if original hovertext was undefined", () => {
96+
const points = createMockDataPoints(["A", "A", "B"], [10, 15, 20]); // No hovertext for B initially
97+
const result = aggregateDataPoints("sum", points);
98+
99+
expect(result.x).toEqual(["A", "B"]);
100+
expect(result.y).toEqual([25, 20]);
101+
// For A (grouped): "SUM: 25 (2 values)"
102+
// For B (single, no original hovertext): undefined
103+
expect(result.hovertext).toEqual(["SUM: 25 (2 values)", undefined]);
104+
expect(result.hoverinfo).toBe("text"); // Because "SUM: 25 (2 values)" is present
105+
});
106+
107+
it("should be undefined and hoverinfo 'none' if no original or generated hovertext for single points", () => {
108+
const points = createMockDataPoints(["A", "B", "C"], [10, 20, 30]); // No hovertext provided
109+
// 'sum' on single, distinct points: yVals.length will be 1 for each.
110+
// hoverTexts[0] will be undefined for each as original hovertext was undefined.
111+
const result = aggregateDataPoints("sum", points);
112+
113+
expect(result.x).toEqual(["A", "B", "C"]);
114+
expect(result.y).toEqual([10, 20, 30]);
115+
expect(result.hovertext).toBeUndefined(); // All points are single, no original hovertext
116+
expect(result.hoverinfo).toBe("none");
117+
});
118+
});
119+
});
120+
121+
describe("aggregation types", () => {
122+
const testData = [5, 10, 15, 20];
123+
const points = createMockDataPoints(["A", "A", "A", "A"], testData);
124+
125+
it.each([
126+
["count", 4],
127+
["sum", 50],
128+
["avg", 12.5],
129+
["min", 5],
130+
["max", 20],
131+
["first", 5],
132+
["last", 20]
133+
] as Array<[AggregationType, number]>)("should correctly compute aggregation", (aggregationType, expected) => {
134+
const result = aggregateDataPoints(aggregationType, points);
135+
expect(result.y).toEqual([expected]);
136+
expect(result.hovertext).toEqual([`${aggregationType.toUpperCase()}: ${expected} (4 values)`]);
137+
});
138+
139+
describe("median aggregation", () => {
140+
it("should compute median for odd number of values", () => {
141+
const oddPoints = createMockDataPoints(["A", "A", "A"], [1, 3, 5]);
142+
const result = aggregateDataPoints("median", oddPoints);
143+
expect(result.y).toEqual([3]);
144+
});
145+
146+
it("should compute median for even number of values", () => {
147+
const evenPoints = createMockDataPoints(["A", "A", "A", "A"], [1, 2, 3, 4]);
148+
const result = aggregateDataPoints("median", evenPoints);
149+
expect(result.y).toEqual([2.5]);
150+
});
151+
});
152+
153+
describe("mode aggregation", () => {
154+
it("should find the most frequent value", () => {
155+
const modePoints = createMockDataPoints(["A", "A", "A", "A", "A"], [1, 2, 2, 3, 2]);
156+
const result = aggregateDataPoints("mode", modePoints);
157+
expect(result.y).toEqual([2]);
158+
});
159+
160+
it("should return first value when all values have same frequency", () => {
161+
const modePoints = createMockDataPoints(["A", "A", "A"], [1, 2, 3]);
162+
const result = aggregateDataPoints("mode", modePoints);
163+
expect(result.y).toEqual([1]);
164+
});
165+
});
166+
});
167+
168+
describe("edge cases", () => {
169+
it("should handle empty data", () => {
170+
const points = createMockDataPoints([], []);
171+
const result = aggregateDataPoints("sum", points);
172+
173+
expect(result.x).toEqual([]);
174+
expect(result.y).toEqual([]);
175+
expect(result.hovertext).toBeUndefined();
176+
});
177+
178+
it("should handle data with all null y values", () => {
179+
const points = createMockDataPoints(["A", "B"], [null, null]);
180+
const result = aggregateDataPoints("sum", points);
181+
182+
expect(result.x).toEqual([]);
183+
expect(result.y).toEqual([]);
184+
expect(result.hovertext).toBeUndefined();
185+
});
186+
187+
it("should return NaN for empty groups in aggregation functions", () => {
188+
const points = createMockDataPoints(["A"], []);
189+
const result = aggregateDataPoints("sum", points);
190+
191+
expect(result.x).toEqual([]);
192+
expect(result.y).toEqual([]);
193+
});
194+
195+
it("should handle mixed hover text presence", () => {
196+
const points = createMockDataPoints(["A", "A", "B", "B"], [10, 15, 20, 25]);
197+
points.hovertext = ["hover1", undefined as any, "hover3", "hover4"];
198+
const result = aggregateDataPoints("sum", points);
199+
200+
expect(result.hovertext).toEqual(["SUM: 25 (2 values)", "SUM: 45 (2 values)"]);
201+
});
202+
203+
it("should preserve other properties and original data for non-aggregated points", () => {
204+
const basePoints = createMockDataPoints(["A", "B"], [10, 20], ["hoverA", "hoverB"]);
205+
const pointsWithCustomProp = {
206+
...basePoints,
207+
customProperty: "should be preserved"
208+
};
209+
210+
// Using "sum" but on distinct points, so no actual aggregation occurs for yVals > 1
211+
const result = aggregateDataPoints("sum", pointsWithCustomProp);
212+
213+
expect((result as any).customProperty).toBe("should be preserved");
214+
expect(result.x).toEqual(["A", "B"]);
215+
expect(result.y).toEqual([10, 20]); // Should remain original
216+
expect(result.hovertext).toEqual(["hoverA", "hoverB"]); // Should remain original
217+
expect(result.hoverinfo).toBe("text"); // Original hovertext exists
218+
});
219+
});
220+
221+
describe("hover text formatting", () => {
222+
it("should format hover text with proper capitalization", () => {
223+
const points = createMockDataPoints(["A", "A"], [10, 15], ["hover1", "hover2"]);
224+
const result = aggregateDataPoints("avg", points);
225+
226+
expect(result.hovertext).toEqual(["AVG: 12.5 (2 values)"]);
227+
});
228+
229+
it("should handle decimal values in hover text", () => {
230+
const points = createMockDataPoints(["A", "A"], [1, 2], ["hover1", "hover2"]);
231+
const result = aggregateDataPoints("avg", points);
232+
233+
expect(result.hovertext).toEqual(["AVG: 1.5 (2 values)"]);
234+
});
235+
236+
it("should show count in hover text", () => {
237+
const points = createMockDataPoints(["A", "A", "A"], [1, 2, 3], ["h1", "h2", "h3"]);
238+
const result = aggregateDataPoints("sum", points);
239+
240+
expect(result.hovertext).toEqual(["SUM: 6 (3 values)"]);
241+
});
242+
});
243+
});

packages/shared/charts/src/utils/aggregations.ts

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@ import { PlotChartDataPoints } from "../hooks/usePlotChartDataSeries";
33

44
export type AggregationType = "none" | "count" | "sum" | "avg" | "min" | "max" | "median" | "mode" | "first" | "last";
55

6+
function getXValueKey(xVal: Datum | null): string {
7+
if (xVal == null) {
8+
return "";
9+
}
10+
if (typeof xVal === "string" || typeof xVal === "number") {
11+
return xVal.toString();
12+
}
13+
// Assuming it's a Date object if not string, number, or null
14+
return (xVal as Date).toISOString();
15+
}
16+
617
export function aggregateDataPoints(
718
aggregationType: AggregationType,
819
points: PlotChartDataPoints
@@ -19,12 +30,7 @@ export function aggregateDataPoints(
1930
const originalHover = points.hovertext;
2031

2132
points.x.forEach((xVal, i) => {
22-
const key =
23-
xVal == null
24-
? ""
25-
: typeof xVal === "string" || typeof xVal === "number"
26-
? xVal.toString()
27-
: (xVal as Date).toISOString();
33+
const key = getXValueKey(xVal);
2834
const yVal = points.y[i] as number | undefined;
2935
if (yVal == null) {
3036
return; // skip nulls
@@ -46,8 +52,15 @@ export function aggregateDataPoints(
4652

4753
for (const [key, { yVals, hoverTexts }] of groups) {
4854
aggregatedX.push(key);
49-
aggregatedY.push(computeAggregate(yVals, aggregationType));
50-
aggregatedHover.push(hoverTexts.length > 0 ? hoverTexts.join("; ") : undefined);
55+
const aggregatedValue = computeAggregate(yVals, aggregationType);
56+
aggregatedY.push(aggregatedValue);
57+
58+
const aggregatedHoverText =
59+
yVals.length > 1
60+
? `${aggregationType.toUpperCase()}: ${aggregatedValue} (${yVals.length} values)`
61+
: hoverTexts[0];
62+
63+
aggregatedHover.push(aggregatedHoverText);
5164
}
5265

5366
const hasText = aggregatedHover.some(text => text !== undefined && text !== "");
@@ -80,25 +93,28 @@ function computeAggregate(arr: number[], type: AggregationType): number {
8093
case "last":
8194
return arr[arr.length - 1];
8295
case "median": {
83-
const sorted = arr.slice().sort((a, b) => a - b);
96+
const sorted = [...arr].sort((a, b) => a - b);
8497
const m = Math.floor(sorted.length / 2);
8598
return sorted.length % 2 === 1 ? sorted[m] : (sorted[m - 1] + sorted[m]) / 2;
8699
}
87100
case "mode": {
101+
if (arr.length === 0) return NaN;
88102
const freq = new Map<number, number>();
89-
let best = arr[0];
90-
let bestCount = 0;
91-
arr.forEach(n => {
92-
const c = (freq.get(n) || 0) + 1;
93-
freq.set(n, c);
94-
if (c > bestCount) {
95-
best = n;
96-
bestCount = c;
103+
let maxFreq = 0;
104+
let mode = arr[0];
105+
106+
for (const n of arr) {
107+
const count = (freq.get(n) || 0) + 1;
108+
freq.set(n, count);
109+
if (count > maxFreq) {
110+
maxFreq = count;
111+
mode = n;
97112
}
98-
});
99-
return best;
113+
}
114+
return mode;
100115
}
101116
default:
117+
console.warn(`Unknown aggregation type: ${type}`);
102118
return NaN;
103119
}
104120
}

0 commit comments

Comments
 (0)