[Dashboards] Improve bar chart performance (#17399)

This commit is contained in:
nitin
2026-01-23 18:33:36 +05:30
committed by GitHub
parent 0091ef5f6c
commit 8255c51910
7 changed files with 95 additions and 222 deletions
@@ -1,17 +1,20 @@
import { LEGEND_HIGHLIGHT_DIMMED_OPACITY } from '@/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant';
import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
import { graphWidgetIsSliceHoveredComponentFamilySelector } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetIsSliceHoveredComponentFamilySelector';
import { graphWidgetHighlightedLegendIdComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { type BarDatum, type BarItemProps } from '@nivo/bar';
import { animated, to } from '@react-spring/web';
import { isNumber } from '@sniptt/guards';
import { useMemo } from 'react';
import styled from 'styled-components';
import { isDefined } from 'twenty-shared/utils';
import { BarChartLayout } from '~/generated/graphql';
type CustomBarItemProps<D extends BarDatum> = BarItemProps<D> & {
shouldRoundFreeEnd: boolean;
seriesIndex: number;
isDimmed: boolean;
isSliceHovered: boolean;
layout?: BarChartLayout;
chartId?: string;
};
@@ -49,11 +52,22 @@ export const CustomBarItem = <D extends BarDatum>({
ariaHidden,
shouldRoundFreeEnd,
seriesIndex,
isDimmed,
isSliceHovered,
layout = BarChartLayout.VERTICAL,
chartId,
}: CustomBarItemProps<D>) => {
const highlightedLegendId = useRecoilComponentValue(
graphWidgetHighlightedLegendIdComponentState,
);
const isSliceHovered = useRecoilComponentFamilyValue(
graphWidgetIsSliceHoveredComponentFamilySelector,
String(barData.indexValue),
);
const isDimmed =
isDefined(highlightedLegendId) &&
String(highlightedLegendId) !== String(barData.id);
const isNegativeValue = isNumber(barData.value) && barData.value < 0;
const isHorizontal = layout === BarChartLayout.HORIZONTAL;
@@ -19,7 +19,6 @@ import { computeShouldRoundFreeEndMap } from '@/page-layout/widgets/graph/graphW
import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor';
import { getBarChartInnerPadding } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartInnerPadding';
import { getBarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartLayout';
import { graphWidgetHighlightedLegendIdComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { computeEffectiveValueRange } from '@/page-layout/widgets/graph/utils/computeEffectiveValueRange';
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
@@ -28,7 +27,6 @@ import {
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
import { NodeDimensionEffect } from '@/ui/utilities/dimensions/components/NodeDimensionEffect';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
@@ -117,14 +115,6 @@ export const GraphWidgetBarChart = ({
graphWidgetHoveredSliceIndexComponentState,
);
const highlightedLegendId = useRecoilComponentValue(
graphWidgetHighlightedLegendIdComponentState,
);
const hoveredSliceIndex = useRecoilComponentValue(
graphWidgetHoveredSliceIndexComponentState,
);
const formatOptions: GraphValueFormatOptions = {
displayType,
decimals,
@@ -135,16 +125,8 @@ export const GraphWidgetBarChart = ({
const chartTheme = useBarChartTheme();
const { barConfigs, enrichedKeys, legendItems, visibleKeys } =
useBarChartData({
data,
indexBy,
keys,
series,
colorRegistry,
seriesLabels,
colorMode,
});
const { enrichedKeysMap, enrichedKeys, legendItems, visibleKeys } =
useBarChartData({ keys, series, colorRegistry, seriesLabels, colorMode });
const orderedKeys =
groupMode === 'stacked' && layout === BarChartLayout.VERTICAL
@@ -255,35 +237,18 @@ export const GraphWidgetBarChart = ({
const shouldRoundFreeEnd = shouldRoundFreeEndMap?.get(barKey) ?? true;
const seriesIndex = keyToIndexMap.get(String(props.bar.data.id)) ?? -1;
const isDimmed =
isDefined(highlightedLegendId) &&
String(highlightedLegendId) !== String(props.bar.data.id);
const isSliceHovered =
isDefined(hoveredSliceIndex) &&
String(hoveredSliceIndex) === String(props.bar.data.indexValue);
return (
<CustomBarItem
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
shouldRoundFreeEnd={shouldRoundFreeEnd}
seriesIndex={seriesIndex}
isDimmed={isDimmed}
isSliceHovered={isSliceHovered}
layout={layout}
chartId={id}
/>
);
},
[
shouldRoundFreeEndMap,
keyToIndexMap,
highlightedLegendId,
hoveredSliceIndex,
layout,
id,
],
[shouldRoundFreeEndMap, keyToIndexMap, layout, id],
);
const TotalsLayer = ({
@@ -377,7 +342,7 @@ export const GraphWidgetBarChart = ({
clamp: true,
}}
indexScale={{ type: 'band', round: true }}
colors={(datum) => getBarChartColor(datum, barConfigs, theme)}
colors={(datum) => getBarChartColor(datum, enrichedKeysMap, theme)}
animate
motionConfig={CHART_MOTION_CONFIG}
layers={[
@@ -1,7 +1,6 @@
import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData';
import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { type BarDatum } from '@nivo/bar';
import { renderHook } from '@testing-library/react';
const mockUseRecoilComponentValue = jest.fn();
@@ -57,12 +56,6 @@ describe('useBarChartData', () => {
},
};
const mockData: BarDatum[] = [
{ month: 'Jan', sales: 100, costs: 80 },
{ month: 'Feb', sales: 120, costs: 90 },
{ month: 'Mar', sales: 150, costs: 100 },
];
const mockSeries: BarChartSeriesWithColor[] = [
{ key: 'sales', label: 'Sales', color: 'green' },
{ key: 'costs', label: 'Costs', color: 'purple' },
@@ -71,8 +64,6 @@ describe('useBarChartData', () => {
it('should create series config map', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
@@ -84,11 +75,9 @@ describe('useBarChartData', () => {
expect(result.current.seriesConfigMap.get('costs')).toEqual(mockSeries[1]);
});
it('should generate bar configs for each data point and key', () => {
it('should generate enriched keys map for O(1) color lookup', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
@@ -96,17 +85,17 @@ describe('useBarChartData', () => {
}),
);
expect(result.current.barConfigs).toHaveLength(6);
expect(result.current.barConfigs[0]).toMatchObject({
expect(result.current.enrichedKeysMap.size).toBe(2);
expect(result.current.enrichedKeysMap.get('sales')).toMatchObject({
key: 'sales',
indexValue: 'Jan',
label: 'Sales',
colorScheme: {
name: 'green',
},
});
expect(result.current.barConfigs[1]).toMatchObject({
expect(result.current.enrichedKeysMap.get('costs')).toMatchObject({
key: 'costs',
indexValue: 'Jan',
label: 'Costs',
colorScheme: {
name: 'purple',
},
@@ -116,8 +105,6 @@ describe('useBarChartData', () => {
it('should create enriched keys with labels', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
@@ -147,8 +134,6 @@ describe('useBarChartData', () => {
it('should use series labels when series config is not provided', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: undefined,
colorRegistry: mockColorRegistry,
@@ -161,26 +146,9 @@ describe('useBarChartData', () => {
expect(result.current.enrichedKeys[1].label).toBe('Expenses');
});
it('should handle empty data', () => {
const { result } = renderHook(() =>
useBarChartData({
data: [],
indexBy: 'month',
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
colorMode: 'automaticPalette',
}),
);
expect(result.current.barConfigs).toEqual([]);
});
it('should handle empty keys', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: [],
series: mockSeries,
colorRegistry: mockColorRegistry,
@@ -188,15 +156,13 @@ describe('useBarChartData', () => {
}),
);
expect(result.current.barConfigs).toEqual([]);
expect(result.current.enrichedKeysMap.size).toBe(0);
expect(result.current.enrichedKeys).toEqual([]);
});
it('should fall back to key name when no label is provided', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: undefined,
colorRegistry: mockColorRegistry,
@@ -212,8 +178,6 @@ describe('useBarChartData', () => {
it('should return legend items from all keys', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
@@ -234,8 +198,6 @@ describe('useBarChartData', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
@@ -253,8 +215,6 @@ describe('useBarChartData', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
@@ -270,8 +230,6 @@ describe('useBarChartData', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
@@ -283,33 +241,11 @@ describe('useBarChartData', () => {
expect(result.current.legendItems).toHaveLength(2);
});
it('should filter barConfigs to only include visible keys', () => {
mockUseRecoilComponentValue.mockReturnValue(['costs']);
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
colorMode: 'automaticPalette',
}),
);
expect(result.current.barConfigs).toHaveLength(3);
result.current.barConfigs.forEach((config) => {
expect(config.key).toBe('sales');
});
});
it('should handle hidden ids that do not exist in keys', () => {
mockUseRecoilComponentValue.mockReturnValue(['nonexistent', 'alsoNotReal']);
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
@@ -1,20 +1,14 @@
import { type GraphWidgetLegendItem } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
import { type BarChartConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartConfig';
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme';
import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { type BarDatum } from '@nivo/bar';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
type UseBarChartDataProps = {
data: BarDatum[];
indexBy: string;
keys: string[];
series?: BarChartSeriesWithColor[];
colorRegistry: GraphColorRegistry;
@@ -24,8 +18,6 @@ type UseBarChartDataProps = {
};
export const useBarChartData = ({
data,
indexBy,
keys,
series,
colorRegistry,
@@ -44,23 +36,25 @@ export const useBarChartData = ({
[series],
);
const shouldApplyGradient = colorMode === 'explicitSingleColor';
const allEnrichedKeys = useMemo((): BarChartEnrichedKey[] => {
const shouldApplyGradient = colorMode === 'explicitSingleColor';
const allEnrichedKeys: BarChartEnrichedKey[] = keys.map((key, index) => {
const seriesConfig = seriesConfigMap.get(key);
const colorScheme = getColorScheme({
registry: colorRegistry,
colorName: seriesConfig?.color,
fallbackIndex: index,
totalGroups: shouldApplyGradient ? keys.length : undefined,
return keys.map((key, index) => {
const seriesConfig = seriesConfigMap.get(key);
const colorScheme = getColorScheme({
registry: colorRegistry,
colorName: seriesConfig?.color,
fallbackIndex: index,
totalGroups: shouldApplyGradient ? keys.length : undefined,
});
return {
key,
colorScheme,
label: seriesConfig?.label ?? seriesLabels?.[key] ?? key,
};
});
return {
key,
colorScheme,
label: seriesConfig?.label ?? seriesLabels?.[key] ?? key,
};
});
}, [keys, seriesConfigMap, colorRegistry, seriesLabels, colorMode]);
const legendItems: GraphWidgetLegendItem[] = allEnrichedKeys.map((item) => ({
id: item.key,
@@ -74,38 +68,14 @@ export const useBarChartData = ({
(item) => !hiddenLegendIds.includes(item.key),
);
const barConfigs = useMemo((): BarChartConfig[] => {
return data.flatMap((dataPoint) => {
const indexValue = dataPoint[indexBy];
const datumColor = parseGraphColor(dataPoint.color as string | undefined);
return visibleKeys.flatMap((key): BarChartConfig[] => {
const enrichedKey = allEnrichedKeys.find((ek) => ek.key === key);
if (!isDefined(enrichedKey)) {
return [];
}
const colorScheme = isDefined(datumColor)
? getColorScheme({
registry: colorRegistry,
colorName: datumColor,
})
: enrichedKey.colorScheme;
return [
{
key,
indexValue,
colorScheme,
},
];
});
});
}, [data, indexBy, visibleKeys, allEnrichedKeys, colorRegistry]);
const enrichedKeysMap = useMemo(
() => new Map(allEnrichedKeys.map((ek) => [ek.key, ek])),
[allEnrichedKeys],
);
return {
seriesConfigMap,
barConfigs,
enrichedKeysMap,
enrichedKeys,
legendItems,
visibleKeys,
@@ -1,7 +0,0 @@
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
export type BarChartConfig = {
key: string;
indexValue: string | number;
colorScheme: GraphColorScheme;
};
@@ -1,4 +1,4 @@
import { type BarChartConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartConfig';
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor';
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
@@ -51,36 +51,37 @@ describe('getBarChartColor', () => {
],
};
const mockBarConfigs: BarChartConfig[] = [
{
key: 'sales',
indexValue: 'January',
colorScheme: mockBlueColorScheme,
},
{
key: 'revenue',
indexValue: 'January',
colorScheme: mockGreenColorScheme,
},
{
key: 'sales',
indexValue: 'February',
colorScheme: mockBlueColorScheme,
},
];
const mockEnrichedKeysMap = new Map<string, BarChartEnrichedKey>([
[
'sales',
{
key: 'sales',
label: 'Sales',
colorScheme: mockBlueColorScheme,
},
],
[
'revenue',
{
key: 'revenue',
label: 'Revenue',
colorScheme: mockGreenColorScheme,
},
],
]);
it('should return the correct color when datum matches bar config', () => {
it('should return the correct color when datum matches enriched key', () => {
const datum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const result = getBarChartColor(datum, mockBarConfigs, mockTheme);
const result = getBarChartColor(datum, mockEnrichedKeysMap, mockTheme);
expect(result).toBe('#solidBlue');
});
it('should return different colors for different keys at same index', () => {
it('should return different colors for different keys', () => {
const salesDatum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'January',
@@ -91,10 +92,14 @@ describe('getBarChartColor', () => {
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const salesColor = getBarChartColor(salesDatum, mockBarConfigs, mockTheme);
const salesColor = getBarChartColor(
salesDatum,
mockEnrichedKeysMap,
mockTheme,
);
const revenueColor = getBarChartColor(
revenueDatum,
mockBarConfigs,
mockEnrichedKeysMap,
mockTheme,
);
@@ -102,40 +107,33 @@ describe('getBarChartColor', () => {
expect(revenueColor).toBe('#solidGreen');
});
it('should return theme fallback color when no matching config is found', () => {
it('should return theme fallback color when no matching key is found', () => {
const datum: ComputedDatum<BarDatum> = {
id: 'unknown',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const result = getBarChartColor(datum, mockBarConfigs, mockTheme);
const result = getBarChartColor(datum, mockEnrichedKeysMap, mockTheme);
expect(result).toBe('#fallback');
});
it('should return fallback color when indexValue does not match', () => {
const datum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'March',
} as unknown as ComputedDatum<BarDatum>;
const result = getBarChartColor(datum, mockBarConfigs, mockTheme);
expect(result).toBe('#fallback');
});
it('should return fallback color when barConfigs is empty', () => {
it('should return fallback color when enrichedKeysMap is empty', () => {
const datum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const result = getBarChartColor(datum, [], mockTheme);
const result = getBarChartColor(
datum,
new Map<string, BarChartEnrichedKey>(),
mockTheme,
);
expect(result).toBe('#fallback');
});
it('should match based on both key and indexValue', () => {
it('should return same color for same key regardless of indexValue', () => {
const januaryDatum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'January',
@@ -148,12 +146,12 @@ describe('getBarChartColor', () => {
const januaryColor = getBarChartColor(
januaryDatum,
mockBarConfigs,
mockEnrichedKeysMap,
mockTheme,
);
const februaryColor = getBarChartColor(
februaryDatum,
mockBarConfigs,
mockEnrichedKeysMap,
mockTheme,
);
@@ -1,18 +1,15 @@
import { type BarChartConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartConfig';
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
import { isDefined } from 'twenty-shared/utils';
import { type ThemeType } from 'twenty-ui/theme';
export const getBarChartColor = (
datum: ComputedDatum<BarDatum>,
barConfigs: BarChartConfig[],
enrichedKeysMap: Map<string, BarChartEnrichedKey>,
theme: ThemeType,
) => {
const bar = barConfigs.find(
(b) => b.key === datum.id && b.indexValue === datum.indexValue,
);
if (!isDefined(bar)) {
const enrichedKey = enrichedKeysMap.get(String(datum.id));
if (!enrichedKey) {
return theme.border.color.light;
}
return bar.colorScheme.solid;
return enrichedKey.colorScheme.solid;
};