[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:
+76
-126
@@ -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}
|
||||
|
||||
+71
-15
@@ -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}
|
||||
|
||||
+54
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user