Data labels improvements (#15475)

Create custom total component and display the total instead of each
group separately



https://github.com/user-attachments/assets/4e3edde8-9675-4bb1-834a-98dbff016063



https://github.com/user-attachments/assets/294555cc-a64b-4148-b1b9-f1dc0f0322ae

---------

Co-authored-by: ehconitin <nitinkoche03@gmail.com>
This commit is contained in:
Raphaël Bosi
2025-10-30 19:17:37 +01:00
committed by GitHub
parent 4c35cf305f
commit 769ce95cde
3 changed files with 210 additions and 7 deletions
@@ -0,0 +1,181 @@
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { useTheme } from '@emotion/react';
import { type BarCustomLayerProps, type ComputedBarDatum } from '@nivo/bar';
import { animated } from '@react-spring/web';
import { isDefined } from 'twenty-shared/utils';
type CustomTotalsLayerProps = Pick<
BarCustomLayerProps<BarChartDataItem>,
'bars'
> & {
formatValue?: (value: number) => string;
offset?: number;
layout?: 'vertical' | 'horizontal';
groupMode?: 'grouped' | 'stacked';
omitNullValues?: boolean;
};
type LabelData = {
key: string;
value: number;
verticalX: number;
verticalY: number;
horizontalX: number;
horizontalY: number;
isNegative: boolean;
};
const computeGroupedLabels = (
bars: readonly ComputedBarDatum<BarChartDataItem>[],
): LabelData[] => {
return bars.map((bar) => {
const value = Number(bar.data.value);
const isNegative = value < 0;
const centerX = bar.x + bar.width / 2;
const centerY = bar.y + bar.height / 2;
return {
key: `value-${bar.data.id}-${bar.data.indexValue}`,
value,
verticalX: centerX,
verticalY: isNegative ? bar.y + bar.height : bar.y,
horizontalX: isNegative ? bar.x : bar.x + bar.width,
horizontalY: centerY,
isNegative,
};
});
};
const computeStackedLabels = (
bars: readonly ComputedBarDatum<BarChartDataItem>[],
): LabelData[] => {
const groupTotals = new Map<
string,
{
total: number;
maxY: number;
maxX: number;
bars: ComputedBarDatum<BarChartDataItem>[];
}
>();
for (const bar of bars) {
const groupKey = String(bar.data.indexValue);
const existingGroup = groupTotals.get(groupKey);
if (isDefined(existingGroup)) {
existingGroup.total += Number(bar.data.value);
existingGroup.maxY = Math.min(existingGroup.maxY, bar.y);
existingGroup.maxX = Math.max(existingGroup.maxX, bar.x + bar.width);
existingGroup.bars.push(bar);
} else {
groupTotals.set(groupKey, {
total: Number(bar.data.value),
maxY: bar.y,
maxX: bar.x + bar.width,
bars: [bar],
});
}
}
return Array.from(groupTotals.entries()).map(
([groupKey, { total, maxY, maxX, bars: groupBars }]) => {
const centerX =
groupBars.reduce((acc, bar) => acc + bar.x + bar.width / 2, 0) /
groupBars.length;
const centerY =
groupBars.reduce((acc, bar) => acc + bar.y + bar.height / 2, 0) /
groupBars.length;
return {
key: `total-${groupKey}`,
value: total,
verticalX: centerX,
verticalY: maxY,
horizontalX: maxX,
horizontalY: centerY,
isNegative: false,
};
},
);
};
const getLabelStyles = (
label: LabelData,
isVertical: boolean,
offset: number,
) => {
const offsetSign = isVertical
? label.isNegative
? 1
: -1
: label.isNegative
? -1
: 1;
const axis = isVertical ? 'Y' : 'X';
const transformOffset = `translate${axis}(${offsetSign * offset}px)`;
const textAnchor = isVertical ? 'middle' : label.isNegative ? 'end' : 'start';
const dominantBaseline = isVertical
? label.isNegative
? 'hanging'
: 'auto'
: 'central';
return {
x: isVertical ? label.verticalX : label.horizontalX,
y: isVertical ? label.verticalY : label.horizontalY,
textAnchor,
dominantBaseline,
transformOffset,
};
};
export const CustomTotalsLayer = ({
bars,
formatValue,
offset = 0,
layout = 'vertical',
groupMode = 'grouped',
omitNullValues = false,
}: CustomTotalsLayerProps) => {
const theme = useTheme();
const isVertical = layout === 'vertical';
const labels =
groupMode === 'stacked'
? computeStackedLabels(bars)
: computeGroupedLabels(bars);
const labelsToRender = labels.filter(
(label) => !omitNullValues || label.value !== 0,
);
return (
<>
{labelsToRender.map((label) => {
const styles = getLabelStyles(label, isVertical, offset);
return (
<animated.text
key={label.key}
x={styles.x}
y={styles.y}
textAnchor={styles.textAnchor}
dominantBaseline={styles.dominantBaseline}
style={{
fill: theme.font.color.light,
fontSize: 11,
fontWeight: theme.font.weight.medium,
transform: styles.transformOffset,
}}
>
{isDefined(formatValue) ? formatValue(label.value) : label.value}
</animated.text>
);
})}
</>
);
};
@@ -2,6 +2,7 @@ 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 { CustomBarItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem';
import { CustomTotalsLayer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/CustomTotalsLayer';
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';
@@ -21,12 +22,11 @@ import {
import { NodeDimensionEffect } from '@/ui/utilities/dimensions/components/NodeDimensionEffect';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ResponsiveBar } from '@nivo/bar';
import { type ComputedBarDatum, ResponsiveBar } from '@nivo/bar';
import { useMemo, useRef, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
const LEGEND_THRESHOLD = 10;
const LABEL_THRESHOLD = 15;
type GraphWidgetBarChartProps = {
data: BarChartDataItem[];
@@ -45,6 +45,7 @@ type GraphWidgetBarChartProps = {
rangeMin?: number;
rangeMax?: number;
enableGroupTooltip?: boolean;
omitNullValues?: boolean;
} & GraphValueFormatOptions;
const StyledContainer = styled.div`
@@ -73,6 +74,7 @@ export const GraphWidgetBarChart = ({
rangeMin,
rangeMax,
enableGroupTooltip,
omitNullValues = false,
displayType,
decimals,
prefix,
@@ -122,11 +124,8 @@ export const GraphWidgetBarChart = ({
enableGroupTooltip: shouldEnableGroupTooltip,
});
const isLargeChart = data.length * keys.length > LABEL_THRESHOLD;
const areThereTooManyKeys = keys.length > LEGEND_THRESHOLD;
const shouldShowLabels = showValues && !isLargeChart;
const shouldShowLegend = showLegend && !areThereTooManyKeys;
const { axisBottom: axisBottomConfig, axisLeft: axisLeftConfig } =
@@ -171,6 +170,25 @@ export const GraphWidgetBarChart = ({
[keys, groupMode, data, indexBy, layout, id],
);
const TotalsLayer = ({
bars,
}: {
bars: readonly ComputedBarDatum<BarChartDataItem>[];
}) => (
<>
{showValues && (
<CustomTotalsLayer
bars={bars}
formatValue={(value) => formatGraphValue(value, formatOptions)}
offset={theme.spacingMultiplicator * 2}
layout={layout}
groupMode={groupMode}
omitNullValues={omitNullValues}
/>
)}
</>
);
const calculatedRange =
groupMode === 'stacked'
? calculateStackedBarChartValueRange(data, keys)
@@ -223,7 +241,7 @@ export const GraphWidgetBarChart = ({
}}
indexScale={{ type: 'band', round: true }}
colors={(datum) => getBarChartColor(datum, barConfigs, theme)}
layers={['grid', 'markers', 'axes', 'bars', 'legends']}
layers={['grid', 'markers', 'axes', 'bars', 'legends', TotalsLayer]}
markers={zeroMarker}
axisTop={null}
axisRight={null}
@@ -233,9 +251,12 @@ export const GraphWidgetBarChart = ({
enableGridY={layout === 'vertical' && showGrid}
gridXValues={layout === 'horizontal' ? 5 : undefined}
gridYValues={layout === 'vertical' ? 5 : undefined}
enableLabel={shouldShowLabels}
enableLabel={false}
labelSkipWidth={12}
labelSkipHeight={12}
valueFormat={(value) =>
formatGraphValue(Number(value), formatOptions)
}
labelTextColor={theme.font.color.primary}
label={(d) => formatGraphValue(Number(d.value), formatOptions)}
tooltip={(props) => renderTooltip(props)}
@@ -75,6 +75,7 @@ export const GraphWidgetBarChartRenderer = ({
displayType="shortNumber"
rangeMin={configuration.rangeMin ?? undefined}
rangeMax={configuration.rangeMax ?? undefined}
omitNullValues={configuration.omitNullValues ?? false}
/>
</Suspense>
</>