From f5045d830eb1ab74817d5632869824ec1e40d255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:22:38 +0100 Subject: [PATCH] [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 --- .../components/CustomBarItem.tsx | 202 +++++++----------- .../components/GraphWidgetBarChart.tsx | 86 ++++++-- .../utils/computeShouldRoundFreeEndMap.ts | 54 +++++ 3 files changed, 201 insertions(+), 141 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeShouldRoundFreeEndMap.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx index efb7d6391c..55bda4f673 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx @@ -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 = BarItemProps & { - 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 = ({ ariaDescribedBy, ariaDisabled, ariaHidden, - keys, - groupMode = 'grouped', - data: chartData, - indexBy, + shouldRoundFreeEnd, + seriesIndex, + isDimmed, + isSliceHovered, layout = BarChartLayout.VERTICAL, chartId, }: CustomBarItemProps) => { - 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 ( - - {shouldRoundFreeEnd && ( + + {clipInterpolations && ( @@ -200,9 +150,9 @@ export const CustomBarItem = ({ $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} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx index 0322cfa4e6..b594e7f00e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx @@ -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( + 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) => ( - - ), - [orderedKeys, groupMode, data, indexBy, layout, id], + const MemoizedBarItem = useMemo( + () => (props: BarItemProps) => { + 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 ( + + ); + }, + [ + shouldRoundFreeEndMap, + keyToIndexMap, + highlightedLegendId, + hoveredSliceIndex, + layout, + id, + ], ); const TotalsLayer = ({ @@ -311,7 +367,7 @@ export const GraphWidgetBarChart = ({ }} /> | null => { + if ( + groupMode !== 'stacked' || + !orderedKeys?.length || + !data?.length || + !indexBy + ) { + return null; + } + + const map = new Map(); + + 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; +};