diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomTotalsLayer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomTotalsLayer.tsx new file mode 100644 index 0000000000..a6dc850cfc --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomTotalsLayer.tsx @@ -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, + '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[], +): 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[], +): LabelData[] => { + const groupTotals = new Map< + string, + { + total: number; + maxY: number; + maxX: number; + bars: ComputedBarDatum[]; + } + >(); + + 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 ( + + {isDefined(formatValue) ? formatValue(label.value) : label.value} + + ); + })} + + ); +}; 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 2ef6259206..8c233cf4c4 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 @@ -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[]; + }) => ( + <> + {showValues && ( + 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)} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx index 95ec99c058..349df97db5 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx @@ -75,6 +75,7 @@ export const GraphWidgetBarChartRenderer = ({ displayType="shortNumber" rangeMin={configuration.rangeMin ?? undefined} rangeMax={configuration.rangeMax ?? undefined} + omitNullValues={configuration.omitNullValues ?? false} />