From ba3cbd1676b422d0373cc3f060092dc664be7b0a Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Tue, 16 Sep 2025 21:14:31 +0530 Subject: [PATCH] [Dashboards] - refactor - pie chart (#14526) This is the third PR from the split of https://github.com/twentyhq/twenty/pull/14458 - refactoring only the PieChart widget. Plus small performance improvements on already refactored widgets --- .../graph/components/GraphWidgetPieChart.tsx | 222 ------------------ .../graph/components/GraphWidgetRenderer.tsx | 10 +- .../GraphWidgetBarChart.stories.tsx | 2 +- .../GraphWidgetLineChart.stories.tsx | 2 +- .../GraphWidgetPieChart.stories.tsx | 2 +- .../components/BarChartEndLines.tsx | 6 +- .../components/GraphWidgetBarChart.tsx | 4 +- .../hooks/useBarChartData.ts | 6 + .../components/GraphWidgetPieChart.tsx | 141 +++++++++++ .../components/PieChartEndLines.tsx | 68 ++++++ .../hooks/__tests__/usePieChartData.test.ts | 208 ++++++++++++++++ .../hooks/usePieChartData.ts | 77 ++++++ .../hooks/usePieChartHandlers.ts | 30 +++ .../hooks/usePieChartTooltip.ts | 49 ++++ .../types/PieChartDataItem.ts | 9 + .../types/PieChartEnrichedData.ts | 10 + .../__tests__/calculatePieChartAngles.test.ts | 88 +++++++ ...alculatePieChartEndLineCoordinates.test.ts | 133 +++++++++++ .../calculatePieChartPercentage.test.ts | 57 +++++ .../utils/calculatePieChartAngles.ts | 14 ++ .../calculatePieChartEndLineCoordinates.ts | 15 ++ .../utils/calculatePieChartPercentage.ts | 9 + 22 files changed, 927 insertions(+), 235 deletions(-) delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetPieChart.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/PieChartEndLines.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/__tests__/usePieChartData.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartHandlers.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartTooltip.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartAngles.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartEndLineCoordinates.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartPercentage.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartAngles.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartEndLineCoordinates.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartPercentage.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetPieChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetPieChart.tsx deleted file mode 100644 index b8e8839a71..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetPieChart.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { useTheme } from '@emotion/react'; -import styled from '@emotion/styled'; -import { - ResponsivePie, - type ComputedDatum, - type DatumId, - type PieCustomLayerProps, -} from '@nivo/pie'; -import { useState } from 'react'; -import { isDefined } from 'twenty-shared/utils'; - -import { type GraphColor } from '../types/GraphColor'; -import { createGradientDef } from '../utils/createGradientDef'; -import { createGraphColorRegistry } from '../utils/createGraphColorRegistry'; -import { getColorScheme } from '../utils/getColorScheme'; -import { - formatGraphValue, - type GraphValueFormatOptions, -} from '../utils/graphFormatters'; -import { GraphWidgetChartContainer } from './GraphWidgetChartContainer'; -import { GraphWidgetLegend } from './GraphWidgetLegend'; -import { GraphWidgetTooltip } from './GraphWidgetTooltip'; - -type GraphWidgetPieChartProps = { - data: Array<{ - id: string; - value: number; - label?: string; - color?: GraphColor; - to?: string; - }>; - showLegend?: boolean; - id: string; -} & GraphValueFormatOptions; - -const StyledContainer = styled.div` - align-items: center; - display: flex; - flex-direction: column; - height: 100%; - justify-content: center; - width: 100%; -`; - -export const GraphWidgetPieChart = ({ - data, - showLegend = true, - id, - displayType, - decimals, - prefix, - suffix, - customFormatter, -}: GraphWidgetPieChartProps) => { - const theme = useTheme(); - const [hoveredSliceId, setHoveredSliceId] = useState(null); - - const colorRegistry = createGraphColorRegistry(theme); - - const formatOptions: GraphValueFormatOptions = { - displayType, - decimals, - prefix, - suffix, - customFormatter, - }; - - const totalValue = data.reduce((sum, item) => sum + item.value, 0); - - let cumulativeAngle = 0; - const enrichedData = data.map((item, index) => { - const colorScheme = getColorScheme(colorRegistry, item.color, index); - const isHovered = hoveredSliceId === item.id; - const gradientId = `${colorScheme.name}Gradient-${id}-${index}`; - const percentage = totalValue > 0 ? (item.value / totalValue) * 100 : 0; - - const sliceAngle = (percentage / 100) * 360; - const middleAngle = cumulativeAngle + sliceAngle / 2; - cumulativeAngle += sliceAngle; - - return { - ...item, - gradientId, - colorScheme, - isHovered, - percentage, - middleAngle, - }; - }); - - const defs = enrichedData.map((item) => - createGradientDef( - item.colorScheme, - item.gradientId, - item.isHovered, - item.middleAngle, - ), - ); - - const fill = enrichedData.map((item) => ({ - match: { id: item.id }, - id: item.gradientId, - })); - - const handleSliceClick = ( - datum: ComputedDatum<{ id: string; value: number; label?: string }>, - ) => { - const clickedItem = data.find((d) => d.id === datum.id); - if (isDefined(clickedItem?.to)) { - window.location.href = clickedItem.to; - } - }; - - const renderTooltip = ( - datum: ComputedDatum<{ id: string; value: number; label?: string }>, - ) => { - const item = enrichedData.find((d) => d.id === datum.id); - if (!item) return null; - - const dataItem = data.find((d) => d.id === datum.id); - const formattedValue = formatGraphValue( - displayType === 'percentage' ? item.percentage / 100 : item.value, - formatOptions, - ); - const formattedWithPercentage = `${formattedValue} (${item.percentage.toFixed(1)}%)`; - - return ( - - ); - }; - - const renderSliceEndLines = ( - layerProps: PieCustomLayerProps<{ - id: string; - value: number; - label?: string; - }>, - ) => { - const { dataWithArc, centerX, centerY, innerRadius, radius } = layerProps; - - if (!dataWithArc || !Array.isArray(dataWithArc) || dataWithArc.length < 2) { - return null; - } - - return ( - - {dataWithArc.map((datum) => { - const enrichedItem = enrichedData.find((d) => d.id === datum.id); - const lineColor = enrichedItem - ? enrichedItem.colorScheme.solid - : theme.border.color.strong; - - const angle = datum.arc.endAngle - Math.PI / 2; - const x1 = centerX + Math.cos(angle) * innerRadius; - const y1 = centerY + Math.sin(angle) * innerRadius; - const x2 = centerX + Math.cos(angle) * radius; - const y2 = centerY + Math.sin(angle) * radius; - - return ( - - ); - })} - - ); - }; - - const hasClickableItems = data.some((item) => isDefined(item.to)); - - return ( - - - `url(#${item.gradientId})`)} - borderWidth={0} - enableArcLinkLabels={false} - enableArcLabels={false} - tooltip={({ datum }) => renderTooltip(datum)} - onClick={handleSliceClick} - onMouseEnter={(datum) => setHoveredSliceId(datum.id)} - onMouseLeave={() => setHoveredSliceId(null)} - defs={defs} - fill={fill} - layers={['arcs', renderSliceEndLines]} - /> - - ({ - id: item.id, - label: item.label || item.id, - formattedValue: formatGraphValue( - displayType === 'percentage' ? item.percentage / 100 : item.value, - formatOptions, - ), - color: item.colorScheme.solid, - }))} - /> - - ); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx index 82fcbd0c96..fafc567dce 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx @@ -22,11 +22,11 @@ const GraphWidgetLineChart = lazy(() => ); const GraphWidgetPieChart = lazy(() => - import('@/page-layout/widgets/graph/components/GraphWidgetPieChart').then( - (module) => ({ - default: module.GraphWidgetPieChart, - }), - ), + import( + '@/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart' + ).then((module) => ({ + default: module.GraphWidgetPieChart, + })), ); const GraphWidgetGaugeChart = lazy(() => diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx index f40c9f4e8c..c8b6d25c7c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx @@ -1,7 +1,7 @@ import { type Meta, type StoryObj } from '@storybook/react'; import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing'; -import { GraphWidgetBarChart } from '../../graphWidgetBarChart/components/GraphWidgetBarChart'; +import { GraphWidgetBarChart } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart'; const meta: Meta = { title: 'Modules/PageLayout/Widgets/GraphWidgetBarChart', diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx index 77d737e171..70519ac387 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx @@ -2,7 +2,7 @@ import { type Meta, type StoryObj } from '@storybook/react'; import { type ComponentProps } from 'react'; import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing'; -import { GraphWidgetLineChart } from '../../graphWidgetLineChart/components/GraphWidgetLineChart'; +import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart'; const meta: Meta = { title: 'Modules/PageLayout/Widgets/GraphWidgetLineChart', diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetPieChart.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetPieChart.stories.tsx index 531cc1a169..28b942b612 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetPieChart.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetPieChart.stories.tsx @@ -1,7 +1,7 @@ import { type Meta, type StoryObj } from '@storybook/react'; import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing'; -import { GraphWidgetPieChart } from '../GraphWidgetPieChart'; +import { GraphWidgetPieChart } from '@/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart'; const meta: Meta = { title: 'Modules/PageLayout/Widgets/GraphWidgetPieChart', diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartEndLines.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartEndLines.tsx index 4369451eff..8b31f968bb 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartEndLines.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartEndLines.tsx @@ -6,19 +6,19 @@ import { isDefined } from 'twenty-shared/utils'; type BarChartEndLinesProps = { bars: readonly ComputedBarDatum[]; - enrichedKeys: BarChartEnrichedKey[]; + enrichedKeysMap: Map; layout: 'vertical' | 'horizontal'; }; export const BarChartEndLines = ({ bars, - enrichedKeys, + enrichedKeysMap, layout, }: BarChartEndLinesProps) => { return ( {bars.map((bar: ComputedBarDatum, index: number) => { - const enrichedKey = enrichedKeys.find((k) => k.key === bar.data.id); + const enrichedKey = enrichedKeysMap.get(String(bar.data.id)); if (!isDefined(enrichedKey)) { return null; } 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 aa0af90d0e..cf1e413987 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 @@ -87,7 +87,7 @@ export const GraphWidgetBarChart = ({ const chartTheme = useBarChartTheme(); - const { barConfigs, enrichedKeys, defs } = useBarChartData({ + const { barConfigs, enrichedKeys, enrichedKeysMap, defs } = useBarChartData({ data, indexBy, keys, @@ -138,7 +138,7 @@ export const GraphWidgetBarChart = ({ return ( ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData.ts index 07be9a04ab..78ca3787fb 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData.ts @@ -72,6 +72,11 @@ export const useBarChartData = ({ }; }); + const enrichedKeysMap = useMemo( + () => new Map(enrichedKeys.map((item) => [item.key, item])), + [enrichedKeys], + ); + const defs = barConfigs.map((bar) => { const isHovered = hoveredBar?.key === bar.key && hoveredBar?.indexValue === bar.indexValue; @@ -88,6 +93,7 @@ export const useBarChartData = ({ seriesConfigMap, barConfigs, enrichedKeys, + enrichedKeysMap, defs, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx new file mode 100644 index 0000000000..257b52ef00 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx @@ -0,0 +1,141 @@ +import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer'; +import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend'; +import { GraphWidgetTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip'; +import { PieChartEndLines } from '@/page-layout/widgets/graph/graphWidgetPieChart/components/PieChartEndLines'; +import { usePieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData'; +import { usePieChartHandlers } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartHandlers'; +import { usePieChartTooltip } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartTooltip'; +import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry'; +import { + formatGraphValue, + type GraphValueFormatOptions, +} from '@/page-layout/widgets/graph/utils/graphFormatters'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { + ResponsivePie, + type PieCustomLayerProps, + type PieTooltipProps, +} from '@nivo/pie'; +import { isDefined } from 'twenty-shared/utils'; + +type GraphWidgetPieChartProps = { + data: PieChartDataItem[]; + showLegend?: boolean; + id: string; +} & GraphValueFormatOptions; + +const StyledContainer = styled.div` + align-items: center; + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + width: 100%; +`; + +export const GraphWidgetPieChart = ({ + data, + showLegend = true, + id, + displayType, + decimals, + prefix, + suffix, + customFormatter, +}: GraphWidgetPieChartProps) => { + const theme = useTheme(); + const colorRegistry = createGraphColorRegistry(theme); + + const formatOptions: GraphValueFormatOptions = { + displayType, + decimals, + prefix, + suffix, + customFormatter, + }; + + const { + hoveredSliceId, + setHoveredSliceId, + handleSliceClick, + hasClickableItems, + } = usePieChartHandlers({ data }); + + const { enrichedData, enrichedDataMap, defs, fill } = usePieChartData({ + data, + colorRegistry, + id, + hoveredSliceId, + }); + + const { createTooltipData } = usePieChartTooltip({ + enrichedData, + data, + formatOptions, + displayType, + }); + + const renderSliceEndLines = ( + layerProps: PieCustomLayerProps, + ) => ( + + ); + + const renderTooltip = ({ datum }: PieTooltipProps) => { + const tooltipData = createTooltipData(datum); + if (!isDefined(tooltipData)) return null; + + return ( + + ); + }; + + return ( + + + `url(#${item.gradientId})`)} + borderWidth={0} + enableArcLinkLabels={false} + enableArcLabels={false} + tooltip={renderTooltip} + onClick={handleSliceClick} + onMouseEnter={(datum) => setHoveredSliceId(datum.id)} + onMouseLeave={() => setHoveredSliceId(null)} + defs={defs} + fill={fill} + layers={['arcs', renderSliceEndLines]} + /> + + ({ + id: item.id, + label: item.label || item.id, + formattedValue: formatGraphValue( + displayType === 'percentage' ? item.percentage / 100 : item.value, + formatOptions, + ), + color: item.colorScheme.solid, + }))} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/PieChartEndLines.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/PieChartEndLines.tsx new file mode 100644 index 0000000000..3d066924ec --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/PieChartEndLines.tsx @@ -0,0 +1,68 @@ +import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData'; +import { calculatePieChartEndLineCoordinates } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartEndLineCoordinates'; +import { useTheme } from '@emotion/react'; +import { type ComputedDatum } from '@nivo/pie'; +import { isDefined } from 'twenty-shared/utils'; + +type PieChartEndLinesProps = { + dataWithArc: readonly ComputedDatum<{ + id: string; + value: number; + label?: string; + }>[]; + centerX: number; + centerY: number; + innerRadius: number; + radius: number; + enrichedDataMap: Map; +}; + +export const PieChartEndLines = ({ + dataWithArc, + centerX, + centerY, + innerRadius, + radius, + enrichedDataMap, +}: PieChartEndLinesProps) => { + const theme = useTheme(); + + if ( + !isDefined(dataWithArc) || + !Array.isArray(dataWithArc) || + dataWithArc.length < 2 + ) { + return null; + } + + return ( + + {dataWithArc.map((datum) => { + const enrichedItem = enrichedDataMap.get(datum.id); + const lineColor = enrichedItem + ? enrichedItem.colorScheme.solid + : theme.border.color.strong; + + const { x1, y1, x2, y2 } = calculatePieChartEndLineCoordinates( + datum.arc.endAngle, + centerX, + centerY, + innerRadius, + radius, + ); + + return ( + + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/__tests__/usePieChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/__tests__/usePieChartData.test.ts new file mode 100644 index 0000000000..cf7030e9c0 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/__tests__/usePieChartData.test.ts @@ -0,0 +1,208 @@ +import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; +import { type DatumId } from '@nivo/pie'; +import { renderHook } from '@testing-library/react'; +import { usePieChartData } from '../usePieChartData'; + +describe('usePieChartData', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockColorRegistry: GraphColorRegistry = { + red: { + name: 'red', + gradient: { + normal: ['red1', 'red2'], + hover: ['red3', 'red4'], + }, + solid: 'redSolid', + }, + blue: { + name: 'blue', + gradient: { + normal: ['blue1', 'blue2'], + hover: ['blue3', 'blue4'], + }, + solid: 'blueSolid', + }, + }; + + const mockData: PieChartDataItem[] = [ + { id: 'item1', value: 30, label: 'Item 1' }, + { id: 'item2', value: 50, label: 'Item 2' }, + { id: 'item3', value: 20, label: 'Item 3' }, + ]; + + it('should enrich data with color schemes and percentages', () => { + const { result } = renderHook(() => + usePieChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + id: 'test-chart', + hoveredSliceId: null, + }), + ); + + expect(result.current.enrichedData).toHaveLength(3); + expect(result.current.enrichedData[0]).toMatchObject({ + id: 'item1', + value: 30, + label: 'Item 1', + percentage: 30, + colorScheme: mockColorRegistry.red, + isHovered: false, + gradientId: 'redGradient-test-chart-0', + }); + expect(result.current.enrichedData[1].percentage).toBe(50); + expect(result.current.enrichedData[2].percentage).toBe(20); + }); + + it('should calculate middle angles for each slice', () => { + const { result } = renderHook(() => + usePieChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + id: 'test-chart', + hoveredSliceId: null, + }), + ); + + expect(result.current.enrichedData[0].middleAngle).toBe(54); + expect(result.current.enrichedData[1].middleAngle).toBe(198); + expect(result.current.enrichedData[2].middleAngle).toBe(324); + }); + + it('should handle hover state', () => { + const { result, rerender } = renderHook( + ({ hoveredSliceId }: { hoveredSliceId: DatumId | null }) => + usePieChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + id: 'test-chart', + hoveredSliceId, + }), + { initialProps: { hoveredSliceId: null as DatumId | null } }, + ); + + expect(result.current.enrichedData[1].isHovered).toBe(false); + + rerender({ hoveredSliceId: 'item2' as DatumId }); + expect(result.current.enrichedData[1].isHovered).toBe(true); + expect(result.current.enrichedData[0].isHovered).toBe(false); + expect(result.current.enrichedData[2].isHovered).toBe(false); + }); + + it('should generate gradient definitions', () => { + const { result } = renderHook(() => + usePieChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + id: 'test-chart', + hoveredSliceId: 'item1', + }), + ); + + expect(result.current.defs).toHaveLength(3); + expect(result.current.defs[0]).toMatchObject({ + id: 'redGradient-test-chart-0', + type: 'linearGradient', + colors: [ + { offset: 0, color: 'red3' }, + { offset: 100, color: 'red4' }, + ], + }); + }); + + it('should generate fill configuration', () => { + const { result } = renderHook(() => + usePieChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + id: 'test-chart', + hoveredSliceId: null, + }), + ); + + expect(result.current.fill).toEqual([ + { match: { id: 'item1' }, id: 'redGradient-test-chart-0' }, + { match: { id: 'item2' }, id: 'blueGradient-test-chart-1' }, + { match: { id: 'item3' }, id: 'redGradient-test-chart-2' }, + ]); + }); + + it('should handle empty data', () => { + const { result } = renderHook(() => + usePieChartData({ + data: [], + colorRegistry: mockColorRegistry, + id: 'test-chart', + hoveredSliceId: null, + }), + ); + + expect(result.current.enrichedData).toEqual([]); + expect(result.current.defs).toEqual([]); + expect(result.current.fill).toEqual([]); + }); + + it('should handle single data item', () => { + const singleData: PieChartDataItem[] = [ + { id: 'single', value: 100, label: 'Single Item' }, + ]; + + const { result } = renderHook(() => + usePieChartData({ + data: singleData, + colorRegistry: mockColorRegistry, + id: 'test-chart', + hoveredSliceId: null, + }), + ); + + expect(result.current.enrichedData[0].percentage).toBe(100); + expect(result.current.enrichedData[0].middleAngle).toBe(180); + }); + + it('should handle custom colors in data items', () => { + const dataWithColors: PieChartDataItem[] = [ + { id: 'item1', value: 50, label: 'Item 1', color: 'blue' }, + { id: 'item2', value: 50, label: 'Item 2' }, + ]; + + const { result } = renderHook(() => + usePieChartData({ + data: dataWithColors, + colorRegistry: mockColorRegistry, + id: 'test-chart', + hoveredSliceId: null, + }), + ); + + expect(result.current.enrichedData[0].colorScheme.name).toBe('blue'); + expect(result.current.enrichedData[1].colorScheme.name).toBe('blue'); + }); + + it('should memoize calculations', () => { + const { result, rerender } = renderHook( + ({ id }) => + usePieChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + id, + hoveredSliceId: null, + }), + { initialProps: { id: 'test-chart' } }, + ); + + const firstEnrichedData = result.current.enrichedData; + const firstDefs = result.current.defs; + const firstFill = result.current.fill; + + rerender({ id: 'test-chart' }); + + expect(result.current.enrichedData).toBe(firstEnrichedData); + expect(result.current.defs).toBe(firstDefs); + expect(result.current.fill).toBe(firstFill); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData.ts new file mode 100644 index 0000000000..4092ef65d3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData.ts @@ -0,0 +1,77 @@ +import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData'; +import { useMemo } from 'react'; +import { calculatePieChartAngles } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartAngles'; +import { calculatePieChartPercentage } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartPercentage'; +import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; +import { createGradientDef } from '@/page-layout/widgets/graph/utils/createGradientDef'; +import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme'; +import { type DatumId } from '@nivo/pie'; + +type UsePieChartDataProps = { + data: PieChartDataItem[]; + colorRegistry: GraphColorRegistry; + id: string; + hoveredSliceId: DatumId | null; +}; + +export const usePieChartData = ({ + data, + colorRegistry, + id, + hoveredSliceId, +}: UsePieChartDataProps) => { + const enrichedData = useMemo((): PieChartEnrichedData[] => { + const totalValue = data.reduce((sum, item) => sum + item.value, 0); + + let cumulativeAngle = 0; + return data.map((item, index) => { + const colorScheme = getColorScheme(colorRegistry, item.color, index); + const isHovered = hoveredSliceId === item.id; + const gradientId = `${colorScheme.name}Gradient-${id}-${index}`; + const percentage = calculatePieChartPercentage(item.value, totalValue); + + const angles = calculatePieChartAngles(percentage, cumulativeAngle); + cumulativeAngle = angles.newCumulativeAngle; + + return { + ...item, + gradientId, + colorScheme, + isHovered, + percentage, + middleAngle: angles.middleAngle, + }; + }); + }, [data, colorRegistry, id, hoveredSliceId]); + + const defs = useMemo(() => { + return enrichedData.map((item) => + createGradientDef( + item.colorScheme, + item.gradientId, + item.isHovered, + item.middleAngle, + ), + ); + }, [enrichedData]); + + const fill = useMemo(() => { + return enrichedData.map((item) => ({ + match: { id: item.id }, + id: item.gradientId, + })); + }, [enrichedData]); + + const enrichedDataMap = useMemo( + () => new Map(enrichedData.map((item) => [item.id, item])), + [enrichedData], + ); + + return { + enrichedData, + enrichedDataMap, + defs, + fill, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartHandlers.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartHandlers.ts new file mode 100644 index 0000000000..8cbb062c81 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartHandlers.ts @@ -0,0 +1,30 @@ +import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type ComputedDatum, type DatumId } from '@nivo/pie'; +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +type UsePieChartHandlersProps = { + data: PieChartDataItem[]; +}; + +export const usePieChartHandlers = ({ data }: UsePieChartHandlersProps) => { + const [hoveredSliceId, setHoveredSliceId] = useState(null); + + const handleSliceClick = ( + datum: ComputedDatum<{ id: string; value: number; label?: string }>, + ) => { + const clickedItem = data.find((d) => d.id === datum.id); + if (isDefined(clickedItem?.to)) { + window.location.href = clickedItem.to; + } + }; + + const hasClickableItems = data.some((item) => isDefined(item.to)); + + return { + hoveredSliceId, + setHoveredSliceId, + handleSliceClick, + hasClickableItems, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartTooltip.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartTooltip.ts new file mode 100644 index 0000000000..688173fe2c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartTooltip.ts @@ -0,0 +1,49 @@ +import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData'; +import { + formatGraphValue, + type GraphValueFormatOptions, +} from '@/page-layout/widgets/graph/utils/graphFormatters'; +import { type ComputedDatum } from '@nivo/pie'; +import { isDefined } from 'twenty-shared/utils'; + +type UsePieChartTooltipProps = { + enrichedData: PieChartEnrichedData[]; + data: PieChartDataItem[]; + formatOptions: GraphValueFormatOptions; + displayType?: string; +}; + +export const usePieChartTooltip = ({ + enrichedData, + data, + formatOptions, + displayType, +}: UsePieChartTooltipProps) => { + const createTooltipData = ( + datum: ComputedDatum<{ id: string; value: number; label?: string }>, + ) => { + const item = enrichedData.find((d) => d.id === datum.id); + if (!isDefined(item)) return null; + + const dataItem = data.find((d) => d.id === datum.id); + + const formattedValue = + displayType === 'percentage' + ? formatGraphValue(item.percentage / 100, formatOptions) + : `${formatGraphValue(item.value, formatOptions)} (${item.percentage.toFixed(1)}%)`; + + return { + tooltipItem: { + label: item.label || item.id, + formattedValue, + dotColor: item.colorScheme.solid, + }, + showClickHint: isDefined(dataItem?.to), + }; + }; + + return { + createTooltipData, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem.ts new file mode 100644 index 0000000000..07aaf3f4ec --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem.ts @@ -0,0 +1,9 @@ +import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor'; + +export type PieChartDataItem = { + id: string; + value: number; + label?: string; + color?: GraphColor; + to?: string; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData.ts new file mode 100644 index 0000000000..89535dc365 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData.ts @@ -0,0 +1,10 @@ +import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme'; + +export type PieChartEnrichedData = PieChartDataItem & { + gradientId: string; + colorScheme: GraphColorScheme; + isHovered: boolean; + percentage: number; + middleAngle: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartAngles.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartAngles.test.ts new file mode 100644 index 0000000000..d6996eb77b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartAngles.test.ts @@ -0,0 +1,88 @@ +import { calculatePieChartAngles } from '../calculatePieChartAngles'; +describe('calculatePieChartAngles', () => { + it('should calculate angles for a quarter slice (25%)', () => { + const result = calculatePieChartAngles(25, 0); + expect(result).toEqual({ + sliceAngle: 90, + middleAngle: 45, + newCumulativeAngle: 90, + }); + }); + it('should calculate angles for a half slice (50%)', () => { + const result = calculatePieChartAngles(50, 0); + expect(result).toEqual({ + sliceAngle: 180, + middleAngle: 90, + newCumulativeAngle: 180, + }); + }); + it('should calculate angles for a full circle (100%)', () => { + const result = calculatePieChartAngles(100, 0); + expect(result).toEqual({ + sliceAngle: 360, + middleAngle: 180, + newCumulativeAngle: 360, + }); + }); + it('should handle cumulative angles correctly', () => { + const result = calculatePieChartAngles(25, 90); + expect(result).toEqual({ + sliceAngle: 90, + middleAngle: 135, + newCumulativeAngle: 180, + }); + }); + it('should handle very small percentages', () => { + const result = calculatePieChartAngles(1, 0); + expect(result).toEqual({ + sliceAngle: 3.6, + middleAngle: 1.8, + newCumulativeAngle: 3.6, + }); + }); + it('should handle decimal percentages', () => { + const result = calculatePieChartAngles(33.33, 0); + expect(result.sliceAngle).toBeCloseTo(119.988, 2); + expect(result.middleAngle).toBeCloseTo(59.994, 2); + expect(result.newCumulativeAngle).toBeCloseTo(119.988, 2); + }); + it('should handle zero percentage', () => { + const result = calculatePieChartAngles(0, 45); + expect(result).toEqual({ + sliceAngle: 0, + middleAngle: 45, + newCumulativeAngle: 45, + }); + }); + it('should calculate sequential slices correctly', () => { + let cumulative = 0; + const slice1 = calculatePieChartAngles(30, cumulative); + expect(slice1.sliceAngle).toBe(108); + expect(slice1.middleAngle).toBe(54); + cumulative = slice1.newCumulativeAngle; + const slice2 = calculatePieChartAngles(50, cumulative); + expect(slice2.sliceAngle).toBe(180); + expect(slice2.middleAngle).toBe(198); + cumulative = slice2.newCumulativeAngle; + const slice3 = calculatePieChartAngles(20, cumulative); + expect(slice3.sliceAngle).toBe(72); + expect(slice3.middleAngle).toBe(324); + expect(slice3.newCumulativeAngle).toBe(360); + }); + it('should handle negative percentages', () => { + const result = calculatePieChartAngles(-10, 0); + expect(result).toEqual({ + sliceAngle: -36, + middleAngle: -18, + newCumulativeAngle: -36, + }); + }); + it('should handle percentages over 100', () => { + const result = calculatePieChartAngles(150, 0); + expect(result).toEqual({ + sliceAngle: 540, + middleAngle: 270, + newCumulativeAngle: 540, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartEndLineCoordinates.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartEndLineCoordinates.test.ts new file mode 100644 index 0000000000..8bdbfe8e64 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartEndLineCoordinates.test.ts @@ -0,0 +1,133 @@ +import { calculatePieChartEndLineCoordinates } from '../calculatePieChartEndLineCoordinates'; +describe('calculatePieChartEndLineCoordinates', () => { + it('should calculate coordinates for angle 0 (top)', () => { + const result = calculatePieChartEndLineCoordinates(0, 100, 100, 50, 80); + expect(result).toEqual({ + x1: 100, + y1: 50, + x2: 100, + y2: 20, + }); + }); + it('should calculate coordinates for angle π/2 (right)', () => { + const result = calculatePieChartEndLineCoordinates( + Math.PI / 2, + 100, + 100, + 50, + 80, + ); + expect(result).toEqual({ + x1: 150, + y1: 100, + x2: 180, + y2: 100, + }); + }); + it('should calculate coordinates for angle π (bottom)', () => { + const result = calculatePieChartEndLineCoordinates( + Math.PI, + 100, + 100, + 50, + 80, + ); + expect(result).toEqual({ + x1: 100, + y1: 150, + x2: 100, + y2: 180, + }); + }); + it('should calculate coordinates for angle 3π/2 (left)', () => { + const result = calculatePieChartEndLineCoordinates( + (3 * Math.PI) / 2, + 100, + 100, + 50, + 80, + ); + expect(result.x1).toBeCloseTo(50, 5); + expect(result.y1).toBeCloseTo(100, 5); + expect(result.x2).toBeCloseTo(20, 5); + expect(result.y2).toBeCloseTo(100, 5); + }); + it('should calculate coordinates for 45-degree angle', () => { + const angle = Math.PI / 4; + const result = calculatePieChartEndLineCoordinates(angle, 100, 100, 50, 80); + const expectedCos = Math.sqrt(2) / 2; + const expectedSin = -Math.sqrt(2) / 2; + expect(result.x1).toBeCloseTo(100 + expectedCos * 50, 5); + expect(result.y1).toBeCloseTo(100 + expectedSin * 50, 5); + expect(result.x2).toBeCloseTo(100 + expectedCos * 80, 5); + expect(result.y2).toBeCloseTo(100 + expectedSin * 80, 5); + }); + it('should handle different center positions', () => { + const result = calculatePieChartEndLineCoordinates( + Math.PI / 2, + 200, + 150, + 30, + 60, + ); + expect(result).toEqual({ + x1: 230, + y1: 150, + x2: 260, + y2: 150, + }); + }); + it('should handle zero radius', () => { + const result = calculatePieChartEndLineCoordinates( + Math.PI / 4, + 100, + 100, + 0, + 0, + ); + expect(result).toEqual({ + x1: 100, + y1: 100, + x2: 100, + y2: 100, + }); + }); + it('should handle negative angles', () => { + const result = calculatePieChartEndLineCoordinates( + -Math.PI / 2, + 100, + 100, + 50, + 80, + ); + expect(result.x1).toBeCloseTo(50, 5); + expect(result.y1).toBeCloseTo(100, 5); + expect(result.x2).toBeCloseTo(20, 5); + expect(result.y2).toBeCloseTo(100, 5); + }); + it('should create a line from inner to outer radius', () => { + const angle = Math.PI / 6; + const centerX = 100; + const centerY = 100; + const innerRadius = 40; + const outerRadius = 70; + const result = calculatePieChartEndLineCoordinates( + angle, + centerX, + centerY, + innerRadius, + outerRadius, + ); + const dist1 = Math.sqrt( + (result.x1 - centerX) ** 2 + (result.y1 - centerY) ** 2, + ); + const dist2 = Math.sqrt( + (result.x2 - centerX) ** 2 + (result.y2 - centerY) ** 2, + ); + expect(dist1).toBeCloseTo(innerRadius, 5); + expect(dist2).toBeCloseTo(outerRadius, 5); + const angle1 = Math.atan2(result.y1 - centerY, result.x1 - centerX); + const angle2 = Math.atan2(result.y2 - centerY, result.x2 - centerX); + expect(angle1).toBeCloseTo(angle2, 5); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartPercentage.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartPercentage.test.ts new file mode 100644 index 0000000000..d3303d2da9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartPercentage.test.ts @@ -0,0 +1,57 @@ +import { calculatePieChartPercentage } from '../calculatePieChartPercentage'; +describe('calculatePieChartPercentage', () => { + it('should calculate percentage for normal values', () => { + expect(calculatePieChartPercentage(25, 100)).toBe(25); + expect(calculatePieChartPercentage(50, 100)).toBe(50); + expect(calculatePieChartPercentage(75, 100)).toBe(75); + }); + it('should calculate percentage with decimal values', () => { + expect(calculatePieChartPercentage(33, 100)).toBe(33); + expect(calculatePieChartPercentage(1, 3)).toBeCloseTo(33.333, 2); + expect(calculatePieChartPercentage(2, 3)).toBeCloseTo(66.667, 2); + }); + it('should handle zero value', () => { + expect(calculatePieChartPercentage(0, 100)).toBe(0); + expect(calculatePieChartPercentage(0, 1)).toBe(0); + }); + it('should handle zero total (divide by zero)', () => { + expect(calculatePieChartPercentage(10, 0)).toBe(0); + expect(calculatePieChartPercentage(0, 0)).toBe(0); + expect(calculatePieChartPercentage(-5, 0)).toBe(0); + }); + it('should handle negative total', () => { + expect(calculatePieChartPercentage(10, -100)).toBe(0); + expect(calculatePieChartPercentage(-10, -100)).toBe(0); + }); + it('should handle value greater than total', () => { + expect(calculatePieChartPercentage(150, 100)).toBe(150); + expect(calculatePieChartPercentage(200, 50)).toBe(400); + }); + it('should handle very small values', () => { + expect(calculatePieChartPercentage(0.01, 100)).toBe(0.01); + expect(calculatePieChartPercentage(0.001, 1)).toBe(0.1); + }); + it('should handle very large values', () => { + expect(calculatePieChartPercentage(1000000, 10000000)).toBe(10); + expect(calculatePieChartPercentage(1e10, 1e12)).toBe(1); + }); + it('should maintain precision for financial calculations', () => { + const value1 = 33.33; + const value2 = 33.33; + const value3 = 33.34; + const total = value1 + value2 + value3; + expect(calculatePieChartPercentage(value1, total)).toBeCloseTo(33.33, 2); + expect(calculatePieChartPercentage(value2, total)).toBeCloseTo(33.33, 2); + expect(calculatePieChartPercentage(value3, total)).toBeCloseTo(33.34, 2); + }); + it('should handle edge case with Infinity', () => { + expect(calculatePieChartPercentage(Infinity, 100)).toBe(Infinity); + expect(calculatePieChartPercentage(100, Infinity)).toBe(0); + expect(calculatePieChartPercentage(Infinity, Infinity)).toBeNaN(); + }); + it('should handle NaN inputs', () => { + expect(calculatePieChartPercentage(NaN, 100)).toBeNaN(); + expect(calculatePieChartPercentage(100, NaN)).toBeNaN(); + expect(calculatePieChartPercentage(NaN, NaN)).toBeNaN(); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartAngles.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartAngles.ts new file mode 100644 index 0000000000..144915e9d4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartAngles.ts @@ -0,0 +1,14 @@ +export const calculatePieChartAngles = ( + percentage: number, + cumulativeAngle: number, +) => { + const sliceAngle = (percentage / 100) * 360; + const middleAngle = cumulativeAngle + sliceAngle / 2; + const newCumulativeAngle = cumulativeAngle + sliceAngle; + + return { + sliceAngle, + middleAngle, + newCumulativeAngle, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartEndLineCoordinates.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartEndLineCoordinates.ts new file mode 100644 index 0000000000..51c1f87615 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartEndLineCoordinates.ts @@ -0,0 +1,15 @@ +export const calculatePieChartEndLineCoordinates = ( + angle: number, + centerX: number, + centerY: number, + innerRadius: number, + outerRadius: number, +) => { + const adjustedAngle = angle - Math.PI / 2; + const x1 = centerX + Math.cos(adjustedAngle) * innerRadius; + const y1 = centerY + Math.sin(adjustedAngle) * innerRadius; + const x2 = centerX + Math.cos(adjustedAngle) * outerRadius; + const y2 = centerY + Math.sin(adjustedAngle) * outerRadius; + + return { x1, y1, x2, y2 }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartPercentage.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartPercentage.ts new file mode 100644 index 0000000000..41d03f32ac --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartPercentage.ts @@ -0,0 +1,9 @@ +export const calculatePieChartPercentage = ( + value: number, + totalValue: number, +): number => { + if (isNaN(value) || isNaN(totalValue)) { + return NaN; + } + return totalValue > 0 ? (value / totalValue) * 100 : 0; +};