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:
+181
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+28
-7
@@ -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)}
|
||||
|
||||
+1
@@ -75,6 +75,7 @@ export const GraphWidgetBarChartRenderer = ({
|
||||
displayType="shortNumber"
|
||||
rangeMin={configuration.rangeMin ?? undefined}
|
||||
rangeMax={configuration.rangeMax ?? undefined}
|
||||
omitNullValues={configuration.omitNullValues ?? false}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user