Skip to content

Commit ea40796

Browse files
committed
Merge branch 'bugfix/chart-tooltip-unit-per-value' into q/1.0
2 parents f6cc295 + 5d0d1c7 commit ea40796

8 files changed

Lines changed: 284 additions & 23 deletions

File tree

src/lib/components/charts/common/chartUtils.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
formatXAxisDate,
77
normalizeChartDataWithUnits,
88
getTooltipDateFormat,
9+
formatTooltipValueWithUnit,
910
} from './chartUtils';
1011
import { NAN_STRING } from '../../constants';
1112
import { UnitRange } from '../types';
@@ -414,4 +415,79 @@ describe('normalizeChartDataWithUnits', () => {
414415
expect(result.rechartsData).toEqual(data);
415416
});
416417
});
418+
419+
describe('valueBase', () => {
420+
it('should expose the value base used for normalization', () => {
421+
const data = [{ category: 'A', value: 2000 }];
422+
const unitRange: UnitRange = [
423+
{ threshold: 1, label: 'op/s' },
424+
{ threshold: 1000, label: 'kop/s' },
425+
];
426+
427+
const result = normalizeChartDataWithUnits(
428+
data,
429+
2000,
430+
unitRange,
431+
'category',
432+
);
433+
434+
expect(result.unitLabel).toBe('kop/s');
435+
expect(result.valueBase).toBe(1000);
436+
});
437+
438+
it('should default valueBase to 1 when no unit range is provided', () => {
439+
const result = normalizeChartDataWithUnits(
440+
[{ category: 'A', value: 100 }],
441+
100,
442+
undefined,
443+
'category',
444+
);
445+
446+
expect(result.valueBase).toBe(1);
447+
});
448+
});
449+
});
450+
451+
describe('formatTooltipValueWithUnit', () => {
452+
const unitRange: UnitRange = [
453+
{ threshold: 1, label: 'op/s' },
454+
{ threshold: 1000, label: 'kop/s' },
455+
{ threshold: 1000000, label: 'Mop/s' },
456+
];
457+
458+
it('re-derives a smaller unit for values that are small relative to the axis unit', () => {
459+
// Axis is in kop/s (valueBase 1000). A point of 5 op/s is stored as 0.005.
460+
// Without re-scaling it would read "0.01 kop/s"; instead it should read "5 op/s".
461+
expect(formatTooltipValueWithUnit(0.005, 1000, unitRange, 'kop/s')).toBe(
462+
'5.00 op/s',
463+
);
464+
});
465+
466+
it('keeps the axis unit for values that match its magnitude', () => {
467+
// 2 (stored) * 1000 = 2000 op/s → 2 kop/s
468+
expect(formatTooltipValueWithUnit(2, 1000, unitRange, 'kop/s')).toBe(
469+
'2.00 kop/s',
470+
);
471+
});
472+
473+
it('re-derives a larger unit for values that exceed the axis unit', () => {
474+
// 5000 (stored) * 1000 = 5,000,000 op/s → 5 Mop/s
475+
expect(formatTooltipValueWithUnit(5000, 1000, unitRange, 'kop/s')).toBe(
476+
'5.00 Mop/s',
477+
);
478+
});
479+
480+
it('handles negative values (symmetrical charts) using the magnitude', () => {
481+
expect(formatTooltipValueWithUnit(-0.005, 1000, unitRange, 'kop/s')).toBe(
482+
'-5.00 op/s',
483+
);
484+
});
485+
486+
it('falls back to the provided unit label when no unit range is given', () => {
487+
expect(formatTooltipValueWithUnit(42.5, 1, undefined, '%')).toBe('42.50 %');
488+
});
489+
490+
it('returns "-" for non-finite values', () => {
491+
expect(formatTooltipValueWithUnit(NaN, 1000, unitRange, 'kop/s')).toBe('-');
492+
});
417493
});

