[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
This commit is contained in:
-222
@@ -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<DatumId | null>(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 (
|
||||
<GraphWidgetTooltip
|
||||
items={[
|
||||
{
|
||||
label: item.label || item.id,
|
||||
formattedValue: formattedWithPercentage,
|
||||
dotColor: item.colorScheme.solid,
|
||||
},
|
||||
]}
|
||||
showClickHint={isDefined(dataItem?.to)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<g>
|
||||
{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 (
|
||||
<line
|
||||
key={`${datum.id}-separator`}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={lineColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const hasClickableItems = data.some((item) => isDefined(item.to));
|
||||
|
||||
return (
|
||||
<StyledContainer id={id}>
|
||||
<GraphWidgetChartContainer
|
||||
$isClickable={hasClickableItems}
|
||||
$cursorSelector='svg g path[fill^="url(#"]'
|
||||
>
|
||||
<ResponsivePie
|
||||
data={data}
|
||||
innerRadius={0.8}
|
||||
colors={enrichedData.map((item) => `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]}
|
||||
/>
|
||||
</GraphWidgetChartContainer>
|
||||
<GraphWidgetLegend
|
||||
show={showLegend}
|
||||
items={enrichedData.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label || item.id,
|
||||
formattedValue: formatGraphValue(
|
||||
displayType === 'percentage' ? item.percentage / 100 : item.value,
|
||||
formatOptions,
|
||||
),
|
||||
color: item.colorScheme.solid,
|
||||
}))}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+5
-5
@@ -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(() =>
|
||||
|
||||
+1
-1
@@ -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<typeof GraphWidgetBarChart> = {
|
||||
title: 'Modules/PageLayout/Widgets/GraphWidgetBarChart',
|
||||
|
||||
+1
-1
@@ -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<typeof GraphWidgetLineChart> = {
|
||||
title: 'Modules/PageLayout/Widgets/GraphWidgetLineChart',
|
||||
|
||||
+1
-1
@@ -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<typeof GraphWidgetPieChart> = {
|
||||
title: 'Modules/PageLayout/Widgets/GraphWidgetPieChart',
|
||||
|
||||
+3
-3
@@ -6,19 +6,19 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type BarChartEndLinesProps = {
|
||||
bars: readonly ComputedBarDatum<BarChartDataItem>[];
|
||||
enrichedKeys: BarChartEnrichedKey[];
|
||||
enrichedKeysMap: Map<string, BarChartEnrichedKey>;
|
||||
layout: 'vertical' | 'horizontal';
|
||||
};
|
||||
|
||||
export const BarChartEndLines = ({
|
||||
bars,
|
||||
enrichedKeys,
|
||||
enrichedKeysMap,
|
||||
layout,
|
||||
}: BarChartEndLinesProps) => {
|
||||
return (
|
||||
<g>
|
||||
{bars.map((bar: ComputedBarDatum<BarChartDataItem>, 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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 (
|
||||
<BarChartEndLines
|
||||
bars={props.bars}
|
||||
enrichedKeys={enrichedKeys}
|
||||
enrichedKeysMap={enrichedKeysMap}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
|
||||
+6
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+141
@@ -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<PieChartDataItem>,
|
||||
) => (
|
||||
<PieChartEndLines
|
||||
dataWithArc={layerProps.dataWithArc}
|
||||
centerX={layerProps.centerX}
|
||||
centerY={layerProps.centerY}
|
||||
innerRadius={layerProps.innerRadius}
|
||||
radius={layerProps.radius}
|
||||
enrichedDataMap={enrichedDataMap}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderTooltip = ({ datum }: PieTooltipProps<PieChartDataItem>) => {
|
||||
const tooltipData = createTooltipData(datum);
|
||||
if (!isDefined(tooltipData)) return null;
|
||||
|
||||
return (
|
||||
<GraphWidgetTooltip
|
||||
items={[tooltipData.tooltipItem]}
|
||||
showClickHint={tooltipData.showClickHint}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer id={id}>
|
||||
<GraphWidgetChartContainer
|
||||
$isClickable={hasClickableItems}
|
||||
$cursorSelector='svg g path[fill^="url(#"]'
|
||||
>
|
||||
<ResponsivePie
|
||||
data={data}
|
||||
innerRadius={0.8}
|
||||
colors={enrichedData.map((item) => `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]}
|
||||
/>
|
||||
</GraphWidgetChartContainer>
|
||||
<GraphWidgetLegend
|
||||
show={showLegend}
|
||||
items={enrichedData.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label || item.id,
|
||||
formattedValue: formatGraphValue(
|
||||
displayType === 'percentage' ? item.percentage / 100 : item.value,
|
||||
formatOptions,
|
||||
),
|
||||
color: item.colorScheme.solid,
|
||||
}))}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+68
@@ -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<string, PieChartEnrichedData>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<g>
|
||||
{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 (
|
||||
<line
|
||||
key={`${datum.id}-separator`}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={lineColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
+208
@@ -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);
|
||||
});
|
||||
});
|
||||
+77
@@ -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,
|
||||
};
|
||||
};
|
||||
+30
@@ -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<DatumId | null>(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,
|
||||
};
|
||||
};
|
||||
+49
@@ -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,
|
||||
};
|
||||
};
|
||||
+9
@@ -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;
|
||||
};
|
||||
+10
@@ -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;
|
||||
};
|
||||
+88
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+133
@@ -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);
|
||||
});
|
||||
});
|
||||
+57
@@ -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();
|
||||
});
|
||||
});
|
||||
+14
@@ -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,
|
||||
};
|
||||
};
|
||||
+15
@@ -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 };
|
||||
};
|
||||
+9
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user