nitin
2025-12-03 21:21:44 +05:30
committed by GitHub
parent 267bfcadf8
commit 6c9abedc96
22 changed files with 371 additions and 609 deletions
@@ -12,10 +12,6 @@ describe('useBarChartData', () => {
const mockColorRegistry: GraphColorRegistry = {
green: {
name: 'green',
gradient: {
normal: ['green1', 'green2'],
hover: ['green3', 'green4'],
},
solid: 'greenSolid',
variations: [
'green1',
@@ -34,10 +30,6 @@ describe('useBarChartData', () => {
},
purple: {
name: 'purple',
gradient: {
normal: ['purple1', 'purple2'],
hover: ['purple3', 'purple4'],
},
solid: 'purpleSolid',
variations: [
'purple1',
@@ -99,7 +91,6 @@ describe('useBarChartData', () => {
indexValue: 'Jan',
colorScheme: {
name: 'green',
gradient: mockColorRegistry.green.gradient,
},
});
expect(result.current.barConfigs[1]).toMatchObject({
@@ -107,7 +98,6 @@ describe('useBarChartData', () => {
indexValue: 'Jan',
colorScheme: {
name: 'purple',
gradient: mockColorRegistry.purple.gradient,
},
});
});
@@ -129,7 +119,6 @@ describe('useBarChartData', () => {
label: 'Sales',
colorScheme: {
name: 'green',
gradient: mockColorRegistry.green.gradient,
},
});
expect(result.current.enrichedKeys[0].colorScheme.solid).toBeDefined();
@@ -138,7 +127,6 @@ describe('useBarChartData', () => {
label: 'Costs',
colorScheme: {
name: 'purple',
gradient: mockColorRegistry.purple.gradient,
},
});
expect(result.current.enrichedKeys[1].colorScheme.solid).toBeDefined();
@@ -2,7 +2,6 @@ import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphW
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';
@@ -17,7 +16,7 @@ import {
type RadialBarCustomLayerProps,
ResponsiveRadialBar,
} from '@nivo/radial-bar';
import { useId } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
type GraphWidgetGaugeChartProps = {
@@ -62,7 +61,6 @@ export const GraphWidgetGaugeChart = ({
data,
showValue = true,
showLegend = true,
id,
displayType,
decimals,
prefix,
@@ -71,7 +69,6 @@ export const GraphWidgetGaugeChart = ({
onGaugeClick,
}: GraphWidgetGaugeChartProps) => {
const theme = useTheme();
const instanceId = useId();
const colorRegistry = createGraphColorRegistry(theme);
const formatOptions: GraphValueFormatOptions = {
@@ -81,24 +78,17 @@ export const GraphWidgetGaugeChart = ({
suffix,
customFormatter,
};
const handleClick = () => {
onGaugeClick?.(data);
};
const { isHovered, setIsHovered, handleClick, hasClickableItems } =
useGaugeChartHandlers({ data, onGaugeClick });
const hasClickableItems = isDefined(onGaugeClick);
const {
colorScheme,
normalizedValue,
clampedNormalizedValue,
chartData,
gradientId,
defs,
} = useGaugeChartData({
data,
colorRegistry,
id,
instanceId,
isHovered,
});
const { colorScheme, normalizedValue, clampedNormalizedValue, chartData } =
useGaugeChartData({
data,
colorRegistry,
});
const { createTooltipData } = useGaugeChartTooltip({
value: data.value,
@@ -133,23 +123,13 @@ export const GraphWidgetGaugeChart = ({
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)}
onClick={hasClickableItems ? handleClick : undefined}
layers={['bars', renderValueEndLine]}
/>
{showValue && (
@@ -7,10 +7,6 @@ describe('useGaugeChartData', () => {
const mockColorRegistry: GraphColorRegistry = {
blue: {
name: 'blue',
gradient: {
normal: ['blue1', 'blue2'],
hover: ['blue3', 'blue4'],
},
solid: 'blueSolid',
variations: [
'blue1',
@@ -29,10 +25,6 @@ describe('useGaugeChartData', () => {
},
green: {
name: 'green',
gradient: {
normal: ['green1', 'green2'],
hover: ['green3', 'green4'],
},
solid: 'greenSolid',
variations: [
'green1',
@@ -62,9 +54,6 @@ describe('useGaugeChartData', () => {
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
@@ -83,9 +72,6 @@ describe('useGaugeChartData', () => {
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
@@ -104,9 +90,6 @@ describe('useGaugeChartData', () => {
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
@@ -125,9 +108,6 @@ describe('useGaugeChartData', () => {
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
@@ -146,9 +126,6 @@ describe('useGaugeChartData', () => {
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
@@ -168,9 +145,6 @@ describe('useGaugeChartData', () => {
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
@@ -188,9 +162,6 @@ describe('useGaugeChartData', () => {
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
@@ -208,9 +179,6 @@ describe('useGaugeChartData', () => {
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered: false,
}),
);
@@ -225,84 +193,6 @@ describe('useGaugeChartData', () => {
]);
});
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,
@@ -310,23 +200,16 @@ describe('useGaugeChartData', () => {
max: 100,
};
const { result, rerender } = renderHook(
({ isHovered }) =>
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
id: 'test-gauge',
instanceId: 'instance-1',
isHovered,
}),
{ initialProps: { isHovered: false } },
const { result } = renderHook(() =>
useGaugeChartData({
data,
colorRegistry: mockColorRegistry,
}),
);
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);
});
@@ -1,23 +1,16 @@
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;
@@ -53,20 +46,10 @@ export const useGaugeChartData = ({
[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,
};
};
@@ -1,28 +0,0 @@
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;
onGaugeClick?: (data: GaugeChartData) => void;
};
export const useGaugeChartHandlers = ({
data,
onGaugeClick,
}: UseGaugeChartHandlersProps) => {
const [isHovered, setIsHovered] = useState(false);
const handleClick = () => {
onGaugeClick?.(data);
};
const hasClickableItems = isDefined(onGaugeClick);
return {
isHovered,
setIsHovered,
handleClick,
hasClickableItems,
};
};
@@ -0,0 +1,99 @@
import { LineAnimatedAreaPath } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/LineAnimatedAreaPath';
import { LineAreaGradientDefs } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/LineAreaGradientDefs';
import { type LineChartEnrichedSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries';
import { computeLineAreaPath } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineAreaPath';
import {
type ComputedSeries,
type LineCustomSvgLayerProps,
type LineSeries,
} from '@nivo/line';
import { isNumberOrNaN } from '@sniptt/guards';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
type CustomStackedAreasLayerProps = {
series: readonly ComputedSeries<LineSeries>[];
innerHeight: number;
enrichedSeries: LineChartEnrichedSeries[];
enableArea: boolean;
yScale: LineCustomSvgLayerProps<LineSeries>['yScale'];
isStacked: boolean;
};
type AreaPathData = {
id: string;
path: string;
fillId: string;
};
export const CustomStackedAreasLayer = ({
series,
innerHeight,
enrichedSeries,
enableArea,
yScale,
isStacked,
}: CustomStackedAreasLayerProps) => {
const seriesById = useMemo(
() =>
new Map(
enrichedSeries.map((seriesItem) => [String(seriesItem.id), seriesItem]),
),
[enrichedSeries],
);
const baseline = useMemo(() => {
const scaled = yScale(0);
if (!isNumberOrNaN(scaled)) {
return innerHeight;
}
return Math.max(0, Math.min(scaled, innerHeight));
}, [innerHeight, yScale]);
const paths = useMemo(() => {
if (!enableArea) {
return [];
}
const initialAreaPathData: AreaPathData[] = [];
return series.reduce((acc, currentSeries, index) => {
const previousStackedSeries =
isStacked && index > 0 ? series[index - 1] : null;
const enriched = seriesById.get(String(currentSeries.id));
if (!isDefined(enriched)) {
return acc;
}
const path = computeLineAreaPath({
currentSeries,
previousStackedSeries,
baseline,
});
if (isDefined(path)) {
acc.push({
id: String(currentSeries.id),
path,
fillId: enriched.areaFillId,
});
}
return acc;
}, initialAreaPathData);
}, [enableArea, series, seriesById, baseline, isStacked]);
if (!enableArea) {
return null;
}
return (
<g>
<LineAreaGradientDefs enrichedSeries={enrichedSeries} />
{paths.map(({ id, path, fillId }) => (
<LineAnimatedAreaPath key={id} path={path} fillId={fillId} />
))}
</g>
);
};
@@ -5,6 +5,7 @@ import {
type SliceHoverData,
} from '@/page-layout/widgets/graph/graphWidgetLineChart/components/CustomCrosshairLayer';
import { CustomPointLabelsLayer } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/CustomPointLabelsLayer';
import { CustomStackedAreasLayer } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/CustomStackedAreasLayer';
import { GraphLineChartTooltip } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphLineChartTooltip';
import { LINE_CHART_MARGIN_BOTTOM } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginBottom';
import { LINE_CHART_MARGIN_LEFT } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginLeft';
@@ -34,12 +35,13 @@ import {
type Point,
type SliceTooltipProps,
} from '@nivo/line';
import { useCallback, useId, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useDebouncedCallback } from 'use-debounce';
type CrosshairLayerProps = LineCustomSvgLayerProps<LineSeries>;
type PointLabelsLayerProps = LineCustomSvgLayerProps<LineSeries>;
type StackedAreasLayerProps = LineCustomSvgLayerProps<LineSeries>;
type GraphWidgetLineChartProps = {
data: LineChartSeries[];
@@ -87,7 +89,6 @@ export const GraphWidgetLineChart = ({
onSliceClick,
}: GraphWidgetLineChartProps) => {
const theme = useTheme();
const instanceId = useId();
const colorRegistry = createGraphColorRegistry(theme);
const chartTheme = useLineChartTheme();
const containerRef = useRef<HTMLDivElement>(null);
@@ -105,15 +106,11 @@ export const GraphWidgetLineChart = ({
const effectiveMinimumValue = rangeMin ?? calculatedValueRange.minimum;
const effectiveMaximumValue = rangeMax ?? calculatedValueRange.maximum;
const { enrichedSeries, nivoData, defs, fill, colors, legendItems } =
useLineChartData({
data,
colorRegistry,
id,
instanceId,
enableArea,
theme,
});
const { enrichedSeries, nivoData, colors, legendItems } = useLineChartData({
data,
colorRegistry,
id,
});
const hasClickableItems = isDefined(onSliceClick);
@@ -194,6 +191,17 @@ export const GraphWidgetLineChart = ({
/>
);
const StackedAreasLayer = (layerProps: StackedAreasLayerProps) => (
<CustomStackedAreasLayer
series={layerProps.series}
innerHeight={layerProps.innerHeight}
enrichedSeries={enrichedSeries}
enableArea={enableArea}
yScale={layerProps.yScale}
isStacked={groupMode === 'stacked'}
/>
);
const axisBottomConfig = getLineChartAxisBottomConfig(
xAxisLabel,
chartWidth,
@@ -232,16 +240,11 @@ export const GraphWidgetLineChart = ({
}}
curve={'monotoneX'}
lineWidth={1}
enableArea={enableArea}
areaBaselineValue={0}
enablePoints={true}
pointSize={0}
enablePointLabel={false}
pointBorderWidth={0}
colors={colors}
areaBlendMode={'normal'}
defs={defs}
fill={fill}
axisTop={null}
axisRight={null}
axisBottom={axisBottomConfig}
@@ -255,15 +258,13 @@ export const GraphWidgetLineChart = ({
'grid',
'markers',
'axes',
'areas',
StackedAreasLayer,
'lines',
CrosshairLayer,
'points',
PointLabelsLayer,
'legends',
]}
useMesh={true}
crosshairType="cross"
theme={chartTheme}
/>
</GraphWidgetChartContainer>
@@ -0,0 +1,23 @@
import { useMotionConfig } from '@nivo/core';
import { animated, useSpring } from '@react-spring/web';
type LineAnimatedAreaPathProps = {
path: string;
fillId: string;
};
export const LineAnimatedAreaPath = ({
path,
fillId,
}: LineAnimatedAreaPathProps) => {
const { animate, config: motionConfig } = useMotionConfig();
const spring = useSpring({
d: path,
config: motionConfig,
immediate: !animate,
});
return (
<animated.path d={spring.d} fill={`url(#${fillId})`} strokeWidth={0} />
);
};
@@ -0,0 +1,40 @@
import { type LineChartEnrichedSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries';
import { createAreaFillDef } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/createAreaFillDef';
type LineAreaGradientDefsProps = {
enrichedSeries: LineChartEnrichedSeries[];
};
export const LineAreaGradientDefs = ({
enrichedSeries,
}: LineAreaGradientDefsProps) => {
return (
<defs>
{enrichedSeries.map((seriesItem) => {
const def = createAreaFillDef(
seriesItem.colorScheme,
seriesItem.areaFillId,
);
return (
<linearGradient
key={def.id}
id={def.id}
x1={def.x1}
y1={def.y1}
x2={def.x2}
y2={def.y2}
>
{def.colors.map((color, idx) => (
<stop
key={idx}
offset={`${color.offset}%`}
stopColor={color.color}
stopOpacity={color.opacity}
/>
))}
</linearGradient>
);
})}
</defs>
);
};
@@ -0,0 +1 @@
export const LINE_AREA_FILL_END_OPACITY = 0.01;
@@ -0,0 +1 @@
export const LINE_AREA_FILL_START_OPACITY = 0.18;
@@ -1,7 +1,6 @@
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { renderHook } from '@testing-library/react';
import { type ThemeType } from 'twenty-ui/theme';
import { useLineChartData } from '../useLineChartData';
@@ -9,10 +8,6 @@ describe('useLineChartData', () => {
const mockColorRegistry: GraphColorRegistry = {
red: {
name: 'red',
gradient: {
normal: ['red1', 'red2'],
hover: ['red3', 'red4'],
},
solid: 'redSolid',
variations: [
'red1',
@@ -31,10 +26,6 @@ describe('useLineChartData', () => {
},
blue: {
name: 'blue',
gradient: {
normal: ['blue1', 'blue2'],
hover: ['blue3', 'blue4'],
},
solid: 'blueSolid',
variations: [
'blue1',
@@ -53,8 +44,6 @@ describe('useLineChartData', () => {
},
};
const mockTheme = { name: 'light' } as ThemeType;
const mockData: LineChartSeries[] = [
{
id: 'series1',
@@ -77,61 +66,24 @@ describe('useLineChartData', () => {
},
];
it('should enrich series with color schemes', () => {
it('should enrich series with color schemes and area fill ids', () => {
const { result } = renderHook(() =>
useLineChartData({
data: mockData,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
enableArea: false,
theme: mockTheme,
}),
);
expect(result.current.enrichedSeries[0].gradientId).toBe(
'lineGradient-test-chart-instance-1-series1-0',
);
expect(result.current.enrichedSeries[0].label).toBe('Sales');
expect(result.current.enrichedSeries[0].areaFillId).toBe(
'areaFill-test-chart-series1-0',
);
expect(result.current.enrichedSeries[1].label).toBe('Costs');
});
it('should handle enableArea per series', () => {
const dataWithArea: LineChartSeries[] = [
{ ...mockData[0], enableArea: true },
{ ...mockData[1], enableArea: false },
];
const { result } = renderHook(() =>
useLineChartData({
data: dataWithArea,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
enableArea: true,
theme: mockTheme,
}),
expect(result.current.enrichedSeries[1].areaFillId).toBe(
'areaFill-test-chart-series2-1',
);
expect(result.current.enrichedSeries[0].shouldEnableArea).toBe(true);
expect(result.current.enrichedSeries[1].shouldEnableArea).toBe(false);
});
it('should use global enableArea when series does not specify', () => {
const { result } = renderHook(() =>
useLineChartData({
data: mockData,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
enableArea: true,
theme: mockTheme,
}),
);
expect(result.current.enrichedSeries[0].shouldEnableArea).toBe(true);
expect(result.current.enrichedSeries[1].shouldEnableArea).toBe(true);
});
it('should format data for Nivo', () => {
@@ -140,9 +92,6 @@ describe('useLineChartData', () => {
data: mockData,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
enableArea: false,
theme: mockTheme,
}),
);
@@ -166,113 +115,12 @@ describe('useLineChartData', () => {
]);
});
it('should generate defs only for series with area enabled', () => {
const dataWithArea: LineChartSeries[] = [
{ ...mockData[0], enableArea: true },
{ ...mockData[1], enableArea: false },
];
const { result } = renderHook(() =>
useLineChartData({
data: dataWithArea,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
enableArea: false,
theme: mockTheme,
}),
);
expect(result.current.defs).toHaveLength(1);
expect(result.current.defs[0].id).toBe(
'lineGradient-test-chart-instance-1-series1-0',
);
});
it('should reverse gradient for light theme', () => {
const dataWithArea: LineChartSeries[] = [
{ ...mockData[0], enableArea: true },
];
const { result } = renderHook(() =>
useLineChartData({
data: dataWithArea,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
enableArea: false,
theme: mockTheme,
}),
);
expect(result.current.defs[0].colors).toEqual([
{ offset: 0, color: 'red2' },
{ offset: 100, color: 'red1' },
]);
});
it('should not reverse gradient for dark theme', () => {
const dataWithArea: LineChartSeries[] = [
{ ...mockData[0], enableArea: true },
];
const darkTheme = { name: 'dark' } as ThemeType;
const { result } = renderHook(() =>
useLineChartData({
data: dataWithArea,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
enableArea: false,
theme: darkTheme,
}),
);
expect(result.current.defs[0].colors).toEqual([
{ offset: 0, color: 'red1' },
{ offset: 100, color: 'red2' },
]);
});
it('should generate fill configuration', () => {
const dataWithArea: LineChartSeries[] = [
{ ...mockData[0], enableArea: true },
{ ...mockData[1], enableArea: true },
];
const { result } = renderHook(() =>
useLineChartData({
data: dataWithArea,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
enableArea: false,
theme: mockTheme,
}),
);
expect(result.current.fill).toEqual([
{
match: { id: 'series1' },
id: 'lineGradient-test-chart-instance-1-series1-0',
},
{
match: { id: 'series2' },
id: 'lineGradient-test-chart-instance-1-series2-1',
},
]);
});
it('should calculate legend items with totals', () => {
const { result } = renderHook(() =>
useLineChartData({
data: mockData,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
enableArea: false,
theme: mockTheme,
}),
);
@@ -296,16 +144,11 @@ describe('useLineChartData', () => {
data: [],
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
enableArea: false,
theme: mockTheme,
}),
);
expect(result.current.enrichedSeries).toEqual([]);
expect(result.current.nivoData).toEqual([]);
expect(result.current.defs).toEqual([]);
expect(result.current.fill).toEqual([]);
expect(result.current.colors).toEqual([]);
expect(result.current.legendItems).toEqual([]);
});
@@ -323,9 +166,6 @@ describe('useLineChartData', () => {
data: dataWithoutLabel,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
enableArea: false,
theme: mockTheme,
}),
);
@@ -1,93 +1,50 @@
import { type LineChartEnrichedSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
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 LineSeries } from '@nivo/line';
import { useMemo } from 'react';
import { type ThemeType } from 'twenty-ui/theme';
type UseLineChartDataProps = {
data: LineChartSeries[];
colorRegistry: GraphColorRegistry;
id: string;
instanceId: string;
enableArea: boolean;
theme: ThemeType;
};
export const useLineChartData = ({
data,
colorRegistry,
id,
instanceId,
enableArea,
theme,
}: UseLineChartDataProps) => {
const enrichedSeries = useMemo((): LineChartEnrichedSeries[] => {
return data.map((series, index) => {
return useMemo(() => {
const enrichedSeries: LineChartEnrichedSeries[] = [];
const nivoData: LineSeries[] = [];
const colors: string[] = [];
const legendItems: { id: string; label: string; color: string }[] = [];
for (const [index, series] of data.entries()) {
const colorScheme = getColorScheme({
registry: colorRegistry,
colorName: series.color,
fallbackIndex: index,
totalGroups: data.length,
});
const shouldEnableArea = series.enableArea ?? enableArea;
const gradientId = `lineGradient-${id}-${instanceId}-${series.id}-${index}`;
return {
...series,
colorScheme,
gradientId,
shouldEnableArea,
label: series.label || series.id,
};
});
}, [data, colorRegistry, id, instanceId, enableArea]);
const sanitizedSeriesId = series.id
.replace(/\s+/g, '_')
.replace(/[^a-zA-Z0-9_-]/g, '');
const areaFillId = `areaFill-${id}-${sanitizedSeriesId}-${index}`;
const label = series.label || series.id;
const nivoData: LineSeries[] = data.map((series) => ({
id: series.id,
data: series.data.map((point) => ({
x: point.x,
y: point.y,
})),
}));
enrichedSeries.push({ ...series, colorScheme, areaFillId, label });
nivoData.push({
id: series.id,
data: series.data.map((point) => ({ x: point.x, y: point.y })),
});
colors.push(colorScheme.solid);
legendItems.push({ id: series.id, label, color: colorScheme.solid });
}
const seriesWithArea = enrichedSeries.filter(
(series) => series.shouldEnableArea,
);
const defs = seriesWithArea.map((series) =>
createGradientDef(
series.colorScheme,
series.gradientId,
false,
90,
theme.name === 'light',
),
);
const fill = seriesWithArea.map((series) => ({
match: { id: series.id },
id: series.gradientId,
}));
const colors = enrichedSeries.map((series) => series.colorScheme.solid);
const legendItems = enrichedSeries.map((series) => {
return {
id: series.id,
label: series.label,
color: series.colorScheme.solid,
};
});
return {
enrichedSeries,
nivoData,
defs,
fill,
colors,
legendItems,
};
return { enrichedSeries, nivoData, colors, legendItems };
}, [data, colorRegistry, id]);
};
@@ -3,7 +3,6 @@ import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphCo
export type LineChartEnrichedSeries = LineChartSeries & {
colorScheme: GraphColorScheme;
gradientId: string;
shouldEnableArea: boolean;
areaFillId: string;
label: string;
};
@@ -6,5 +6,4 @@ export type LineChartSeries = {
label?: string;
color?: GraphColor;
data: LineChartDataPoint[];
enableArea?: boolean;
};
@@ -0,0 +1,76 @@
import { type ComputedSeries, type LineSeries } from '@nivo/line';
jest.mock('d3-shape', () => {
const createAreaGenerator = () => {
let y0Fn: (d: any, i: number) => number = () => 0;
const generator = ((data: any[]) =>
data
.map((d, i) => `(${d.position.x},${y0Fn(d, i)},${d.position.y})`)
.join('|')) as any;
generator.defined = () => generator;
generator.x = () => generator;
generator.y1 = () => generator;
generator.y0 = (fn: (d: any, i: number) => number) => {
y0Fn = fn;
return generator;
};
generator.curve = () => generator;
return generator;
};
return {
area: createAreaGenerator,
curveMonotoneX: () => 'mockCurve',
};
});
import { computeLineAreaPath } from '../computeLineAreaPath';
const buildComputedSeries = (
points: { x: number; y: number }[],
): ComputedSeries<LineSeries> =>
({
id: 'series',
data: points.map(({ x, y }) => ({
data: { x, y },
position: { x, y },
})),
}) as unknown as ComputedSeries<LineSeries>;
describe('computeLineAreaPath', () => {
it('fills to the provided baseline for non-stacked mode', () => {
const currentSeries = buildComputedSeries([
{ x: 0, y: 10 },
{ x: 10, y: 20 },
]);
const path = computeLineAreaPath({
currentSeries,
baseline: 100,
});
expect(path).toBe('(0,100,10)|(10,100,20)');
});
it('fills between current and previous series for stacked mode', () => {
const currentSeries = buildComputedSeries([
{ x: 0, y: 10 },
{ x: 10, y: 20 },
]);
const previousStackedSeries = buildComputedSeries([
{ x: 0, y: 5 },
{ x: 10, y: 15 },
]);
const path = computeLineAreaPath({
currentSeries,
previousStackedSeries,
baseline: 100,
});
expect(path).toBe('(0,5,10)|(10,15,20)');
});
});
@@ -0,0 +1,37 @@
import { area, curveMonotoneX, type CurveFactory } from 'd3-shape';
import { isDefined } from 'twenty-shared/utils';
import { type ComputedSeries, type LineSeries } from '@nivo/line';
type ComputeLineAreaPathParams = {
currentSeries: ComputedSeries<LineSeries>;
previousStackedSeries?: ComputedSeries<LineSeries> | null;
baseline: number;
curve?: CurveFactory;
};
export const computeLineAreaPath = ({
currentSeries,
previousStackedSeries,
baseline,
curve = curveMonotoneX,
}: ComputeLineAreaPathParams) => {
type PositionData = (typeof currentSeries.data)[number];
const areaGenerator = area<PositionData>()
.defined((d) => d.position.x !== null && d.position.y !== null)
.x((d) => d.position.x ?? 0)
.y1((d) => d.position.y ?? 0)
.y0((_, index) => {
if (isDefined(previousStackedSeries)) {
const previousPoint = previousStackedSeries.data[index];
if (isDefined(previousPoint) && isDefined(previousPoint.position.y)) {
return previousPoint.position.y;
}
}
return baseline;
})
.curve(curve);
return areaGenerator(currentSeries.data);
};
@@ -0,0 +1,29 @@
import { LINE_AREA_FILL_END_OPACITY } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineAreaFillEndOpacity';
import { LINE_AREA_FILL_START_OPACITY } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineAreaFillStartOpacity';
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
export const createAreaFillDef = (
colorScheme: GraphColorScheme,
id: string,
) => {
return {
id,
type: 'linearGradient' as const,
x1: '0%',
y1: '0%',
x2: '0%',
y2: '100%',
colors: [
{
offset: 0,
color: colorScheme.solid,
opacity: LINE_AREA_FILL_START_OPACITY,
},
{
offset: 100,
color: colorScheme.solid,
opacity: LINE_AREA_FILL_END_OPACITY,
},
],
};
};
@@ -12,10 +12,6 @@ describe('usePieChartData', () => {
const mockColorRegistry: GraphColorRegistry = {
red: {
name: 'red',
gradient: {
normal: ['red1', 'red2'],
hover: ['red3', 'red4'],
},
solid: 'redSolid',
variations: [
'red1',
@@ -34,10 +30,6 @@ describe('usePieChartData', () => {
},
blue: {
name: 'blue',
gradient: {
normal: ['blue1', 'blue2'],
hover: ['blue3', 'blue4'],
},
solid: 'blueSolid',
variations: [
'blue1',
@@ -1,9 +1,5 @@
export type GraphColorScheme = {
name: string;
gradient: {
normal: [string, string];
hover: [string, string];
};
solid: string;
variations: [
string,
@@ -1,34 +0,0 @@
import { type GraphColorScheme } from '../types/GraphColorScheme';
import { calculateAngularGradient } from './calculateAngularGradient';
export const createGradientDef = (
colorScheme: GraphColorScheme,
id: string,
isHovered: boolean = false,
angle?: number,
reverseGradient: boolean = false,
) => {
const colors = isHovered
? colorScheme.gradient.hover
: colorScheme.gradient.normal;
const coords =
angle !== undefined
? calculateAngularGradient(angle)
: { x1: '0%', y1: '0%', x2: '0%', y2: '100%' };
return {
id,
type: 'linearGradient' as const,
...coords,
colors: reverseGradient
? [
{ offset: 0, color: colors[1] },
{ offset: 100, color: colors[0] },
]
: [
{ offset: 0, color: colors[0] },
{ offset: 100, color: colors[1] },
],
};
};
@@ -7,10 +7,6 @@ export const createGraphColorRegistry = (
): GraphColorRegistry => ({
blue: {
name: 'blue',
gradient: {
normal: [theme.color.blue3, theme.color.blue5],
hover: [theme.color.blue7, theme.color.blue8],
},
solid: theme.color.blue8,
variations: [
theme.color.blue1,
@@ -29,10 +25,6 @@ export const createGraphColorRegistry = (
},
purple: {
name: 'purple',
gradient: {
normal: [theme.color.purple3, theme.color.purple5],
hover: [theme.color.purple7, theme.color.purple8],
},
solid: theme.color.purple8,
variations: [
theme.color.purple1,
@@ -51,10 +43,6 @@ export const createGraphColorRegistry = (
},
turquoise: {
name: 'turquoise',
gradient: {
normal: [theme.color.turquoise3, theme.color.turquoise5],
hover: [theme.color.turquoise7, theme.color.turquoise8],
},
solid: theme.color.turquoise8,
variations: [
theme.color.turquoise1,
@@ -73,10 +61,6 @@ export const createGraphColorRegistry = (
},
orange: {
name: 'orange',
gradient: {
normal: [theme.color.orange3, theme.color.orange5],
hover: [theme.color.orange7, theme.color.orange8],
},
solid: theme.color.orange8,
variations: [
theme.color.orange1,
@@ -95,10 +79,6 @@ export const createGraphColorRegistry = (
},
pink: {
name: 'pink',
gradient: {
normal: [theme.color.pink3, theme.color.pink5],
hover: [theme.color.pink7, theme.color.pink8],
},
solid: theme.color.pink8,
variations: [
theme.color.pink1,
@@ -117,10 +97,6 @@ export const createGraphColorRegistry = (
},
yellow: {
name: 'yellow',
gradient: {
normal: [theme.color.yellow3, theme.color.yellow5],
hover: [theme.color.yellow7, theme.color.yellow8],
},
solid: theme.color.yellow8,
variations: [
theme.color.yellow1,
@@ -139,10 +115,6 @@ export const createGraphColorRegistry = (
},
red: {
name: 'red',
gradient: {
normal: [theme.color.red3, theme.color.red5],
hover: [theme.color.red7, theme.color.red8],
},
solid: theme.color.red8,
variations: [
theme.color.red1,
@@ -161,10 +133,6 @@ export const createGraphColorRegistry = (
},
green: {
name: 'green',
gradient: {
normal: [theme.color.green3, theme.color.green5],
hover: [theme.color.green7, theme.color.green8],
},
solid: theme.color.green8,
variations: [
theme.color.green1,
@@ -183,10 +151,6 @@ export const createGraphColorRegistry = (
},
sky: {
name: 'sky',
gradient: {
normal: [theme.color.sky3, theme.color.sky5],
hover: [theme.color.sky7, theme.color.sky8],
},
solid: theme.color.sky8,
variations: [
theme.color.sky1,
@@ -205,10 +169,6 @@ export const createGraphColorRegistry = (
},
gray: {
name: 'gray',
gradient: {
normal: [theme.color.gray3, theme.color.gray5],
hover: [theme.color.gray7, theme.color.gray8],
},
solid: theme.color.gray8,
variations: [
theme.color.gray1,
@@ -227,10 +187,6 @@ export const createGraphColorRegistry = (
},
tomato: {
name: 'tomato',
gradient: {
normal: [theme.color.tomato3, theme.color.tomato5],
hover: [theme.color.tomato7, theme.color.tomato8],
},
solid: theme.color.tomato8,
variations: [
theme.color.tomato1,
@@ -249,10 +205,6 @@ export const createGraphColorRegistry = (
},
ruby: {
name: 'ruby',
gradient: {
normal: [theme.color.ruby3, theme.color.ruby5],
hover: [theme.color.ruby7, theme.color.ruby8],
},
solid: theme.color.ruby8,
variations: [
theme.color.ruby1,
@@ -271,10 +223,6 @@ export const createGraphColorRegistry = (
},
crimson: {
name: 'crimson',
gradient: {
normal: [theme.color.crimson3, theme.color.crimson5],
hover: [theme.color.crimson7, theme.color.crimson8],
},
solid: theme.color.crimson8,
variations: [
theme.color.crimson1,
@@ -293,10 +241,6 @@ export const createGraphColorRegistry = (
},
plum: {
name: 'plum',
gradient: {
normal: [theme.color.plum3, theme.color.plum5],
hover: [theme.color.plum7, theme.color.plum8],
},
solid: theme.color.plum8,
variations: [
theme.color.plum1,
@@ -315,10 +259,6 @@ export const createGraphColorRegistry = (
},
violet: {
name: 'violet',
gradient: {
normal: [theme.color.violet3, theme.color.violet5],
hover: [theme.color.violet7, theme.color.violet8],
},
solid: theme.color.violet8,
variations: [
theme.color.violet1,
@@ -337,10 +277,6 @@ export const createGraphColorRegistry = (
},
iris: {
name: 'iris',
gradient: {
normal: [theme.color.iris3, theme.color.iris5],
hover: [theme.color.iris7, theme.color.iris8],
},
solid: theme.color.iris8,
variations: [
theme.color.iris1,
@@ -359,10 +295,6 @@ export const createGraphColorRegistry = (
},
cyan: {
name: 'cyan',
gradient: {
normal: [theme.color.cyan3, theme.color.cyan5],
hover: [theme.color.cyan7, theme.color.cyan8],
},
solid: theme.color.cyan8,
variations: [
theme.color.cyan1,
@@ -381,10 +313,6 @@ export const createGraphColorRegistry = (
},
jade: {
name: 'jade',
gradient: {
normal: [theme.color.jade3, theme.color.jade5],
hover: [theme.color.jade7, theme.color.jade8],
},
solid: theme.color.jade8,
variations: [
theme.color.jade1,
@@ -403,10 +331,6 @@ export const createGraphColorRegistry = (
},
grass: {
name: 'grass',
gradient: {
normal: [theme.color.grass3, theme.color.grass5],
hover: [theme.color.grass7, theme.color.grass8],
},
solid: theme.color.grass8,
variations: [
theme.color.grass1,
@@ -425,10 +349,6 @@ export const createGraphColorRegistry = (
},
mint: {
name: 'mint',
gradient: {
normal: [theme.color.mint3, theme.color.mint5],
hover: [theme.color.mint7, theme.color.mint8],
},
solid: theme.color.mint8,
variations: [
theme.color.mint1,
@@ -447,10 +367,6 @@ export const createGraphColorRegistry = (
},
lime: {
name: 'lime',
gradient: {
normal: [theme.color.lime3, theme.color.lime5],
hover: [theme.color.lime7, theme.color.lime8],
},
solid: theme.color.lime8,
variations: [
theme.color.lime1,
@@ -469,10 +385,6 @@ export const createGraphColorRegistry = (
},
bronze: {
name: 'bronze',
gradient: {
normal: [theme.color.bronze3, theme.color.bronze5],
hover: [theme.color.bronze7, theme.color.bronze8],
},
solid: theme.color.bronze8,
variations: [
theme.color.bronze1,
@@ -491,10 +403,6 @@ export const createGraphColorRegistry = (
},
gold: {
name: 'gold',
gradient: {
normal: [theme.color.gold3, theme.color.gold5],
hover: [theme.color.gold7, theme.color.gold8],
},
solid: theme.color.gold8,
variations: [
theme.color.gold1,
@@ -513,10 +421,6 @@ export const createGraphColorRegistry = (
},
brown: {
name: 'brown',
gradient: {
normal: [theme.color.brown3, theme.color.brown5],
hover: [theme.color.brown7, theme.color.brown8],
},
solid: theme.color.brown8,
variations: [
theme.color.brown1,
@@ -535,10 +439,6 @@ export const createGraphColorRegistry = (
},
amber: {
name: 'amber',
gradient: {
normal: [theme.color.amber3, theme.color.amber5],
hover: [theme.color.amber7, theme.color.amber8],
},
solid: theme.color.amber8,
variations: [
theme.color.amber1,