src/lib/components/charts/common/chartUtils.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export const normalizeChartDataWithUnits = <T extends Record<string, any>>(
182182
topValue: number;
183183
rechartsData: T[];
184184
topDomain: number;
185+
valueBase: number;
185186
} => {
186187
// If no unit range provided, just calculate top value without unit conversion
187188
if (!unitRange || unitRange.length === 0) {
@@ -191,6 +192,7 @@ export const normalizeChartDataWithUnits = <T extends Record<string, any>>(
191192
topValue,
192193
rechartsData: data,
193194
topDomain: maxValue * 1.1,
195+
valueBase: 1,
194196
};
195197
}
196198

@@ -212,7 +214,50 @@ export const normalizeChartDataWithUnits = <T extends Record<string, any>>(
212214
return normalizedDataPoint as T;
213215
});
214216

215-
return { unitLabel, topValue, rechartsData, topDomain };
217+
return { unitLabel, topValue, rechartsData, topDomain, valueBase };
218+
};
219+
220+
/**
221+
* Formats a single value for tooltip display, re-deriving the unit from the
222+
* value's own magnitude when a unitRange is provided.
223+
*
224+
* Chart data is normalized once against the dataset maximum so the Y-axis can
225+
* use a single unit. A value that is small relative to that maximum would
226+
* otherwise render with many decimals under the axis unit (e.g. "0.005 kop/s").
227+
* Re-applying the unitRange per value keeps the tooltip readable (e.g. "5 op/s")
228+
* independently of the axis unit.
229+
*
230+
* @param value - The normalized value as stored in the Recharts dataset
231+
* @param valueBase - The factor the dataset was divided by during normalization
232+
* @param unitRange - Unit range used for the chart; when empty the value is shown as-is
233+
* @param fallbackUnitLabel - Unit label used when no unitRange is provided (e.g. "%")
234+
*/
235+
export const formatTooltipValueWithUnit = (
236+
value: number,
237+
valueBase: number,
238+
unitRange: UnitRange | undefined,
239+
fallbackUnitLabel?: string,
240+
): string => {
241+
if (!Number.isFinite(value)) return '-';
242+
243+
if (!unitRange || unitRange.length === 0) {
244+
const formatted = formatISONumber(value, {
245+
fixedDecimals: true,
246+
compact: true,
247+
});
248+
return `${formatted}${fallbackUnitLabel ? ` ${fallbackUnitLabel}` : ''}`;
249+
}
250+
251+
const originalValue = value * valueBase;
252+
const { valueBase: tooltipValueBase, unitLabel } = getUnitLabel(
253+
unitRange,
254+
Math.abs(originalValue),
255+
);
256+
const formatted = formatISONumber(originalValue / tooltipValueBase, {
257+
fixedDecimals: true,
258+
compact: true,
259+
});
260+
return `${formatted}${unitLabel ? ` ${unitLabel}` : ''}`;
216261
};
217262

218263
/**

src/lib/components/charts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export {
4747
formatXAxisDate,
4848
getTooltipDateFormat,
4949
normalizeChartDataWithUnits,
50+
formatTooltipValueWithUnit,
5051
} from './common/chartUtils';
5152

5253
// Context Providers (for backward compatibility)

src/lib/components/charts/linetimeseries/LineTimeSerieChart.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export function LineTimeSerieChart({
8080
topDomain,
8181
topValue,
8282
unitLabel,
83+
valueBase,
8384
xAxisTicks,
8485
linesToRender,
8586
belowSeriesLabels,
@@ -192,6 +193,8 @@ export function LineTimeSerieChart({
192193
content={(props: TooltipContentProps<number, string>) => (
193194
<LineTimeSerieChartTooltip
194195
unitLabel={unitLabel}
196+
valueBase={valueBase}
197+
unitRange={unitRange}
195198
duration={duration}
196199
renderTooltip={renderTooltip}
197200
isSymmetrical={yAxisType === 'symmetrical'}

src/lib/components/charts/linetimeseries/LineTimeSerieChart.types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ export const CHART_PRESETS: Record<'default' | 'modern', DisplayOptions> = {
119119
export type LineTimeSerieChartTooltipProps = {
120120
tooltipProps: TooltipContentProps<number, string>;
121121
unitLabel?: string;
122+
valueBase?: number;
123+
unitRange?: {
124+
threshold: number;
125+
label: string;
126+
}[];
122127
duration: number;
123128
renderTooltip?: (
124129
tooltipProps: TooltipContentProps<number, string>,

src/lib/components/charts/linetimeseries/LineTimeSerieChartTooltip.tsx

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,7 @@ import {
1010
} from '../common/ChartTooltip';
1111
import { LineTimeSerieChartTooltipProps } from './LineTimeSerieChart.types';
1212
import { getCurrentlyHoveredChartId } from './useChartHover';
13-
import { formatISONumber } from '../../../utils';
14-
15-
/**
16-
* Formats a numeric value for tooltip display
17-
* - Non-finite values (NaN, null, undefined) → "-"
18-
* - Zero → "0" with unit
19-
* - Large values (>= 1000) → compact notation (1k, 1M)
20-
* - Normal values (1-999) → up to 2 decimal places
21-
* - Small values (0.01-0.99) → 2 decimal places
22-
* - Very small values (< 0.01) → scientific notation (e.g., 4.7e-5)
23-
*/
24-
export const formatTooltipValue = (
25-
value: number,
26-
unitLabel?: string,
27-
): string => {
28-
if (!Number.isFinite(value)) return '-';
29-
30-
const formatted = formatISONumber(value, { fixedDecimals: true, compact: true });
31-
return `${formatted}${unitLabel ? ` ${unitLabel}` : ''}`;
32-
};
13+
import { formatTooltipValueWithUnit } from '../common/chartUtils';
3314

