[DASHBOARDS] Improve bar chart performances (#17358)

This PR implements various performances improvements for bar charts.

## Before


https://github.com/user-attachments/assets/26e1d10a-582c-46c6-abc4-a094e0bf7417


## After


https://github.com/user-attachments/assets/fb3ebb8d-929d-4a67-8391-e5d5b22c9e11
This commit is contained in:
Raphaël Bosi
2026-01-22 17:22:38 +01:00
committed by GitHub
parent fb2c9e12bc
commit f5045d830e
3 changed files with 201 additions and 141 deletions
@@ -1,22 +1,17 @@
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> & {
keys?: string[];
groupMode?: 'grouped' | 'stacked';
data?: readonly D[];
indexBy?: string;
shouldRoundFreeEnd: boolean;
seriesIndex: number;
isDimmed: boolean;
isSliceHovered: boolean;
layout?: BarChartLayout;
chartId?: string;
};
@@ -52,145 +47,100 @@ export const CustomBarItem = <D extends BarDatum>({
ariaDescribedBy,
ariaDisabled,
ariaHidden,
keys,
groupMode = 'grouped',
data: chartData,
indexBy,
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 = useMemo(
() => isNumber(barData.value) && barData.value < 0,
[barData.value],
);
const seriesIndex = useMemo(
() =>
isDefined(keys)
? keys.findIndex((currentKey) => currentKey === barData.id)
: -1,
[keys, barData.id],
);
const shouldRoundFreeEnd = useMemo(() => {
const isStackedAndValid =
groupMode === 'stacked' &&
isDefined(keys) &&
keys.length > 0 &&
isDefined(chartData) &&
isDefined(indexBy);
if (!isStackedAndValid) {
return true;
}
const dataPoint = chartData.find(
(chartDataItem) => chartDataItem[indexBy] === barData.indexValue,
);
if (!isDefined(dataPoint)) {
return true;
}
if (seriesIndex === -1) {
return true;
}
const keysAfterCurrentKey = keys.slice(seriesIndex + 1);
const hasSameSignBarAfter = keysAfterCurrentKey.some((key) => {
const value = dataPoint[key];
return isNumber(value) && (isNegativeValue ? value < 0 : value > 0);
});
return !hasSameSignBarAfter;
}, [
groupMode,
keys,
chartData,
indexBy,
isNegativeValue,
seriesIndex,
barData.indexValue,
]);
const isNegativeValue = isNumber(barData.value) && barData.value < 0;
const isHorizontal = layout === BarChartLayout.HORIZONTAL;
const clipPathId = `round-corner-${chartId ?? 'chart'}-${barData.index}-${
seriesIndex >= 0 ? seriesIndex : 'x'
}`;
const unconstrainedThicknessDimension = isHorizontal ? height : width;
const unconstrainedValueDimension = isHorizontal ? width : height;
const clipPathX = !isHorizontal || isNegativeValue ? 0 : -borderRadius;
const clipPathY = isHorizontal || !isNegativeValue ? 0 : -borderRadius;
const constrainedThicknessDimension = to(
unconstrainedThicknessDimension,
(dimension) => Math.min(dimension, BAR_CHART_CONSTANTS.MAXIMUM_WIDTH),
);
const barInterpolations = useMemo(() => {
const unconstrainedThicknessDimension = isHorizontal ? height : width;
const unconstrainedValueDimension = isHorizontal ? width : height;
const centeringOffset = to(unconstrainedThicknessDimension, (dimension) =>
dimension > BAR_CHART_CONSTANTS.MAXIMUM_WIDTH
? (dimension - BAR_CHART_CONSTANTS.MAXIMUM_WIDTH) / 2
: 0,
);
const constrainedThicknessDimension = to(
unconstrainedThicknessDimension,
(dimension) => Math.min(dimension, BAR_CHART_CONSTANTS.MAXIMUM_WIDTH),
);
const centeringTransform = to(centeringOffset, (offset) =>
isHorizontal ? `translate(0, ${offset})` : `translate(${offset}, 0)`,
);
const centeringOffset = to(unconstrainedThicknessDimension, (dimension) =>
dimension > BAR_CHART_CONSTANTS.MAXIMUM_WIDTH
? (dimension - BAR_CHART_CONSTANTS.MAXIMUM_WIDTH) / 2
: 0,
);
const finalBarWidthDimension = isHorizontal
? unconstrainedValueDimension
: constrainedThicknessDimension;
const centeringTransform = to(centeringOffset, (offset) =>
isHorizontal ? `translate(0, ${offset})` : `translate(${offset}, 0)`,
);
const finalBarHeightDimension = isHorizontal
? constrainedThicknessDimension
: unconstrainedValueDimension;
const finalBarWidthDimension = isHorizontal
? unconstrainedValueDimension
: constrainedThicknessDimension;
const clipPathX = !isHorizontal ? 0 : isNegativeValue ? 0 : -borderRadius;
const clipPathY = isHorizontal ? 0 : isNegativeValue ? -borderRadius : 0;
const finalBarHeightDimension = isHorizontal
? constrainedThicknessDimension
: unconstrainedValueDimension;
const widthWithOffset = (value: number) =>
Math.max(value + (isHorizontal ? borderRadius : 0), 0);
const heightWithOffset = (value: number) =>
Math.max(value + (isHorizontal ? 0 : borderRadius), 0);
const clampRadius = (value: number) => Math.min(borderRadius, value / 2);
const clampToZero = (value: number) => Math.max(value, 0);
const clipRectWidth = to(finalBarWidthDimension, (value) =>
widthWithOffset(value),
);
const clipRectHeight = to(finalBarHeightDimension, (value) =>
heightWithOffset(value),
);
const clipBorderRadiusX = to(finalBarWidthDimension, (value) =>
clampRadius(widthWithOffset(value)),
);
const clipBorderRadiusY = to(finalBarHeightDimension, (value) =>
clampRadius(heightWithOffset(value)),
);
return {
centeringTransform,
finalBarWidth: to(finalBarWidthDimension, clampToZero),
finalBarHeight: to(finalBarHeightDimension, clampToZero),
finalBarWidthDimension,
finalBarHeightDimension,
};
}, [width, height, isHorizontal]);
const clipInterpolations = useMemo(() => {
if (!shouldRoundFreeEnd) {
return null;
}
const { finalBarWidthDimension, finalBarHeightDimension } =
barInterpolations;
const widthWithOffset = (value: number) =>
Math.max(value + (isHorizontal ? borderRadius : 0), 0);
const heightWithOffset = (value: number) =>
Math.max(value + (isHorizontal ? 0 : borderRadius), 0);
const clampRadius = (value: number) => Math.min(borderRadius, value / 2);
return {
clipRectWidth: to(finalBarWidthDimension, widthWithOffset),
clipRectHeight: to(finalBarHeightDimension, heightWithOffset),
clipBorderRadiusX: to(finalBarWidthDimension, (value) =>
clampRadius(widthWithOffset(value)),
),
clipBorderRadiusY: to(finalBarHeightDimension, (value) =>
clampRadius(heightWithOffset(value)),
),
};
}, [barInterpolations, shouldRoundFreeEnd, isHorizontal, borderRadius]);
return (
<animated.g transform={transform}>
<animated.g transform={centeringTransform}>
{shouldRoundFreeEnd && (
<animated.g transform={barInterpolations.centeringTransform}>
{clipInterpolations && (
<defs>
<clipPath id={clipPathId}>
<animated.rect
x={clipPathX}
y={clipPathY}
rx={clipBorderRadiusX}
ry={clipBorderRadiusY}
width={clipRectWidth}
height={clipRectHeight}
rx={clipInterpolations.clipBorderRadiusX}
ry={clipInterpolations.clipBorderRadiusY}
width={clipInterpolations.clipRectWidth}
height={clipInterpolations.clipRectHeight}
/>
</clipPath>
</defs>
@@ -200,9 +150,9 @@ export const CustomBarItem = <D extends BarDatum>({
$isInteractive={isInteractive}
$isDimmed={isDimmed}
$isSliceHovered={isSliceHovered}
clipPath={shouldRoundFreeEnd ? `url(#${clipPathId})` : undefined}
width={to(finalBarWidthDimension, (value) => Math.max(value, 0))}
height={to(finalBarHeightDimension, (value) => Math.max(value, 0))}
clipPath={clipInterpolations ? `url(#${clipPathId})` : undefined}
width={barInterpolations.finalBarWidth}
height={barInterpolations.finalBarHeight}
fill={color}
strokeWidth={borderWidth}
stroke={borderColor}
@@ -14,9 +14,11 @@ import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graphW
import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice';
import { calculateStackedBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange';
import { calculateValueRangeFromBarChartKeys } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys';
import { computeShouldRoundFreeEndMap } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeShouldRoundFreeEndMap';
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';
@@ -25,6 +27,7 @@ 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';
@@ -122,6 +125,14 @@ export const GraphWidgetBarChart = ({
graphWidgetHoveredSliceIndexComponentState,
);
const highlightedLegendId = useRecoilComponentValue(
graphWidgetHighlightedLegendIdComponentState,
);
const hoveredSliceIndex = useRecoilComponentValue(
graphWidgetHoveredSliceIndexComponentState,
);
const formatOptions: GraphValueFormatOptions = {
displayType,
decimals,
@@ -148,6 +159,23 @@ export const GraphWidgetBarChart = ({
? visibleKeys.toReversed()
: visibleKeys;
const shouldRoundFreeEndMap = useMemo(
() =>
computeShouldRoundFreeEndMap({
data,
orderedKeys,
indexBy,
groupMode,
}),
[groupMode, orderedKeys, data, indexBy],
);
const keyToIndexMap = useMemo(() => {
return new Map<string, number>(
orderedKeys?.map((key, index) => [key, index]) ?? [],
);
}, [orderedKeys]);
const calculatedValueRange =
groupMode === 'stacked'
? calculateStackedBarChartValueRange(data, visibleKeys)
@@ -222,20 +250,48 @@ export const GraphWidgetBarChart = ({
debouncedHideTooltip();
};
const BarItemWithContext = useMemo(
() => (props: BarItemProps<BarDatum>) => (
<CustomBarItem
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
keys={orderedKeys}
groupMode={groupMode}
data={data}
indexBy={indexBy}
layout={layout}
chartId={id}
/>
),
[orderedKeys, groupMode, data, indexBy, layout, id],
const MemoizedBarItem = useMemo(
() => (props: BarItemProps<BarDatum>) => {
if (props.bar.data.value === 0) {
return null;
}
const barKey = JSON.stringify([
props.bar.data.indexValue,
props.bar.data.id,
]);
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,
],
);
const TotalsLayer = ({
@@ -311,7 +367,7 @@ export const GraphWidgetBarChart = ({
}}
/>
<ResponsiveBar
barComponent={BarItemWithContext}
barComponent={MemoizedBarItem}
data={data}
keys={orderedKeys}
indexBy={indexBy}
@@ -0,0 +1,54 @@
import { type BarDatum } from '@nivo/bar';
import { isNumber } from '@sniptt/guards';
type ComputeShouldRoundFreeEndMapParams = {
data: BarDatum[];
orderedKeys: string[];
indexBy: string;
groupMode?: 'grouped' | 'stacked';
};
export const computeShouldRoundFreeEndMap = ({
data,
orderedKeys,
indexBy,
groupMode,
}: ComputeShouldRoundFreeEndMapParams): Map<string, boolean> | null => {
if (
groupMode !== 'stacked' ||
!orderedKeys?.length ||
!data?.length ||
!indexBy
) {
return null;
}
const map = new Map<string, boolean>();
for (const dataPoint of data) {
const indexValue = dataPoint[indexBy];
for (let seriesIndex = 0; seriesIndex < orderedKeys.length; seriesIndex++) {
const key = orderedKeys[seriesIndex];
const value = dataPoint[key];
if (!isNumber(value) || value === 0) {
continue;
}
const isNegative = value < 0;
const keysAfterCurrent = orderedKeys.slice(seriesIndex + 1);
const hasSameSignBarAfter = keysAfterCurrent.some((afterKey) => {
const afterValue = dataPoint[afterKey];
return (
isNumber(afterValue) && (isNegative ? afterValue < 0 : afterValue > 0)
);
});
map.set(JSON.stringify([indexValue, key]), !hasSameSignBarAfter);
}
}
return map;
};