Design adjustments on Bar chart (#15028)

- Make the ticks dynamic when we resize the graph
- Fix formatting
- Fix gradient color not working when the index have a space in it
- Fix the maximum number of groups for a grouped by graph
- Update the tooltip design and display the group

Video:


https://github.com/user-attachments/assets/9f304b6c-3dec-4ce2-9127-41d27f393d90

---------

Co-authored-by: Marie Stoppa <marie.stoppa@essec.edu>
This commit is contained in:
Raphaël Bosi
2025-10-13 11:43:06 +02:00
committed by GitHub
parent 6188c72f74
commit 168e7b16ec
22 changed files with 351 additions and 113 deletions
@@ -2,26 +2,30 @@ import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/component
import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
import { GraphWidgetTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip';
import { BarChartEndLines } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartEndLines';
import { BAR_CHART_MARGINS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMargins';
import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData';
import { useBarChartHandlers } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartHandlers';
import { useBarChartTheme } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme';
import { useBarChartTooltip } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTooltip';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { getBarChartAxisBottomConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisBottomConfig';
import { getBarChartAxisLeftConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisLeftConfig';
import { getBarChartAxisConfigs } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs';
import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor';
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
import { NodeDimensionEffect } from '@/ui/utilities/dimensions/components/NodeDimensionEffect';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ResponsiveBar, type BarCustomLayerProps } from '@nivo/bar';
import { useId } from 'react';
import { useId, useRef, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
const LEGEND_THRESHOLD = 10;
const LABEL_THRESHOLD = 15;
type GraphWidgetBarChartProps = {
data: BarChartDataItem[];
indexBy: string;
@@ -70,6 +74,9 @@ export const GraphWidgetBarChart = ({
const theme = useTheme();
const instanceId = useId();
const colorRegistry = createGraphColorRegistry(theme);
const [chartWidth, setChartWidth] = useState<number>(0);
const [chartHeight, setChartHeight] = useState<number>(0);
const containerRef = useRef<HTMLDivElement>(null);
const formatOptions: GraphValueFormatOptions = {
displayType,
@@ -108,19 +115,25 @@ export const GraphWidgetBarChart = ({
formatOptions,
});
const axisBottomConfig = getBarChartAxisBottomConfig(
layout,
xAxisLabel,
yAxisLabel,
formatOptions,
);
const isLargeChart = data.length * keys.length > LABEL_THRESHOLD;
const areThereTooManyKeys = keys.length > LEGEND_THRESHOLD;
const axisLeftConfig = getBarChartAxisLeftConfig(
layout,
xAxisLabel,
yAxisLabel,
formatOptions,
);
const shouldShowLabels = showValues && !isLargeChart;
const shouldShowLegend = showLegend && !areThereTooManyKeys;
const { axisBottom: axisBottomConfig, axisLeft: axisLeftConfig } =
getBarChartAxisConfigs({
width: chartWidth,
height: chartHeight,
data,
layout,
indexBy,
xAxisLabel,
yAxisLabel,
formatOptions,
axisFontSize: chartTheme.axis.ticks.text.fontSize,
});
const renderTooltip = (datum: Parameters<typeof getTooltipData>[0]) => {
const tooltipData = getTooltipData(datum);
@@ -130,6 +143,7 @@ export const GraphWidgetBarChart = ({
<GraphWidgetTooltip
items={[tooltipData.tooltipItem]}
showClickHint={tooltipData.showClickHint}
title={tooltipData.title}
/>
);
};
@@ -147,14 +161,22 @@ export const GraphWidgetBarChart = ({
return (
<StyledContainer id={id}>
<GraphWidgetChartContainer
ref={containerRef}
$isClickable={hasClickableItems}
$cursorSelector='svg g[transform] rect[fill^="url(#gradient-"]'
>
<NodeDimensionEffect
elementRef={containerRef}
onDimensionChange={({ width, height }) => {
setChartWidth(width);
setChartHeight(height);
}}
/>
<ResponsiveBar
data={data}
keys={keys}
indexBy={indexBy}
margin={{ top: 20, right: 20, bottom: 60, left: 70 }}
margin={BAR_CHART_MARGINS}
padding={0.3}
groupMode={groupMode}
layout={layout}
@@ -178,7 +200,7 @@ export const GraphWidgetBarChart = ({
enableGridY={layout === 'vertical' && showGrid}
gridXValues={layout === 'horizontal' ? 5 : undefined}
gridYValues={layout === 'vertical' ? 5 : undefined}
enableLabel={showValues}
enableLabel={shouldShowLabels}
labelSkipWidth={12}
labelSkipHeight={12}
labelTextColor={theme.font.color.primary}
@@ -198,7 +220,7 @@ export const GraphWidgetBarChart = ({
/>
</GraphWidgetChartContainer>
<GraphWidgetLegend
show={showLegend}
show={shouldShowLegend}
items={enrichedKeys.map((item) => {
const total = data.reduce(
(sum, d) => sum + Number(d[item.key] || 0),
@@ -54,6 +54,7 @@ export const GraphWidgetBarChartRenderer = ({
yAxisLabel={yAxisLabel}
showValues={showDataLabels}
id={widget.id}
displayType="shortNumber"
/>
</Suspense>
);
@@ -0,0 +1,6 @@
export const BAR_CHART_MARGINS = {
top: 20,
right: 20,
bottom: 60,
left: 70,
} as const;
@@ -47,7 +47,8 @@ export const useBarChartData = ({
seriesConfig?.color,
keyIndex,
);
const gradientId = `gradient-${id}-${instanceId}-${key}-${dataIndex}-${keyIndex}`;
const sanitizedKey = key.replace(/\s+/g, '-');
const gradientId = `gradient-${id}-${instanceId}-${sanitizedKey}-${dataIndex}-${keyIndex}`;
return {
key,
@@ -40,6 +40,7 @@ export const useBarChartTooltip = ({
return {
tooltipItem,
showClickHint: isDefined(dataItem?.to),
title: String(datum.indexValue),
};
};
@@ -0,0 +1,32 @@
import { BAR_CHART_MARGINS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMargins';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
const MINIMUM_WIDTH_PER_TICK = 100;
export const computeBarChartCategoryTickValues = ({
width,
data,
indexBy,
}: {
width: number;
data: BarChartDataItem[];
indexBy: string;
}): (string | number)[] => {
if (width === 0 || data.length === 0) return [];
const horizontalMargins = BAR_CHART_MARGINS.left + BAR_CHART_MARGINS.right;
const availableWidth = width - horizontalMargins;
const numberOfTicks = Math.floor(availableWidth / MINIMUM_WIDTH_PER_TICK);
if (numberOfTicks <= 0) return [];
if (numberOfTicks === 1) return [data[0][indexBy] as string | number];
if (numberOfTicks >= data.length)
return data.map((item) => item[indexBy] as string | number);
const step = (data.length - 1) / (numberOfTicks - 1);
return Array.from({ length: numberOfTicks }, (_, i) => {
const index = Math.min(Math.round(i * step), data.length - 1);
return data[index][indexBy] as string | number;
});
};
@@ -0,0 +1,14 @@
const MIN_TICK_SPACING_HEIGHT_RATIO = 2.5;
type ComputeBarChartValueTickCountProps = {
height: number;
axisFontSize: number;
};
export const computeBarChartValueTickCount = ({
height,
axisFontSize,
}: ComputeBarChartValueTickCountProps): number => {
const minHeightPerTick = axisFontSize * MIN_TICK_SPACING_HEIGHT_RATIO;
return Math.max(1, Math.floor(height / minHeightPerTick));
};
@@ -1,30 +0,0 @@
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
export const getBarChartAxisBottomConfig = (
layout: 'vertical' | 'horizontal',
xAxisLabel?: string,
yAxisLabel?: string,
formatOptions?: GraphValueFormatOptions,
) => {
return layout === 'vertical'
? {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: xAxisLabel,
legendPosition: 'middle' as const,
legendOffset: 40,
}
: {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: yAxisLabel,
legendPosition: 'middle' as const,
legendOffset: 40,
format: (value: number) => formatGraphValue(value, formatOptions || {}),
};
};
@@ -0,0 +1,112 @@
import { BAR_CHART_MARGINS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMargins';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { computeBarChartCategoryTickValues } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues';
import { computeBarChartValueTickCount } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartValueTickCount';
import { truncateTickLabel } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/truncateTickLabel';
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
const AVERAGE_CHARACTER_WIDTH_RATIO = 0.6;
const MIN_TICK_LABEL_LENGTH = 5;
const MAX_LEFT_AXIS_LABEL_LENGTH = 20;
type GetBarChartAxisConfigsProps = {
width: number;
height: number;
data: BarChartDataItem[];
layout: 'vertical' | 'horizontal';
indexBy: string;
xAxisLabel?: string;
yAxisLabel?: string;
formatOptions?: GraphValueFormatOptions;
axisFontSize?: number;
};
export const getBarChartAxisConfigs = ({
width,
height,
data,
layout,
indexBy,
xAxisLabel,
yAxisLabel,
formatOptions,
axisFontSize = 11,
}: GetBarChartAxisConfigsProps) => {
const categoryTickValues = computeBarChartCategoryTickValues({
width,
data,
indexBy,
});
const availableWidth =
width - (BAR_CHART_MARGINS.left + BAR_CHART_MARGINS.right);
const availableHeight =
height - (BAR_CHART_MARGINS.top + BAR_CHART_MARGINS.bottom);
const widthPerTick =
categoryTickValues.length > 0
? availableWidth / categoryTickValues.length
: 0;
const averageCharacterWidth = axisFontSize * AVERAGE_CHARACTER_WIDTH_RATIO;
const maxLabelLength = Math.max(
MIN_TICK_LABEL_LENGTH,
Math.floor(widthPerTick / averageCharacterWidth),
);
const numberOfValueTicks = computeBarChartValueTickCount({
height: availableHeight,
axisFontSize,
});
if (layout === 'vertical') {
return {
axisBottom: {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
tickValues: categoryTickValues,
legend: xAxisLabel,
legendPosition: 'middle' as const,
legendOffset: 40,
format: (value: string | number) =>
truncateTickLabel(String(value), maxLabelLength),
},
axisLeft: {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
tickValues: numberOfValueTicks,
legend: yAxisLabel,
legendPosition: 'middle' as const,
legendOffset: -50,
format: (value: number) => formatGraphValue(value, formatOptions || {}),
},
};
}
return {
axisBottom: {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
tickValues: numberOfValueTicks,
legend: yAxisLabel,
legendPosition: 'middle' as const,
legendOffset: 40,
format: (value: number) => formatGraphValue(value, formatOptions || {}),
},
axisLeft: {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
tickValues: categoryTickValues,
legend: xAxisLabel,
legendPosition: 'middle' as const,
legendOffset: -50,
format: (value: string | number) =>
truncateTickLabel(String(value), MAX_LEFT_AXIS_LABEL_LENGTH),
},
};
};
@@ -1,30 +0,0 @@
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
export const getBarChartAxisLeftConfig = (
layout: 'vertical' | 'horizontal',
xAxisLabel?: string,
yAxisLabel?: string,
formatOptions?: GraphValueFormatOptions,
) => {
return layout === 'vertical'
? {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: yAxisLabel,
legendPosition: 'middle' as const,
legendOffset: -50,
format: (value: number) => formatGraphValue(value, formatOptions || {}),
}
: {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: xAxisLabel,
legendPosition: 'middle' as const,
legendOffset: -50,
};
};
@@ -0,0 +1,10 @@
export const truncateTickLabel = (
value: string | number,
maxLength: number,
): string => {
const stringValue = String(value);
if (maxLength < 4 || stringValue.length <= maxLength) {
return stringValue;
}
return `${stringValue.slice(0, maxLength - 3)}...`;
};