Skip to content

Commit 0c3607b

Browse files
upcoming: [DI-29393] : Utils and Hooks set up for supporting zoom in inside the charts in CloudPulse graphs (#13308)
* [DI-29167] - Initial changes for widget zoom in feature, Utility setup * [DI-29167] - Add understanding comments * Added changeset: Utils and Hooks set up for supporting zoom in inside the charts in `CloudPulse metrics graphs` * [DI-29167] - Address PR comments
1 parent 0dc16c7 commit 0c3607b

File tree

5 files changed

+645
-0
lines changed

5 files changed

+645
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Upcoming Features
3+
---
4+
5+
Utils and Hooks set up for supporting zoom in inside the charts in `CloudPulse metrics graphs` ([#13308](https://github.com/linode/manager/pull/13308))
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import {
4+
computeLegendRowsBasedOnData,
5+
computeZoomedInData,
6+
getMetricsFromDimensionData,
7+
} from './CloudPulseZoomInUtils';
8+
import { formatToolTip } from './unitConversion';
9+
10+
import type { ZoomState } from '../Widget/components/useZoomController';
11+
import type { DataSet } from 'src/components/AreaChart/AreaChart';
12+
import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay';
13+
14+
describe('computeZoomedInData', () => {
15+
const mockData: DataSet[] = [
16+
{ timestamp: 1000, metric1: 10, metric2: 20 },
17+
{ timestamp: 2000, metric1: 15, metric2: 25 },
18+
{ timestamp: 3000, metric1: 20, metric2: 30 },
19+
{ timestamp: 4000, metric1: 25, metric2: 35 },
20+
{ timestamp: 5000, metric1: 30, metric2: 40 },
21+
];
22+
23+
it('should return original data when zoom is at default (dataMin/dataMax)', () => {
24+
const zoom: ZoomState = { left: 'dataMin', right: 'dataMax' };
25+
const result = computeZoomedInData({ data: mockData, zoom });
26+
expect(result).toBe(mockData);
27+
});
28+
29+
it('should return empty array when data is empty', () => {
30+
const zoom: ZoomState = { left: 1000, right: 3000 };
31+
const result = computeZoomedInData({ data: [], zoom });
32+
expect(result).toEqual([]);
33+
});
34+
35+
it('should filter data based on zoom range', () => {
36+
const zoom: ZoomState = { left: 2000, right: 4000 };
37+
const result = computeZoomedInData({ data: mockData, zoom });
38+
expect(result).toHaveLength(3);
39+
expect(result[0].timestamp).toBe(2000);
40+
expect(result[2].timestamp).toBe(4000);
41+
});
42+
43+
it('should handle zoom with dataMin as left', () => {
44+
const zoom: ZoomState = { left: 'dataMin', right: 3000 };
45+
const result = computeZoomedInData({ data: mockData, zoom });
46+
expect(result).toHaveLength(3);
47+
expect(result[0].timestamp).toBe(1000);
48+
expect(result[2].timestamp).toBe(3000);
49+
});
50+
51+
it('should handle zoom with dataMax as right', () => {
52+
const zoom: ZoomState = { left: 3000, right: 'dataMax' };
53+
const result = computeZoomedInData({ data: mockData, zoom });
54+
expect(result).toHaveLength(3);
55+
expect(result[0].timestamp).toBe(3000);
56+
expect(result[2].timestamp).toBe(5000);
57+
});
58+
59+
it('should return empty array when left is greater than right', () => {
60+
const zoom: ZoomState = { left: 4000, right: 2000 };
61+
const result = computeZoomedInData({ data: mockData, zoom });
62+
expect(result).toEqual([]);
63+
});
64+
});
65+
66+
describe('getMetricsFromDimensionData', () => {
67+
it('should return zeros for empty data', () => {
68+
const result = getMetricsFromDimensionData([]);
69+
expect(result).toEqual({
70+
average: 0,
71+
last: 0,
72+
length: 0,
73+
max: 0,
74+
total: 0,
75+
});
76+
});
77+
78+
it('should calculate metrics correctly for valid data', () => {
79+
const data = [10, 20, 30, 40, 50];
80+
const result = getMetricsFromDimensionData(data);
81+
expect(result).toEqual({
82+
average: 30,
83+
last: 50,
84+
length: 5,
85+
max: 50,
86+
total: 150,
87+
});
88+
});
89+
90+
it('should handle single value', () => {
91+
const data = [42];
92+
const result = getMetricsFromDimensionData(data);
93+
expect(result).toEqual({
94+
average: 42,
95+
last: 42,
96+
length: 1,
97+
max: 42,
98+
total: 42,
99+
});
100+
});
101+
102+
it('should ignore NaN values', () => {
103+
const data = [10, NaN, 30, NaN, 50];
104+
const result = getMetricsFromDimensionData(data);
105+
expect(result.total).toBe(90);
106+
expect(result.max).toBe(50);
107+
});
108+
it('should return 0 as last when last value is NaN', () => {
109+
const data = [10, 20, NaN];
110+
const result = getMetricsFromDimensionData(data);
111+
112+
expect(result.last).toBe(0);
113+
});
114+
});
115+
116+
describe('computeLegendRowsBasedOnData', () => {
117+
const mockData: DataSet[] = [
118+
{ timestamp: 1000, cpu: 10, memory: 20 },
119+
{ timestamp: 2000, cpu: 15, memory: 25 },
120+
{ timestamp: 3000, cpu: 20, memory: 30 },
121+
];
122+
const failMessage = 'Result should not be undefined';
123+
124+
const mockLegendRows: MetricsDisplayRow[] = [
125+
{
126+
legendTitle: 'cpu',
127+
legendColor: 'blue',
128+
data: { average: 0, last: 0, length: 0, max: 0, total: 0 },
129+
format: (value: number) => formatToolTip(value, 'MB'),
130+
},
131+
{
132+
legendTitle: 'memory',
133+
legendColor: 'red',
134+
data: { average: 0, last: 0, length: 0, max: 0, total: 0 },
135+
format: (value: number) => formatToolTip(value, 'MB'),
136+
},
137+
];
138+
139+
it('should return undefined when legendRows is undefined', () => {
140+
const zoom: ZoomState = { left: 'dataMin', right: 'dataMax' };
141+
const result = computeLegendRowsBasedOnData({
142+
zoom,
143+
data: mockData,
144+
});
145+
expect(result).toBeUndefined();
146+
});
147+
148+
it('should return undefined when data is empty', () => {
149+
const zoom: ZoomState = { left: 'dataMin', right: 'dataMax' };
150+
const result = computeLegendRowsBasedOnData({
151+
zoom,
152+
data: [],
153+
});
154+
expect(result).toBeUndefined();
155+
});
156+
157+
it('should return original rows when not zoomed', () => {
158+
const zoom: ZoomState = { left: 'dataMin', right: 'dataMax' };
159+
const result = computeLegendRowsBasedOnData({
160+
zoom,
161+
data: mockData,
162+
legendRows: mockLegendRows,
163+
});
164+
expect(result).toEqual(mockLegendRows);
165+
});
166+
167+
it('should compute metrics based on zoomed data', () => {
168+
const zoom: ZoomState = { left: 2000, right: 3000 };
169+
const result = computeLegendRowsBasedOnData({
170+
zoom,
171+
data: mockData,
172+
legendRows: mockLegendRows,
173+
});
174+
175+
if (result) {
176+
expect(result).toHaveLength(2);
177+
expect(result[0].legendTitle).toBe('cpu');
178+
expect(result[0].data.total).toBe(35);
179+
expect(result[0].data.max).toBe(20);
180+
expect(result[0].data.last).toBe(20);
181+
expect(result[1].legendTitle).toBe('memory');
182+
expect(result[1].data.total).toBe(55);
183+
expect(result[1].data.average).toBe(27.5);
184+
expect(result[1].data.last).toBe(30);
185+
} else {
186+
expect.fail(failMessage);
187+
}
188+
});
189+
190+
it('should preserve legend colors and titles', () => {
191+
const zoom: ZoomState = { left: 1000, right: 2000 };
192+
const result = computeLegendRowsBasedOnData({
193+
zoom,
194+
data: mockData,
195+
legendRows: mockLegendRows,
196+
});
197+
198+
if (!result) {
199+
expect.fail(failMessage);
200+
}
201+
202+
expect(result[0].legendColor).toBe('blue');
203+
expect(result[1].legendColor).toBe('red');
204+
});
205+
206+
it('should handle missing values in data', () => {
207+
const dataWithMissing: DataSet[] = [
208+
{ timestamp: 1000, cpu: 10 },
209+
{ timestamp: 2000, memory: 25 },
210+
];
211+
const zoom: ZoomState = { left: 1000, right: 2000 };
212+
const result = computeLegendRowsBasedOnData({
213+
zoom,
214+
data: dataWithMissing,
215+
legendRows: mockLegendRows,
216+
});
217+
218+
if (!result) {
219+
expect.fail(failMessage);
220+
}
221+
222+
expect(result[0].data.total).toBe(10);
223+
expect(result[1].data.total).toBe(25);
224+
});
225+
});
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { type Metrics, roundTo } from '@linode/utilities';
2+
3+
import { humanizeLargeData } from './utils';
4+
5+
import type { ZoomState } from '../Widget/components/useZoomController';
6+
import type { DataSet } from 'src/components/AreaChart/AreaChart';
7+
import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay';
8+
9+
interface ZoomStateData {
10+
/**
11+
* The data to be processed according to the zoom state
12+
*/
13+
data: DataSet[];
14+
/**
15+
* Indicates if the unit is humanizable
16+
*/
17+
isHumanizableUnit?: boolean;
18+
/**
19+
* The legend rows to be processed according to zoom state
20+
*/
21+
legendRows?: MetricsDisplayRow[];
22+
23+
/**
24+
* The unit of measurement for formatting
25+
*/
26+
unit?: string;
27+
28+
/**
29+
* The current zoom state
30+
*/
31+
zoom: ZoomState;
32+
}
33+
34+
/**
35+
* @param data The data for which to compute the zoomed-in subset
36+
* @param zoom The current zoom state
37+
* @returns The subset of data that falls within the zoomed-in range
38+
*/
39+
export const computeZoomedInData = ({
40+
data,
41+
zoom,
42+
}: ZoomStateData): DataSet[] => {
43+
if (!data || data.length === 0) {
44+
return data;
45+
}
46+
if (zoom.left === 'dataMin' && zoom.right === 'dataMax') {
47+
return data;
48+
}
49+
50+
const minZoom = zoom.left === 'dataMin' ? data[0].timestamp : zoom.left; // left zoom boundary
51+
const maxZoom =
52+
zoom.right === 'dataMax' ? data[data.length - 1].timestamp : zoom.right; // right zoom boundary
53+
return data.filter(
54+
({ timestamp }) => timestamp >= minZoom && timestamp <= maxZoom
55+
);
56+
};
57+
58+
/**
59+
* @param zoom The current zoom state
60+
* @param data The data to compute legend rows from
61+
* @param legendRows The original legend rows
62+
* @returns The computed legend rows based on the zoomed-in data
63+
*/
64+
export const computeLegendRowsBasedOnData = ({
65+
data,
66+
zoom,
67+
legendRows,
68+
unit,
69+
isHumanizableUnit,
70+
}: ZoomStateData) => {
71+
if (!legendRows || !data || !data.length) return undefined;
72+
73+
// If not zoomed, return original rows unchanged
74+
if (zoom.left === 'dataMin' && zoom.right === 'dataMax') {
75+
return legendRows;
76+
}
77+
78+
const minZoom = zoom.left === 'dataMin' ? data[0].timestamp : zoom.left; // left zoom boundary
79+
const maxZoom =
80+
zoom.right === 'dataMax' ? data[data.length - 1].timestamp : zoom.right; // right zoom boundary
81+
82+
return legendRows.map((legendRow) => {
83+
const values: number[] = [];
84+
85+
for (const dataRow of data) {
86+
const value = dataRow[legendRow.legendTitle];
87+
if (
88+
typeof value === 'number' &&
89+
!Number.isNaN(value) &&
90+
dataRow.timestamp >= minZoom &&
91+
dataRow.timestamp <= maxZoom
92+
) {
93+
values.push(value);
94+
}
95+
}
96+
97+
return {
98+
...legendRow,
99+
format: isHumanizableUnit
100+
? (value: number) => `${humanizeLargeData(value)} ${unit}` // continue to humanize values
101+
: (value: number) => `${roundTo(value)} ${unit}`, // only round the values, units and values are already scaled up
102+
data: getMetricsFromDimensionData(values),
103+
};
104+
});
105+
};
106+
107+
/**
108+
* @param data The data of the current dimension
109+
* @returns The max, avg, last, length, total from the data
110+
*/
111+
export const getMetricsFromDimensionData = (data: number[]): Metrics => {
112+
// If there's no data
113+
if (!data || !Array.isArray(data) || data.length < 1) {
114+
return { average: 0, last: 0, length: 0, max: 0, total: 0 };
115+
}
116+
117+
let max = 0;
118+
let sum = 0;
119+
120+
// The data is large, so we get everything we need in one iteration
121+
data.forEach((value): void => {
122+
if (value === null || value === undefined || Number.isNaN(value)) {
123+
return;
124+
}
125+
126+
if (value > max) {
127+
max = value;
128+
}
129+
130+
sum += value;
131+
});
132+
133+
const length = data.length;
134+
135+
// Safeguard against dividing by 0
136+
const average = length > 0 ? sum / length : 0;
137+
138+
const last = data[length - 1] || 0;
139+
140+
return { average, last, length, max, total: sum };
141+
};

0 commit comments

Comments
 (0)