3415
/**
3516
* Custom tooltip component for LineTimeSerieChart
@@ -39,6 +20,8 @@ export const LineTimeSerieChartTooltip: React.FC<
3920
LineTimeSerieChartTooltipProps
4021
> = ({
4122
unitLabel,
23+
valueBase = 1,
24+
unitRange,
4225
duration,
4326
tooltipProps,
4427
renderTooltip,
@@ -104,7 +87,12 @@ export const LineTimeSerieChartTooltip: React.FC<
10487
/>
10588
);
10689

107-
const formattedValue = formatTooltipValue(entry.value, unitLabel);
90+
const formattedValue = formatTooltipValueWithUnit(
91+
entry.value,
92+
valueBase,
93+
unitRange,
94+
unitLabel,
95+
);
10896

10997
return (
11098
<React.Fragment key={index}>

src/lib/components/charts/linetimeseries/useChartData.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ type ChartDataOutput = {
3232
topValue: number;
3333
/** Unit label (e.g., "KiB/s", "%") */
3434
unitLabel: string | undefined;
35+
/** Factor the dataset was divided by during normalization (for tooltip re-scaling) */
36+
valueBase: number;
3537
/** X-axis tick positions */
3638
xAxisTicks: number[];
3739
/** Line configurations ready for rendering */
@@ -207,7 +209,7 @@ export function useChartData({
207209
* - Applies unit range thresholds (e.g., B/s → KiB/s → MiB/s)
208210
* - Calculates Y-axis domain
209211
*/
210-
const { topValue, unitLabel, rechartsData, topDomain } = useMemo(() => {
212+
const { topValue, unitLabel, rechartsData, topDomain, valueBase } = useMemo(() => {
211213
const values = chartData.flatMap((dataPoint) =>
212214
Object.entries(dataPoint)
213215
.filter(([key]) => key !== 'timestamp')
@@ -231,6 +233,7 @@ export function useChartData({
231233
unitLabel: yAxisType === 'percentage' ? '%' : undefined,
232234
rechartsData: chartData,
233235
topDomain: 1,
236+
valueBase: 1,
234237
};
235238
}
236239

@@ -258,6 +261,7 @@ export function useChartData({
258261
unitLabel: result.unitLabel ?? (yAxisType === 'percentage' ? '%' : undefined),
259262
rechartsData: result.rechartsData,
260263
topDomain: finalTopDomain,
264+
valueBase: result.valueBase,
261265
};
262266
}, [chartData, yAxisType, unitRange]);
263267

@@ -317,6 +321,7 @@ export function useChartData({
317321
topDomain,
318322
topValue,
319323
unitLabel,
324+
valueBase,
320325
xAxisTicks,
321326
linesToRender,
322327
belowSeriesLabels,

0 commit comments

Comments
 (0)