Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 123 additions & 106 deletions src/lib/components/charts/barchart/Barchart.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useRef } from 'react';
import { useState, useRef, useMemo, useCallback } from 'react';
import {
Bar,
BarChart as RechartsBarChart,
Expand All @@ -13,7 +13,7 @@ import { Stack } from '../../../spacing';
import { chartColors, ChartColors, fontSize } from '../../../style/theme';
import { useChartLegend } from '../legend/ChartLegendWrapper';
import { BarchartTooltip } from './BarchartTooltip';
import { formatToISONumber, getTicks } from '../common/chartUtils';
import { formatTickValue, getTicks } from '../common/chartUtils';
import { useChartData } from './Barchart.utils';
import {
ChartHeader,
Expand Down Expand Up @@ -112,15 +112,19 @@ export const Barchart = <T extends BarchartBars>(props: BarchartProps<T>) => {
} = props;

// Create colorSet from ChartLegendWrapper
const colorSet = bars?.reduce(
(acc, bar) => {
const color = getColor(bar.label);
if (color) {
acc[bar.label] = color;
}
return acc;
},
{} as Record<string, ChartColors | string>,
const colorSet = useMemo(
() =>
bars?.reduce(
(acc, bar) => {
const color = getColor(bar.label);
if (color) {
acc[bar.label] = color;
}
return acc;
},
{} as Record<string, ChartColors | string>,
),
[bars, getColor],
);

const {
Expand All @@ -139,6 +143,113 @@ export const Barchart = <T extends BarchartBars>(props: BarchartProps<T>) => {
stackedBarSort,
);
const titleWithUnit = unitLabel ? `${title} (${unitLabel})` : title;

const tickFormatter = useCallback(
(value: number) => formatTickValue(value, roundReferenceValue),
[roundReferenceValue],
);

const renderChartContent = () => {
if (isError || (!bars && !isLoading)) {
return <ChartError height={height} />;
}
if (isLoading) {
return <ChartLoading height={height} />;
}

return (
<StyledResponsiveContainer ref={chartRef} width="100%" height={height}>
<RechartsBarChart
data={rechartsData}
accessibilityLayer
barSize={
type.type === 'category'
? type.gap === 0
? undefined
: CHART_CONSTANTS.BAR_SIZE
: CHART_CONSTANTS.BAR_SIZE
}
height={height}
margin={CHART_CONSTANTS.CHART_MARGIN}
barCategoryGap={type.type === 'category' ? type.gap : undefined}
>
<CartesianGrid
vertical={true}
horizontal={true}
verticalPoints={[0]}
horizontalPoints={[0]}
stroke={theme.border}
fill={theme.backgroundLevel4}
strokeWidth={1}
/>
{rechartsBars.map((bar) => {
const { fill, dataKey, stackId } = bar;
return (
<Bar
key={dataKey}
dataKey={dataKey}
fill={chartColors[fill] || fill}
minPointSize={stacked ? 0 : CHART_CONSTANTS.MIN_POINT_SIZE}
stackId={stackId}
isAnimationActive={false}
onMouseOver={() => setHoveredValue(dataKey)}
onMouseLeave={() => setHoveredValue(undefined)}
/>
);
})}

<YAxis
interval={0}
domain={[0, topDomain]}
ticks={getTicks(roundReferenceValue, false)}
tickFormatter={tickFormatter}
axisLine={{ stroke: theme.border }}
tick={{
fill: theme.textSecondary,
fontSize: fontSize.smaller,
}}
orientation="right"
/>

<XAxis
dataKey="category"
tick={(props) => (
<CustomTick
{...props}
type={type}
tickWidthOffset={CHART_CONSTANTS.TICK_WIDTH_OFFSET}
/>
)}
type="category"
interval={0}
allowDataOverflow={true}
tickLine={{
stroke: theme.border,
}}
axisLine={{
stroke: theme.border,
}}
/>

<Tooltip
content={(props: TooltipContentProps<number, string>) => (
<BarchartTooltip
type={type}
colorSet={colorSet}
tooltipProps={props}
hoveredValue={hoveredValue}
tooltip={tooltip}
unitLabel={unitLabel}
chartContainerRef={chartRef}
/>
)}
cursor={false}
/>
</RechartsBarChart>
</StyledResponsiveContainer>
);
};

return (
<Stack direction="vertical" style={{ gap: '0' }}>
<ChartHeader
Expand All @@ -147,101 +258,7 @@ export const Barchart = <T extends BarchartBars>(props: BarchartProps<T>) => {
helpTooltip={helpTooltip}
rightTitle={rightTitle}
/>
{isError || (!bars && !isLoading) ? (
<ChartError height={height} />
) : isLoading ? (
<ChartLoading height={height} />
) : (
<StyledResponsiveContainer ref={chartRef} width="100%" height={height}>
<RechartsBarChart
data={rechartsData}
accessibilityLayer
barSize={
type.type === 'category'
? type.gap === 0
? undefined
: CHART_CONSTANTS.BAR_SIZE
: CHART_CONSTANTS.BAR_SIZE
}
height={height}
margin={CHART_CONSTANTS.CHART_MARGIN}
barCategoryGap={type.type === 'category' ? type.gap : undefined}
>
<CartesianGrid
vertical={true}
horizontal={true}
verticalPoints={[0]}
horizontalPoints={[0]}
stroke={theme.border}
fill={theme.backgroundLevel4}
strokeWidth={1}
/>
{rechartsBars.map((bar) => {
const { fill, dataKey, stackId } = bar;
return (
<Bar
key={dataKey}
dataKey={dataKey}
fill={chartColors[fill] || fill}
minPointSize={stacked ? 0 : CHART_CONSTANTS.MIN_POINT_SIZE}
stackId={stackId}
isAnimationActive={false}
onMouseOver={() => setHoveredValue(dataKey)}
onMouseLeave={() => setHoveredValue(undefined)}
/>
);
})}

<YAxis
interval={0}
domain={[0, topDomain]}
ticks={getTicks(roundReferenceValue, false)}
tickFormatter={(value) => formatToISONumber(value)}
axisLine={{ stroke: theme.border }}
tick={{
fill: theme.textSecondary,
fontSize: fontSize.smaller,
}}
orientation="right"
/>

<XAxis
dataKey="category"
tick={(props) => (
<CustomTick
{...props}
type={type}
tickWidthOffset={CHART_CONSTANTS.TICK_WIDTH_OFFSET}
/>
)}
type="category"
interval={0}
allowDataOverflow={true}
tickLine={{
stroke: theme.border,
}}
axisLine={{
stroke: theme.border,
}}
/>

<Tooltip
content={(props: TooltipContentProps<number, string>) => (
<BarchartTooltip
type={type}
colorSet={colorSet}
tooltipProps={props}
hoveredValue={hoveredValue}
tooltip={tooltip}
unitLabel={unitLabel}
chartContainerRef={chartRef}
/>
)}
cursor={false}
/>
</RechartsBarChart>
</StyledResponsiveContainer>
)}
{renderChartContent()}
</Stack>
);
};
39 changes: 27 additions & 12 deletions src/lib/components/charts/common/chartUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,16 @@ describe('getRoundReferenceValue', () => {
expect(getRoundReferenceValue(9)).toBe(9); // 9 → 9.9 → 9 (magnitude 1, remainder 0.9)

// Larger values get 10% buffer applied
expect(getRoundReferenceValue(15)).toBe(10); // 15 → 16.5, remainder 5, incremented 20 > 16.5, so round down to 10
expect(getRoundReferenceValue(35)).toBe(30); // 35 → 38.5, remainder 5, incremented 40 > 38.5, so round down to 30
expect(getRoundReferenceValue(15)).toBe(15); // 15 → increment 5, remainder 0, return 15
expect(getRoundReferenceValue(35)).toBe(35); // 35 → increment 5, remainder 0, return 35
expect(getRoundReferenceValue(75)).toBe(80); // 75 → 82.5, remainder 5, incremented 80 <= 82.5, so round up to 80
expect(getRoundReferenceValue(150)).toBe(150); // 150 → 165, remainder 0, so return 150
expect(getRoundReferenceValue(350)).toBe(350); // 350 → 385, remainder 0, so return 350
expect(getRoundReferenceValue(750)).toBe(750); // 750 → 825, remainder 0, so return 750
expect(getRoundReferenceValue(1500)).toBe(1500); // 1500 → 1650, remainder 0, so return 1500
expect(getRoundReferenceValue(3500)).toBe(3500); // 3500 → 3850, remainder 0, so return 3500
expect(getRoundReferenceValue(7500)).toBe(7500); // 7500 → 8250, remainder 0, so return 7500
expect(getRoundReferenceValue(15000)).toBe(15000); // 15000 → 16500, remainder 0, so return 15000
expect(getRoundReferenceValue(150)).toBe(150); // increment 50, remainder 0
expect(getRoundReferenceValue(350)).toBe(350); // increment 50, remainder 0
expect(getRoundReferenceValue(750)).toBe(800); // increment 100, remainder 50, rounds up
expect(getRoundReferenceValue(1500)).toBe(1500); // increment 500, remainder 0
expect(getRoundReferenceValue(3500)).toBe(3500); // increment 500, remainder 0
expect(getRoundReferenceValue(7500)).toBe(8000); // increment 1000, remainder 500, rounds up
expect(getRoundReferenceValue(15000)).toBe(15000); // increment 5000, remainder 0
});
});

Expand Down Expand Up @@ -95,8 +95,23 @@ describe('getUnitLabel', () => {
});

describe('addMissingDataPoint', () => {
it('should generate placeholder timestamps when original data is empty', () => {
const result = addMissingDataPoint([], 0, 100, 10);
expect(result).toEqual([
[0, NAN_STRING],
[10, NAN_STRING],
[20, NAN_STRING],
[30, NAN_STRING],
[40, NAN_STRING],
[50, NAN_STRING],
[60, NAN_STRING],
[70, NAN_STRING],
[80, NAN_STRING],
[90, NAN_STRING],
]);
});

it('should return empty array for invalid inputs', () => {
expect(addMissingDataPoint([], 0, 100, 10)).toEqual([]);
expect(addMissingDataPoint([[10, 5]], undefined, 100, 10)).toEqual([]);
expect(addMissingDataPoint([[10, 5]], 0, 0, 10)).toEqual([]);
expect(addMissingDataPoint([[10, 5]], -1, 100, 10)).toEqual([]);
Expand Down Expand Up @@ -264,8 +279,8 @@ describe('normalizeChartDataWithUnits', () => {
);

expect(result.unitLabel).toBe('B');
// 680 / 1 = 680 → getRoundReferenceValue(680) = 680
expect(result.topValue).toBe(680);
// 680 / 1 = 680 → getRoundReferenceValue(680) = 700 (rounds up since 80 >= 50)
expect(result.topValue).toBe(700);
expect(result.rechartsData).toEqual([
{ category: 'category1', success: 680 },
]);
Expand Down
Loading
Loading