[Dashboards] - refactor - gauge and number chart (#14550)

This is the fourth and last PR from the split of
https://github.com/twentyhq/twenty/pull/14458 - refactoring PieChart and
NumberChart widgets.
This commit is contained in:
nitin
2025-09-17 14:44:50 +00:00
committed by GitHub
parent 09b9071182
commit bc471deaba
15 changed files with 884 additions and 229 deletions
@@ -1,216 +0,0 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
type RadialBarCustomLayerProps,
ResponsiveRadialBar,
} from '@nivo/radial-bar';
import { useId, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
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 GaugeChartData = {
value: number;
min: number;
max: number;
color?: GraphColor;
to?: string;
label?: string;
};
type GraphWidgetGaugeChartProps = {
data: GaugeChartData;
showValue?: boolean;
showLegend?: boolean;
id: string;
} & GraphValueFormatOptions;
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
`;
const StyledH1Title = styled(H1Title)`
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -150%);
`;
export const GraphWidgetGaugeChart = ({
data,
showValue = true,
showLegend = true,
id,
displayType,
decimals,
prefix,
suffix,
customFormatter,
}: GraphWidgetGaugeChartProps) => {
const { value, min, max, color = 'blue', to, label = 'Value' } = data;
const theme = useTheme();
const instanceId = useId();
const [isHovered, setIsHovered] = useState(false);
const colorRegistry = createGraphColorRegistry(theme);
const colorScheme = getColorScheme(colorRegistry, color);
const formatOptions: GraphValueFormatOptions = {
displayType,
decimals,
prefix,
suffix,
customFormatter,
};
const formattedValue = formatGraphValue(value, formatOptions);
const normalizedValue = max === min ? 0 : ((value - min) / (max - min)) * 100;
const clampedNormalizedValue = Math.max(0, Math.min(100, normalizedValue));
const chartData = [
{
id: 'gauge',
data: [
{ x: 'value', y: clampedNormalizedValue },
{ x: 'empty', y: 100 - clampedNormalizedValue },
],
},
];
const gradientId = `gaugeGradient-${id}-${instanceId}`;
const gaugeAngle = -90 + (clampedNormalizedValue / 100) * 90;
const gradientDef = createGradientDef(
colorScheme,
gradientId,
isHovered,
gaugeAngle,
);
const defs = [gradientDef];
const handleClick = () => {
if (isDefined(to)) {
window.location.href = to;
}
};
const renderTooltip = () => {
const formattedWithPercentage = `${formattedValue} (${normalizedValue.toFixed(1)}%)`;
return (
<GraphWidgetTooltip
items={[
{
label: label,
formattedValue: formattedWithPercentage,
dotColor: colorScheme.solid,
},
]}
showClickHint={isDefined(to)}
/>
);
};
const renderValueEndLine = (props: RadialBarCustomLayerProps) => {
if (clampedNormalizedValue === 0) {
return null;
}
const { center, bars } = props;
const valueBar = bars?.find((bar) => bar.data.x === 'value');
if (!valueBar) {
return null;
}
const endAngle = valueBar.arc.endAngle - Math.PI / 2;
const arcInnerRadius = valueBar.arc.innerRadius;
const arcOuterRadius = valueBar.arc.outerRadius;
const [centerX, centerY] = center;
const x1 = centerX + Math.cos(endAngle) * arcInnerRadius;
const y1 = centerY + Math.sin(endAngle) * arcInnerRadius;
const x2 = centerX + Math.cos(endAngle) * arcOuterRadius;
const y2 = centerY + Math.sin(endAngle) * arcOuterRadius;
return (
<g>
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={colorScheme.solid}
strokeWidth={1}
/>
</g>
);
};
return (
<StyledContainer>
<GraphWidgetChartContainer
$isClickable={isDefined(to)}
$cursorSelector='svg g path[fill^="url(#"]'
>
<ResponsiveRadialBar
data={chartData}
startAngle={-90}
endAngle={90}
innerRadius={0.7}
padding={0.2}
colors={[`url(#${gradientId})`, theme.background.tertiary]}
defs={defs}
fill={[
{
match: (d: { x: string }) => d.x === 'value',
id: gradientId,
},
]}
enableTracks={false}
enableRadialGrid={false}
enableCircularGrid={false}
enableLabels={false}
isInteractive={true}
tooltip={renderTooltip}
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
layers={['bars', renderValueEndLine]}
/>
{showValue && (
<StyledH1Title
title={formattedValue}
fontColor={H1TitleFontColor.Primary}
/>
)}
</GraphWidgetChartContainer>
<GraphWidgetLegend
show={showLegend}
items={[
{
id: 'gauge',
label: label,
formattedValue: formattedValue,
color: colorScheme.solid,
},
]}
/>
</StyledContainer>
);
};
@@ -1,7 +1,7 @@
import { GraphType } from '@/page-layout/mocks/mockWidgets';
import { getDefaultWidgetData } from '@/page-layout/utils/getDefaultWidgetData';
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
import { GraphWidgetNumberChart } from '@/page-layout/widgets/graph/components/GraphWidgetNumberChart';
import { GraphWidgetNumberChart } from '@/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart';
import { type GraphWidget } from '@/page-layout/widgets/graph/types/GraphWidget';
import { lazy, Suspense } from 'react';
@@ -30,11 +30,11 @@ const GraphWidgetPieChart = lazy(() =>
);
const GraphWidgetGaugeChart = lazy(() =>
import('@/page-layout/widgets/graph/components/GraphWidgetGaugeChart').then(
(module) => ({
default: module.GraphWidgetGaugeChart,
}),
),
import(
'@/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart'
).then((module) => ({
default: module.GraphWidgetGaugeChart,
})),
);
type GraphWidgetRendererProps = {
@@ -1,7 +1,7 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetGaugeChart } from '../GraphWidgetGaugeChart';
import { GraphWidgetGaugeChart } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart';
const meta: Meta<typeof GraphWidgetGaugeChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetGaugeChart',
@@ -1,7 +1,7 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetNumberChart } from '../GraphWidgetNumberChart';
import { GraphWidgetNumberChart } from '@/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart';
const meta: Meta<typeof GraphWidgetNumberChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetNumberChart',
@@ -0,0 +1,49 @@
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
import { calculateGaugeChartEndLineCoordinates } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/utils/calculateGaugeChartEndLineCoordinates';
import { type RadialBarCustomLayerProps } from '@nivo/radial-bar';
import { isDefined } from 'twenty-shared/utils';
type GaugeChartEndLineProps = {
center: RadialBarCustomLayerProps['center'];
bars: RadialBarCustomLayerProps['bars'];
clampedNormalizedValue: number;
colorScheme: GraphColorScheme;
};
export const GaugeChartEndLine = ({
center,
bars,
clampedNormalizedValue,
colorScheme,
}: GaugeChartEndLineProps) => {
if (clampedNormalizedValue === 0) {
return null;
}
const valueBar = bars?.find((bar) => bar.data.x === 'value');
if (!isDefined(valueBar)) {
return null;
}
const [centerX, centerY] = center;
const { x1, y1, x2, y2 } = calculateGaugeChartEndLineCoordinates(
valueBar.arc.endAngle,
centerX,
centerY,
valueBar.arc.innerRadius,
valueBar.arc.outerRadius,
);
return (
<g>
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={colorScheme.solid}
strokeWidth={1}
/>
</g>
);
};
@@ -0,0 +1,179 @@
import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
import { GraphWidgetTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip';
import { GaugeChartEndLine } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/components/GaugeChartEndLine';
import { useGaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartData';
import { useGaugeChartHandlers } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartHandlers';
import { useGaugeChartTooltip } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartTooltip';
import { type GaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData';
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 { t } from '@lingui/core/macro';
import {
type RadialBarCustomLayerProps,
ResponsiveRadialBar,
} from '@nivo/radial-bar';
import { useId } from 'react';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
type GraphWidgetGaugeChartProps = {
data: GaugeChartData;
showValue?: boolean;
showLegend?: boolean;
id: string;
} & GraphValueFormatOptions;
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
`;
const StyledChartContainer = styled.div<{ $isClickable?: boolean }>`
flex: 1;
position: relative;
width: 100%;
${({ $isClickable }) =>
$isClickable &&
`
svg g path[fill^="url(#"] {
cursor: pointer;
}
`}
`;
const StyledH1Title = styled(H1Title)`
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -150%);
`;
export const GraphWidgetGaugeChart = ({
data,
showValue = true,
showLegend = true,
id,
displayType,
decimals,
prefix,
suffix,
customFormatter,
}: GraphWidgetGaugeChartProps) => {
const theme = useTheme();
const instanceId = useId();
const colorRegistry = createGraphColorRegistry(theme);
const formatOptions: GraphValueFormatOptions = {
displayType,
decimals,
prefix,
suffix,
customFormatter,
};
const { isHovered, setIsHovered, handleClick, hasClickableItems } =
useGaugeChartHandlers({ data });
const {
colorScheme,
normalizedValue,
clampedNormalizedValue,
chartData,
gradientId,
defs,
} = useGaugeChartData({
data,
colorRegistry,
id,
instanceId,
isHovered,
});
const { createTooltipData } = useGaugeChartTooltip({
value: data.value,
normalizedValue,
label: data.label || t`Value`,
colorScheme,
formatOptions,
to: data.to,
});
const formattedValue = formatGraphValue(data.value, formatOptions);
const renderValueEndLine = (props: RadialBarCustomLayerProps) => (
<GaugeChartEndLine
center={props.center}
bars={props.bars}
clampedNormalizedValue={clampedNormalizedValue}
colorScheme={colorScheme}
/>
);
const renderTooltip = () => {
const tooltipData = createTooltipData();
return (
<GraphWidgetTooltip
items={[tooltipData.tooltipItem]}
showClickHint={tooltipData.showClickHint}
/>
);
};
return (
<StyledContainer>
<StyledChartContainer $isClickable={hasClickableItems}>
<ResponsiveRadialBar
data={chartData}
startAngle={-90}
endAngle={90}
innerRadius={0.7}
padding={0.2}
colors={[`url(#${gradientId})`, theme.background.tertiary]}
defs={defs}
fill={[
{
match: (d: { x: string }) => d.x === 'value',
id: gradientId,
},
]}
enableTracks={false}
enableRadialGrid={false}
enableCircularGrid={false}
enableLabels={false}
isInteractive={true}
tooltip={renderTooltip}
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
layers={['bars', renderValueEndLine]}
/>
{showValue && (
<StyledH1Title
title={formattedValue}
fontColor={H1TitleFontColor.Primary}
/>
)}
</StyledChartContainer>
<GraphWidgetLegend
show={showLegend}
items={[
{
id: 'gauge',
label: data.label || t`Value`,
formattedValue: formattedValue,
color: colorScheme.solid,
},
]}
/>
</StyledContainer>
);
};
@@ -0,0 +1,305 @@
import { type GaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { renderHook } from '@testing-library/react';
import { useGaugeChartData } from '../useGaugeChartData';
describe('useGaugeChartData', () => {
const mockColorRegistry: GraphColorRegistry = {
blue: {
name: 'blue',
gradient: {
normal: ['blue1', 'blue2'],
hover: ['blue3', 'blue4'],
},
solid: 'blueSolid',
},
green: {
name: 'green',
gradient: {
normal: ['green1', 'green2'],
hover: ['green3', 'green4'],
},
solid: 'greenSolid',
},
};
it('should calculate normalized value correctly', () => {
const data: GaugeChartData = {
value: 75,
min: 0,
max: 100,
};
const { result } = renderHook(() =>
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
expect(result.current.normalizedValue).toBe(75);
expect(result.current.clampedNormalizedValue).toBe(75);
});
it('should handle min and max with non-zero min', () => {
const data: GaugeChartData = {
value: 150,
min: 100,
max: 200,
};
const { result } = renderHook(() =>
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
expect(result.current.normalizedValue).toBe(50);
expect(result.current.clampedNormalizedValue).toBe(50);
});
it('should clamp values above 100', () => {
const data: GaugeChartData = {
value: 150,
min: 0,
max: 100,
};
const { result } = renderHook(() =>
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
expect(result.current.normalizedValue).toBe(150);
expect(result.current.clampedNormalizedValue).toBe(100);
});
it('should clamp values below 0', () => {
const data: GaugeChartData = {
value: -25,
min: 0,
max: 100,
};
const { result } = renderHook(() =>
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
expect(result.current.normalizedValue).toBe(-25);
expect(result.current.clampedNormalizedValue).toBe(0);
});
it('should handle equal min and max', () => {
const data: GaugeChartData = {
value: 50,
min: 100,
max: 100,
};
const { result } = renderHook(() =>
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
expect(result.current.normalizedValue).toBe(0);
expect(result.current.clampedNormalizedValue).toBe(0);
});
it('should use custom color when provided', () => {
const data: GaugeChartData = {
value: 50,
min: 0,
max: 100,
color: 'green',
};
const { result } = renderHook(() =>
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
expect(result.current.colorScheme).toBe(mockColorRegistry.green);
});
it('should default to blue color when not provided', () => {
const data: GaugeChartData = {
value: 50,
min: 0,
max: 100,
};
const { result } = renderHook(() =>
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
expect(result.current.colorScheme).toBe(mockColorRegistry.blue);
});
it('should generate correct chart data structure', () => {
const data: GaugeChartData = {
value: 30,
min: 0,
max: 100,
};
const { result } = renderHook(() =>
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
expect(result.current.chartData).toEqual([
{
id: 'gauge',
data: [
{ x: 'value', y: 30 },
{ x: 'empty', y: 70 },
],
},
]);
});
it('should generate gradient with correct angle', () => {
const data: GaugeChartData = {
value: 50,
min: 0,
max: 100,
};
const { result } = renderHook(() =>
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
expect(result.current.defs[0].id).toBe(
'gaugeGradient-test-gauge-instance-1',
);
const expectedAngle = -45;
const expectedRadians = (expectedAngle * Math.PI) / 180 + Math.PI / 2;
const expectedSin = Math.sin(expectedRadians);
const expectedCos = -Math.cos(expectedRadians);
const expectedX1 = 50 - expectedSin * 50;
const expectedY1 = 50 - expectedCos * 50;
const actualX1 = parseFloat(result.current.defs[0].x1);
const actualY1 = parseFloat(result.current.defs[0].y1);
expect(actualX1).toBeCloseTo(expectedX1, 5);
expect(actualY1).toBeCloseTo(expectedY1, 5);
});
it('should handle hover state', () => {
const data: GaugeChartData = {
value: 50,
min: 0,
max: 100,
};
const { result } = renderHook(() =>
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: true,
}),
);
expect(result.current.defs[0].colors).toEqual([
{ offset: 0, color: 'blue3' },
{ offset: 100, color: 'blue4' },
]);
});
it('should generate unique gradient id', () => {
const data: GaugeChartData = {
value: 50,
min: 0,
max: 100,
};
const { result } = renderHook(() =>
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'unique-id',
instanceId: 'unique-instance',
isHovered: false,
}),
);
expect(result.current.gradientId).toBe(
'gaugeGradient-unique-id-unique-instance',
);
});
it('should memoize calculations', () => {
const data: GaugeChartData = {
value: 50,
min: 0,
max: 100,
};
const { result, rerender } = renderHook(
({ isHovered }) =>
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered,
}),
{ initialProps: { isHovered: false } },
);
const firstColorScheme = result.current.colorScheme;
const firstChartData = result.current.chartData;
rerender({ isHovered: false });
expect(result.current.colorScheme).toBe(firstColorScheme);
expect(result.current.chartData).toBe(firstChartData);
});
});
@@ -0,0 +1,68 @@
import { type GaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData';
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 { useMemo } from 'react';
type UseGaugeChartDataProps = {
data: GaugeChartData;
colorRegistry: GraphColorRegistry;
id: string;
instanceId: string;
isHovered: boolean;
};
export const useGaugeChartData = ({
data,
colorRegistry,
id,
instanceId,
isHovered,
}: UseGaugeChartDataProps) => {
const { value, min, max, color = 'blue' } = data;
const colorScheme = useMemo(
() => getColorScheme(colorRegistry, color),
[colorRegistry, color],
);
const normalizedValue = useMemo(
() => (max === min ? 0 : ((value - min) / (max - min)) * 100),
[value, min, max],
);
const clampedNormalizedValue = useMemo(
() => Math.max(0, Math.min(100, normalizedValue)),
[normalizedValue],
);
const chartData = useMemo(
() => [
{
id: 'gauge',
data: [
{ x: 'value', y: clampedNormalizedValue },
{ x: 'empty', y: 100 - clampedNormalizedValue },
],
},
],
[clampedNormalizedValue],
);
const gradientId = `gaugeGradient-${id}-${instanceId}`;
const gaugeAngle = -90 + (clampedNormalizedValue / 100) * 90;
const defs = useMemo(
() => [createGradientDef(colorScheme, gradientId, isHovered, gaugeAngle)],
[colorScheme, gradientId, isHovered, gaugeAngle],
);
return {
colorScheme,
normalizedValue,
clampedNormalizedValue,
chartData,
gradientId,
defs,
};
};
@@ -0,0 +1,26 @@
import { type GaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
type UseGaugeChartHandlersProps = {
data: GaugeChartData;
};
export const useGaugeChartHandlers = ({ data }: UseGaugeChartHandlersProps) => {
const [isHovered, setIsHovered] = useState(false);
const handleClick = () => {
if (isDefined(data.to)) {
window.location.href = data.to;
}
};
const hasClickableItems = isDefined(data.to);
return {
isHovered,
setIsHovered,
handleClick,
hasClickableItems,
};
};
@@ -0,0 +1,45 @@
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
import { isDefined } from 'twenty-shared/utils';
type UseGaugeChartTooltipProps = {
value: number;
normalizedValue: number;
label: string;
colorScheme: GraphColorScheme;
formatOptions: GraphValueFormatOptions;
to?: string;
};
export const useGaugeChartTooltip = ({
value,
normalizedValue,
label,
colorScheme,
formatOptions,
to,
}: UseGaugeChartTooltipProps) => {
const createTooltipData = () => {
// Format value based on display type to avoid redundant percentage display
const formattedValue =
formatOptions?.displayType === 'percentage'
? formatGraphValue(normalizedValue / 100, formatOptions)
: `${formatGraphValue(value, formatOptions)} (${normalizedValue.toFixed(1)}%)`;
return {
tooltipItem: {
label: label,
formattedValue,
dotColor: colorScheme.solid,
},
showClickHint: isDefined(to),
};
};
return {
createTooltipData,
};
};
@@ -0,0 +1,10 @@
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
export type GaugeChartData = {
value: number;
min: number;
max: number;
color?: GraphColor;
to?: string;
label?: string;
};
@@ -0,0 +1,168 @@
import { calculateGaugeChartEndLineCoordinates } from '../calculateGaugeChartEndLineCoordinates';
describe('calculateGaugeChartEndLineCoordinates', () => {
it('should calculate coordinates for angle π/2 (after adjustment points right)', () => {
const result = calculateGaugeChartEndLineCoordinates(
Math.PI / 2,
100,
100,
50,
80,
);
expect(result).toEqual({
x1: 150,
y1: 100,
x2: 180,
y2: 100,
});
});
it('should calculate coordinates for angle π (after adjustment points down)', () => {
const result = calculateGaugeChartEndLineCoordinates(
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 (after adjustment points left)', () => {
const result = calculateGaugeChartEndLineCoordinates(
(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 angle 0 (after adjustment points up)', () => {
const result = calculateGaugeChartEndLineCoordinates(0, 100, 100, 50, 80);
expect(result.x1).toBeCloseTo(100, 5);
expect(result.y1).toBeCloseTo(50, 5);
expect(result.x2).toBeCloseTo(100, 5);
expect(result.y2).toBeCloseTo(20, 5);
});
it('should calculate coordinates for 45-degree angle', () => {
const angle = Math.PI / 4;
const result = calculateGaugeChartEndLineCoordinates(
angle,
100,
100,
50,
80,
);
const adjustedAngle = angle - Math.PI / 2;
const expectedCos = Math.cos(adjustedAngle);
const expectedSin = Math.sin(adjustedAngle);
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 = calculateGaugeChartEndLineCoordinates(
Math.PI,
200,
150,
30,
60,
);
expect(result).toEqual({
x1: 200,
y1: 180,
x2: 200,
y2: 210,
});
});
it('should handle zero radius', () => {
const result = calculateGaugeChartEndLineCoordinates(
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 = calculateGaugeChartEndLineCoordinates(
-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 = calculateGaugeChartEndLineCoordinates(
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);
});
it('should handle angles greater than 2π', () => {
const result = calculateGaugeChartEndLineCoordinates(
2.5 * Math.PI,
100,
100,
50,
80,
);
const equivalentResult = calculateGaugeChartEndLineCoordinates(
0.5 * Math.PI,
100,
100,
50,
80,
);
expect(result.x1).toBeCloseTo(equivalentResult.x1, 10);
expect(result.y1).toBeCloseTo(equivalentResult.y1, 10);
expect(result.x2).toBeCloseTo(equivalentResult.x2, 10);
expect(result.y2).toBeCloseTo(equivalentResult.y2, 10);
});
});
@@ -0,0 +1,16 @@
export const calculateGaugeChartEndLineCoordinates = (
endAngle: number,
centerX: number,
centerY: number,
innerRadius: number,
outerRadius: number,
) => {
const adjustedAngle = endAngle - 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 };
};
@@ -1,3 +1,4 @@
import { formatNumberChartTrend } from '@/page-layout/widgets/graph/graphWidgetNumberChart/utils/formatNumberChartTrend';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
@@ -7,7 +8,6 @@ import {
IconTrendingUp,
} from 'twenty-ui/display';
// props are subjected to change
type GraphWidgetNumberChartProps = {
value: string;
trendPercentage: number;
@@ -43,8 +43,8 @@ export const GraphWidgetNumberChart = ({
trendPercentage,
}: GraphWidgetNumberChartProps) => {
const theme = useTheme();
const formattedPercentage =
trendPercentage >= 0 ? `+${trendPercentage}` : `${trendPercentage}`;
const formattedPercentage = formatNumberChartTrend(trendPercentage);
return (
<StyledContainer>
<StyledH1Title title={value} fontColor={H1TitleFontColor.Primary} />
@@ -58,8 +58,10 @@ export const GraphWidgetNumberChart = ({
size={theme.icon.size.md}
/>
) : (
// question for product - whats the exact red here? cant see it on figma
<IconTrendingDown color={theme.color.red} size={theme.icon.size.md} />
<IconTrendingDown
color={theme.adaptiveColors.red4}
size={theme.icon.size.md}
/>
)}
</StyledTrendIconContainer>
</StyledContainer>
@@ -0,0 +1,3 @@
export const formatNumberChartTrend = (trendPercentage: number): string => {
return trendPercentage >= 0 ? `+${trendPercentage}` : `${trendPercentage}`;
};