diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index 574781957a..1a68604618 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -47,7 +47,6 @@ "@lingui/detect-locale": "^5.2.0", "@lingui/react": "^5.1.2", "@monaco-editor/react": "^4.7.0", - "@nivo/bar": "^0.99.0", "@nivo/core": "^0.99.0", "@nivo/line": "^0.99.0", "@nivo/pie": "^0.99.0", diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuOpenContainer.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuOpenContainer.tsx index f74423f3f0..8af08175b3 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuOpenContainer.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuOpenContainer.tsx @@ -2,6 +2,7 @@ import { COMMAND_MENU_ANIMATION_VARIANTS } from '@/command-menu/constants/Comman import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId'; import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { isSidePanelAnimatingState } from '@/command-menu/states/isSidePanelAnimatingState'; import { type CommandMenuAnimationVariant } from '@/command-menu/types/CommandMenuAnimationVariant'; import { RECORD_CHIP_CLICK_OUTSIDE_ID } from '@/object-record/record-table/constants/RecordChipClickOutsideId'; import { SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/SlashMenuDropdownClickOutsideId'; @@ -10,14 +11,14 @@ import { PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/pa import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { WORKFLOW_DIAGRAM_CREATE_STEP_NODE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramCreateStepNodeClickOutsideId'; -import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowDiagramEdgeOptionsClickOutsideId'; import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId'; +import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowDiagramEdgeOptionsClickOutsideId'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { motion } from 'framer-motion'; import { useRef } from 'react'; -import { useRecoilCallback } from 'recoil'; +import { useRecoilCallback, useSetRecoilState } from 'recoil'; import { LINK_CHIP_CLICK_OUTSIDE_ID } from 'twenty-ui/components'; import { useIsMobile } from 'twenty-ui/utilities'; @@ -51,6 +52,7 @@ export const CommandMenuOpenContainer = ({ const { closeCommandMenu } = useCommandMenu(); const commandMenuRef = useRef(null); + const setIsSidePanelAnimating = useSetRecoilState(isSidePanelAnimatingState); const handleClickOutside = useRecoilCallback( ({ snapshot }) => @@ -93,6 +95,8 @@ export const CommandMenuOpenContainer = ({ exit="closed" variants={COMMAND_MENU_ANIMATION_VARIANTS} transition={{ duration: theme.animation.duration.normal }} + onAnimationStart={() => setIsSidePanelAnimating(true)} + onAnimationComplete={() => setIsSidePanelAnimating(false)} > {children} diff --git a/packages/twenty-front/src/modules/command-menu/states/isSidePanelAnimatingState.ts b/packages/twenty-front/src/modules/command-menu/states/isSidePanelAnimatingState.ts new file mode 100644 index 0000000000..72bebd59cf --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/states/isSidePanelAnimatingState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const isSidePanelAnimatingState = atom({ + key: 'command-menu/isSidePanelAnimatingState', + default: false, +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/constants/ChartCoreConstants.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/constants/ChartCoreConstants.ts new file mode 100644 index 0000000000..7362d8a547 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/constants/ChartCoreConstants.ts @@ -0,0 +1,6 @@ +export const CHART_CORE_CONSTANTS = { + DEFAULT_BAND_PADDING: 0, + DEFAULT_OUTER_PADDING_PX: 0, + DEFAULT_DEVICE_PIXEL_RATIO: 1, + MILLISECONDS_PER_SECOND: 1000, +} as const; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/AxisLabel.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/AxisLabel.tsx new file mode 100644 index 0000000000..a63e139afa --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/AxisLabel.tsx @@ -0,0 +1,36 @@ +import { useTheme } from '@emotion/react'; +import { isDefined } from 'twenty-shared/utils'; + +type AxisLabelProps = { + label: string; + x: number; + y: number; + fontSize: number; + rotation?: number; +}; + +export const AxisLabel = ({ + label, + x, + y, + fontSize, + rotation, +}: AxisLabelProps) => { + const theme = useTheme(); + + return ( + + {label} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/AxisLayer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/AxisLayer.tsx new file mode 100644 index 0000000000..aa0cc3b94b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/AxisLayer.tsx @@ -0,0 +1,159 @@ +import { AxisLabel } from '@/page-layout/widgets/graph/chart-core/layers/AxisLabel'; +import { BottomAxisTicks } from '@/page-layout/widgets/graph/chart-core/layers/BottomAxisTicks'; +import { LeftAxisTicks } from '@/page-layout/widgets/graph/chart-core/layers/LeftAxisTicks'; +import { ZeroLine } from '@/page-layout/widgets/graph/chart-core/layers/ZeroLine'; +import { type AxisLayerConfig } from '@/page-layout/widgets/graph/chart-core/types/AxisLayerConfig'; +import { getAxisLayerLayout } from '@/page-layout/widgets/graph/chart-core/utils/getAxisLayerLayout'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +type AxisLayerProps = { + bottomAxisTickRotation: number; + categoryValues: (string | number)[]; + categoryTickValues: (string | number)[]; + chartHeight: number; + chartWidth: number; + formatBottomTick: (value: string | number) => string; + formatLeftTick: (value: string | number) => string; + hasNegativeValues: boolean; + isVertical: boolean; + margins: ChartMargins; + valueDomain: { min: number; max: number }; + valueTickValues: number[]; + xAxisLabel?: string; + yAxisLabel?: string; + axisConfig: AxisLayerConfig; +}; + +const StyledSvgOverlay = styled.svg` + left: 0; + overflow: visible; + pointer-events: none; + position: absolute; + top: 0; +`; + +const STROKE_ALIGNMENT_OFFSET = 0.5 as const; + +export const AxisLayer = ({ + bottomAxisTickRotation, + categoryValues, + categoryTickValues, + chartHeight, + chartWidth, + formatBottomTick, + formatLeftTick, + hasNegativeValues, + isVertical, + margins, + valueDomain, + valueTickValues, + xAxisLabel, + yAxisLabel, + axisConfig, +}: AxisLayerProps) => { + const theme = useTheme(); + const tickFontSize = axisConfig.tickFontSize; + const legendFontSize = axisConfig.legendFontSize; + + const { + innerWidth, + innerHeight, + bottomTickValues, + leftTickValues, + getBottomTickPosition, + getLeftTickPosition, + hasRotation, + bottomLegendOffset, + leftLegendOffset, + shouldRenderZeroLine, + zeroPosition, + } = getAxisLayerLayout({ + bottomAxisTickRotation, + categoryValues, + categoryTickValues, + chartHeight, + chartWidth, + hasNegativeValues, + isVertical, + margins, + valueDomain, + valueTickValues, + axisConfig, + }); + + return ( + + + + + + + {shouldRenderZeroLine && ( + + )} + + + + + {xAxisLabel && ( + + )} + + + + + + {yAxisLabel && ( + + )} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/BottomAxisTicks.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/BottomAxisTicks.tsx new file mode 100644 index 0000000000..4820ca3364 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/BottomAxisTicks.tsx @@ -0,0 +1,51 @@ +import { useTheme } from '@emotion/react'; + +type BottomAxisTicksProps = { + bottomTickValues: (string | number)[]; + getBottomTickPosition: (value: string | number, index: number) => number; + formatBottomTick: (value: string | number) => string; + hasRotation: boolean; + bottomAxisTickRotation: number; + tickPadding: number; + tickFontSize: number; +}; + +export const BottomAxisTicks = ({ + bottomTickValues, + getBottomTickPosition, + formatBottomTick, + hasRotation, + bottomAxisTickRotation, + tickPadding, + tickFontSize, +}: BottomAxisTicksProps) => { + const theme = useTheme(); + + return ( + <> + {bottomTickValues.map((value, index) => { + const x = getBottomTickPosition(value, index); + const label = formatBottomTick(value); + + return ( + + + {label} + + + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/LeftAxisTicks.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/LeftAxisTicks.tsx new file mode 100644 index 0000000000..165b5608c3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/LeftAxisTicks.tsx @@ -0,0 +1,43 @@ +import { useTheme } from '@emotion/react'; + +type LeftAxisTicksProps = { + leftTickValues: (string | number)[]; + getLeftTickPosition: (value: string | number, index: number) => number; + formatLeftTick: (value: string | number) => string; + tickPadding: number; + tickFontSize: number; +}; + +export const LeftAxisTicks = ({ + leftTickValues, + getLeftTickPosition, + formatLeftTick, + tickPadding, + tickFontSize, +}: LeftAxisTicksProps) => { + const theme = useTheme(); + + return ( + <> + {leftTickValues.map((value, index) => { + const y = getLeftTickPosition(value, index); + const label = formatLeftTick(value); + + return ( + + + {label} + + + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/ZeroLine.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/ZeroLine.tsx new file mode 100644 index 0000000000..d1da28b154 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/layers/ZeroLine.tsx @@ -0,0 +1,28 @@ +import { useTheme } from '@emotion/react'; + +type ZeroLineProps = { + isVertical: boolean; + zeroPosition: number; + innerWidth: number; + innerHeight: number; +}; + +export const ZeroLine = ({ + isVertical, + zeroPosition, + innerWidth, + innerHeight, +}: ZeroLineProps) => { + const theme = useTheme(); + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/types/AxisLayerConfig.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/types/AxisLayerConfig.ts new file mode 100644 index 0000000000..d0a22ebe97 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/types/AxisLayerConfig.ts @@ -0,0 +1,11 @@ +export type AxisLayerConfig = { + tickFontSize: number; + legendFontSize: number; + tickPadding: number; + rotatedLabelsExtraMargin: number; + bottomAxisLegendOffset: number; + leftAxisLegendOffsetPadding: number; + legendOffsetMarginBuffer: number; + categoryPadding: number; + categoryOuterPaddingPx: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/computeBandScale.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/computeBandScale.test.ts new file mode 100644 index 0000000000..553de447e8 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/computeBandScale.test.ts @@ -0,0 +1,41 @@ +import { computeBandScale } from '@/page-layout/widgets/graph/chart-core/utils/computeBandScale'; + +describe('computeBandScale', () => { + it('returns zeros when axis length or count is non-positive', () => { + expect(computeBandScale({ axisLength: 0, count: 5 })).toEqual({ + step: 0, + bandwidth: 0, + offset: 0, + }); + expect(computeBandScale({ axisLength: 100, count: 0 })).toEqual({ + step: 0, + bandwidth: 0, + offset: 0, + }); + }); + + it('computes step, bandwidth, and offset with padding and outer padding', () => { + const result = computeBandScale({ + axisLength: 100, + count: 4, + padding: 0.1, + outerPaddingPx: 10, + }); + + expect(result.step).toBeCloseTo(80 / 4.1, 6); + expect(result.bandwidth).toBeCloseTo((80 / 4.1) * 0.9, 6); + expect(result.offset).toBeCloseTo(10 + (80 / 4.1) * 0.1, 6); + }); + + it('clamps padding and outer padding to valid ranges', () => { + const result = computeBandScale({ + axisLength: 90, + count: 2, + padding: 2, + outerPaddingPx: -5, + }); + + expect(result.bandwidth).toBe(0); + expect(result.offset).toBeCloseTo(90 / 3, 6); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/computeBottomTickPosition.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/computeBottomTickPosition.test.ts new file mode 100644 index 0000000000..bd83a4adb5 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/computeBottomTickPosition.test.ts @@ -0,0 +1,127 @@ +import { computeBottomTickPosition } from '@/page-layout/widgets/graph/chart-core/utils/computeBottomTickPosition'; + +describe('computeBottomTickPosition', () => { + const defaultCategoryScale = { + offset: 20, + step: 100, + bandwidth: 80, + }; + + describe('vertical layout (category axis on bottom)', () => { + it('should return center of category band', () => { + const categoryIndexMap = new Map([ + ['A', 0], + ['B', 1], + ]); + + const result = computeBottomTickPosition({ + value: 'A', + index: 0, + isVertical: true, + categoryValues: ['A', 'B'], + categoryIndexMap, + categoryScale: defaultCategoryScale, + valueDomain: { min: 0, max: 100 }, + innerWidth: 400, + }); + + const expectedCenter = 20 + 0 * 100 + 80 / 2; + expect(result).toBe(expectedCenter); + }); + + it('should return 0 when categoryValues is empty', () => { + const result = computeBottomTickPosition({ + value: 'A', + index: 0, + isVertical: true, + categoryValues: [], + categoryIndexMap: new Map(), + categoryScale: defaultCategoryScale, + valueDomain: { min: 0, max: 100 }, + innerWidth: 400, + }); + + expect(result).toBe(0); + }); + + it('should clamp index when value not found in map', () => { + const categoryIndexMap = new Map([['A', 0]]); + + const result = computeBottomTickPosition({ + value: 'X', + index: 5, + isVertical: true, + categoryValues: ['A'], + categoryIndexMap, + categoryScale: defaultCategoryScale, + valueDomain: { min: 0, max: 100 }, + innerWidth: 400, + }); + + const expectedCenter = 20 + 0 * 100 + 80 / 2; + expect(result).toBe(expectedCenter); + }); + }); + + describe('horizontal layout (value axis on bottom)', () => { + it('should position minimum value at 0', () => { + const result = computeBottomTickPosition({ + value: 0, + index: 0, + isVertical: false, + categoryValues: ['A', 'B'], + categoryIndexMap: new Map(), + categoryScale: defaultCategoryScale, + valueDomain: { min: 0, max: 100 }, + innerWidth: 400, + }); + + expect(result).toBe(0); + }); + + it('should position maximum value at innerWidth', () => { + const result = computeBottomTickPosition({ + value: 100, + index: 0, + isVertical: false, + categoryValues: ['A', 'B'], + categoryIndexMap: new Map(), + categoryScale: defaultCategoryScale, + valueDomain: { min: 0, max: 100 }, + innerWidth: 400, + }); + + expect(result).toBe(400); + }); + + it('should return 0 when range is 0', () => { + const result = computeBottomTickPosition({ + value: 50, + index: 0, + isVertical: false, + categoryValues: ['A', 'B'], + categoryIndexMap: new Map(), + categoryScale: defaultCategoryScale, + valueDomain: { min: 50, max: 50 }, + innerWidth: 400, + }); + + expect(result).toBe(0); + }); + + it('should handle negative values', () => { + const result = computeBottomTickPosition({ + value: 0, + index: 0, + isVertical: false, + categoryValues: ['A', 'B'], + categoryIndexMap: new Map(), + categoryScale: defaultCategoryScale, + valueDomain: { min: -50, max: 50 }, + innerWidth: 400, + }); + + expect(result).toBe(200); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/computeLeftTickPosition.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/computeLeftTickPosition.test.ts new file mode 100644 index 0000000000..aa2782f8e6 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/computeLeftTickPosition.test.ts @@ -0,0 +1,129 @@ +import { computeLeftTickPosition } from '@/page-layout/widgets/graph/chart-core/utils/computeLeftTickPosition'; + +describe('computeLeftTickPosition', () => { + const defaultCategoryScale = { + offset: 20, + step: 100, + bandwidth: 80, + }; + + describe('vertical layout (value axis on left)', () => { + it('should position minimum value at bottom (innerHeight)', () => { + const result = computeLeftTickPosition({ + value: 0, + index: 0, + isVertical: true, + categoryValues: ['A', 'B'], + categoryIndexMap: new Map(), + categoryScale: defaultCategoryScale, + valueDomain: { min: 0, max: 100 }, + innerHeight: 200, + }); + + expect(result).toBe(200); + }); + + it('should position maximum value at top (0)', () => { + const result = computeLeftTickPosition({ + value: 100, + index: 0, + isVertical: true, + categoryValues: ['A', 'B'], + categoryIndexMap: new Map(), + categoryScale: defaultCategoryScale, + valueDomain: { min: 0, max: 100 }, + innerHeight: 200, + }); + + expect(result).toBe(0); + }); + + it('should return innerHeight when range is 0', () => { + const result = computeLeftTickPosition({ + value: 50, + index: 0, + isVertical: true, + categoryValues: ['A', 'B'], + categoryIndexMap: new Map(), + categoryScale: defaultCategoryScale, + valueDomain: { min: 50, max: 50 }, + innerHeight: 200, + }); + + expect(result).toBe(200); + }); + + it('should handle negative values', () => { + const result = computeLeftTickPosition({ + value: 0, + index: 0, + isVertical: true, + categoryValues: ['A', 'B'], + categoryIndexMap: new Map(), + categoryScale: defaultCategoryScale, + valueDomain: { min: -50, max: 50 }, + innerHeight: 200, + }); + + expect(result).toBe(100); + }); + }); + + describe('horizontal layout (category axis on left)', () => { + it('should return center of category band (reversed order)', () => { + const categoryIndexMap = new Map([ + ['A', 0], + ['B', 1], + ]); + + const result = computeLeftTickPosition({ + value: 'A', + index: 0, + isVertical: false, + categoryValues: ['A', 'B'], + categoryIndexMap, + categoryScale: defaultCategoryScale, + valueDomain: { min: 0, max: 100 }, + innerHeight: 200, + }); + + const effectiveIndex = 2 - 1 - 0; + const expectedCenter = 20 + effectiveIndex * 100 + 80 / 2; + expect(result).toBe(expectedCenter); + }); + + it('should return 0 when categoryValues is empty', () => { + const result = computeLeftTickPosition({ + value: 'A', + index: 0, + isVertical: false, + categoryValues: [], + categoryIndexMap: new Map(), + categoryScale: defaultCategoryScale, + valueDomain: { min: 0, max: 100 }, + innerHeight: 200, + }); + + expect(result).toBe(0); + }); + + it('should clamp index when value not found in map', () => { + const categoryIndexMap = new Map([['A', 0]]); + + const result = computeLeftTickPosition({ + value: 'X', + index: 5, + isVertical: false, + categoryValues: ['A'], + categoryIndexMap, + categoryScale: defaultCategoryScale, + valueDomain: { min: 0, max: 100 }, + innerHeight: 200, + }); + + const effectiveIndex = 1 - 1 - 0; + const expectedCenter = 20 + effectiveIndex * 100 + 80 / 2; + expect(result).toBe(expectedCenter); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/computeValueScale.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/computeValueScale.test.ts new file mode 100644 index 0000000000..d69b99a50b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/computeValueScale.test.ts @@ -0,0 +1,41 @@ +import { computeValueScale } from '@/page-layout/widgets/graph/chart-core/utils/computeValueScale'; + +describe('computeValueScale', () => { + it('maps domain bounds to axis bounds', () => { + const { valueToPixel } = computeValueScale({ + domain: { min: 10, max: 20 }, + axisLength: 100, + }); + + expect(valueToPixel(10)).toBe(0); + expect(valueToPixel(15)).toBe(50); + expect(valueToPixel(20)).toBe(100); + }); + + it('clamps values below the domain to 0', () => { + const { valueToPixel } = computeValueScale({ + domain: { min: 10, max: 20 }, + axisLength: 100, + }); + + expect(valueToPixel(0)).toBe(0); + }); + + it('clamps values above the domain to axisLength', () => { + const { valueToPixel } = computeValueScale({ + domain: { min: -20, max: -10 }, + axisLength: 100, + }); + + expect(valueToPixel(0)).toBe(100); + }); + + it('returns 0 when the domain range is zero', () => { + const { valueToPixel } = computeValueScale({ + domain: { min: 5, max: 5 }, + axisLength: 100, + }); + + expect(valueToPixel(5)).toBe(0); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/getPointerPosition.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/getPointerPosition.test.ts new file mode 100644 index 0000000000..82622653f8 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/getPointerPosition.test.ts @@ -0,0 +1,16 @@ +import { getPointerPosition } from '@/page-layout/widgets/graph/chart-core/utils/getPointerPosition'; + +describe('getPointerPosition', () => { + it('calculates offsets relative to the element bounds', () => { + const element = { + getBoundingClientRect: () => ({ left: 10, top: 20 }), + } as HTMLElement; + + const position = getPointerPosition({ + event: { clientX: 35, clientY: 50 }, + element, + }); + + expect(position).toEqual({ x: 25, y: 30 }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/isPointInChartArea.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/isPointInChartArea.test.ts new file mode 100644 index 0000000000..045b094dc8 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/__tests__/isPointInChartArea.test.ts @@ -0,0 +1,43 @@ +import { isPointInChartArea } from '@/page-layout/widgets/graph/chart-core/utils/isPointInChartArea'; + +describe('isPointInChartArea', () => { + it('returns true for points inside or on the boundary', () => { + expect( + isPointInChartArea({ + x: 0, + y: 0, + innerWidth: 100, + innerHeight: 50, + }), + ).toBe(true); + + expect( + isPointInChartArea({ + x: 100, + y: 50, + innerWidth: 100, + innerHeight: 50, + }), + ).toBe(true); + }); + + it('returns false for points outside the area', () => { + expect( + isPointInChartArea({ + x: -1, + y: 0, + innerWidth: 100, + innerHeight: 50, + }), + ).toBe(false); + + expect( + isPointInChartArea({ + x: 0, + y: 51, + innerWidth: 100, + innerHeight: 50, + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeBandScale.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeBandScale.ts new file mode 100644 index 0000000000..b22d6000fc --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeBandScale.ts @@ -0,0 +1,37 @@ +import { CHART_CORE_CONSTANTS } from '@/page-layout/widgets/graph/chart-core/constants/ChartCoreConstants'; + +type BandScale = { + step: number; + bandwidth: number; + offset: number; +}; + +export const computeBandScale = ({ + axisLength, + count, + padding = CHART_CORE_CONSTANTS.DEFAULT_BAND_PADDING, + outerPaddingPx = CHART_CORE_CONSTANTS.DEFAULT_OUTER_PADDING_PX, +}: { + axisLength: number; + count: number; + padding?: number; + outerPaddingPx?: number; +}): BandScale => { + if (axisLength <= 0 || count <= 0) { + return { step: 0, bandwidth: 0, offset: 0 }; + } + + const safeOuterPaddingPx = Math.max(0, outerPaddingPx); + const effectiveAxisLength = Math.max(0, axisLength - safeOuterPaddingPx * 2); + + const clampedPadding = Math.max(0, Math.min(1, padding)); + const stepDenominator = Math.max( + 1, + count - clampedPadding + clampedPadding * 2, + ); + const step = effectiveAxisLength / stepDenominator; + const bandwidth = step * (1 - clampedPadding); + const offset = safeOuterPaddingPx + step * clampedPadding; + + return { step, bandwidth, offset }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeBottomTickPosition.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeBottomTickPosition.ts new file mode 100644 index 0000000000..2a7912a82f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeBottomTickPosition.ts @@ -0,0 +1,46 @@ +import { computeCategoryTickCenterPosition } from '@/page-layout/widgets/graph/chart-core/utils/computeCategoryTickCenterPosition'; + +type CategoryScale = { + offset: number; + step: number; + bandwidth: number; +}; + +type ComputeBottomTickPositionParams = { + value: string | number; + index: number; + isVertical: boolean; + categoryValues: (string | number)[]; + categoryIndexMap: Map; + categoryScale: CategoryScale; + valueDomain: { min: number; max: number }; + innerWidth: number; +}; + +export const computeBottomTickPosition = ({ + value, + index, + isVertical, + categoryValues, + categoryIndexMap, + categoryScale, + valueDomain, + innerWidth, +}: ComputeBottomTickPositionParams): number => { + if (isVertical) { + return computeCategoryTickCenterPosition({ + value, + index, + categoryValues, + categoryIndexMap, + categoryScale, + }); + } + + const range = valueDomain.max - valueDomain.min; + if (range === 0) { + return 0; + } + + return ((Number(value) - valueDomain.min) / range) * innerWidth; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeCategoryTickCenterPosition.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeCategoryTickCenterPosition.ts new file mode 100644 index 0000000000..168dd53668 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeCategoryTickCenterPosition.ts @@ -0,0 +1,41 @@ +type CategoryScale = { + offset: number; + step: number; + bandwidth: number; +}; + +type ComputeCategoryTickCenterPositionParams = { + value: string | number; + index: number; + categoryValues: (string | number)[]; + categoryIndexMap: Map; + categoryScale: CategoryScale; + reverse?: boolean; +}; + +export const computeCategoryTickCenterPosition = ({ + value, + index, + categoryValues, + categoryIndexMap, + categoryScale, + reverse = false, +}: ComputeCategoryTickCenterPositionParams): number => { + if (categoryValues.length === 0) { + return 0; + } + + const rawIndex = + categoryIndexMap.get(String(value)) ?? + Math.min(index, categoryValues.length - 1); + const clampedIndex = Math.min( + Math.max(rawIndex, 0), + categoryValues.length - 1, + ); + const effectiveIndex = reverse + ? categoryValues.length - 1 - clampedIndex + : clampedIndex; + const start = categoryScale.offset + effectiveIndex * categoryScale.step; + + return start + categoryScale.bandwidth / 2; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeLeftTickPosition.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeLeftTickPosition.ts new file mode 100644 index 0000000000..733063f2df --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeLeftTickPosition.ts @@ -0,0 +1,48 @@ +import { computeCategoryTickCenterPosition } from '@/page-layout/widgets/graph/chart-core/utils/computeCategoryTickCenterPosition'; + +type CategoryScale = { + offset: number; + step: number; + bandwidth: number; +}; + +type ComputeLeftTickPositionParams = { + value: string | number; + index: number; + isVertical: boolean; + categoryValues: (string | number)[]; + categoryIndexMap: Map; + categoryScale: CategoryScale; + valueDomain: { min: number; max: number }; + innerHeight: number; +}; + +export const computeLeftTickPosition = ({ + value, + index, + isVertical, + categoryValues, + categoryIndexMap, + categoryScale, + valueDomain, + innerHeight, +}: ComputeLeftTickPositionParams): number => { + if (isVertical) { + const range = valueDomain.max - valueDomain.min; + if (range === 0) { + return innerHeight; + } + return ( + innerHeight - ((Number(value) - valueDomain.min) / range) * innerHeight + ); + } + + return computeCategoryTickCenterPosition({ + value, + index, + categoryValues, + categoryIndexMap, + categoryScale, + reverse: true, + }); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeValueScale.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeValueScale.ts new file mode 100644 index 0000000000..e52348ec72 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/computeValueScale.ts @@ -0,0 +1,24 @@ +type ValueScale = { + valueToPixel: (value: number) => number; + range: number; +}; + +export const computeValueScale = ({ + domain, + axisLength, +}: { + domain: { min: number; max: number }; + axisLength: number; +}): ValueScale => { + const range = domain.max - domain.min; + + const valueToPixel = (value: number): number => { + if (range === 0) { + return 0; + } + const raw = ((value - domain.min) / range) * axisLength; + return Math.min(axisLength, Math.max(0, raw)); + }; + + return { valueToPixel, range }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/getAxisLayerLayout.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/getAxisLayerLayout.ts new file mode 100644 index 0000000000..fdf0110e80 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/getAxisLayerLayout.ts @@ -0,0 +1,127 @@ +import { computeBandScale } from '@/page-layout/widgets/graph/chart-core/utils/computeBandScale'; +import { computeBottomTickPosition } from '@/page-layout/widgets/graph/chart-core/utils/computeBottomTickPosition'; +import { computeLeftTickPosition } from '@/page-layout/widgets/graph/chart-core/utils/computeLeftTickPosition'; +import { getChartInnerDimensions } from '@/page-layout/widgets/graph/chart-core/utils/getChartInnerDimensions'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import { type AxisLayerConfig } from '@/page-layout/widgets/graph/chart-core/types/AxisLayerConfig'; + +type GetAxisLayerLayoutParams = { + bottomAxisTickRotation: number; + categoryValues: (string | number)[]; + categoryTickValues: (string | number)[]; + chartHeight: number; + chartWidth: number; + hasNegativeValues: boolean; + isVertical: boolean; + margins: ChartMargins; + valueDomain: { min: number; max: number }; + valueTickValues: number[]; + axisConfig: AxisLayerConfig; +}; + +type AxisLayerLayout = { + innerWidth: number; + innerHeight: number; + bottomTickValues: (string | number)[]; + leftTickValues: (string | number)[]; + getBottomTickPosition: (value: string | number, index: number) => number; + getLeftTickPosition: (value: string | number, index: number) => number; + hasRotation: boolean; + bottomLegendOffset: number; + leftLegendOffset: number; + shouldRenderZeroLine: boolean; + zeroPosition: number; +}; + +export const getAxisLayerLayout = ({ + bottomAxisTickRotation, + categoryValues, + categoryTickValues, + chartHeight, + chartWidth, + hasNegativeValues, + isVertical, + margins, + valueDomain, + valueTickValues, + axisConfig, +}: GetAxisLayerLayoutParams): AxisLayerLayout => { + const { innerWidth, innerHeight } = getChartInnerDimensions({ + chartWidth, + chartHeight, + margins, + }); + + const categoryIndexMap = new Map( + categoryValues.map((value, index) => [String(value), index]), + ); + + const categoryScale = computeBandScale({ + axisLength: isVertical ? innerWidth : innerHeight, + count: categoryValues.length, + padding: axisConfig.categoryPadding, + outerPaddingPx: axisConfig.categoryOuterPaddingPx, + }); + + const bottomTickValues = isVertical ? categoryTickValues : valueTickValues; + const leftTickValues = isVertical ? valueTickValues : categoryTickValues; + + const getBottomTickPosition = (value: string | number, index: number) => + computeBottomTickPosition({ + value, + index, + isVertical, + categoryValues, + categoryIndexMap, + categoryScale, + valueDomain, + innerWidth, + }); + + const getLeftTickPosition = (value: string | number, index: number) => + computeLeftTickPosition({ + value, + index, + isVertical, + categoryValues, + categoryIndexMap, + categoryScale, + valueDomain, + innerHeight, + }); + + const hasRotation = bottomAxisTickRotation !== 0; + + const rotatedLabelsExtraMargin = hasRotation + ? axisConfig.rotatedLabelsExtraMargin + : 0; + const bottomLegendOffset = Math.min( + axisConfig.bottomAxisLegendOffset + rotatedLabelsExtraMargin, + Math.max(margins.bottom - axisConfig.legendOffsetMarginBuffer, 0), + ); + + const leftLegendOffset = + -margins.left + axisConfig.leftAxisLegendOffsetPadding; + + const valueRange = valueDomain.max - valueDomain.min; + const shouldRenderZeroLine = hasNegativeValues && valueRange !== 0; + const zeroPosition = shouldRenderZeroLine + ? isVertical + ? innerHeight - ((0 - valueDomain.min) / valueRange) * innerHeight + : ((0 - valueDomain.min) / valueRange) * innerWidth + : 0; + + return { + innerWidth, + innerHeight, + bottomTickValues, + leftTickValues, + getBottomTickPosition, + getLeftTickPosition, + hasRotation, + bottomLegendOffset, + leftLegendOffset, + shouldRenderZeroLine, + zeroPosition, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/getChartInnerDimensions.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/getChartInnerDimensions.ts new file mode 100644 index 0000000000..1e196286a5 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/getChartInnerDimensions.ts @@ -0,0 +1,23 @@ +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; + +type GetChartInnerDimensionsParams = { + chartWidth: number; + chartHeight: number; + margins: ChartMargins; +}; + +type ChartInnerDimensions = { + innerWidth: number; + innerHeight: number; +}; + +export const getChartInnerDimensions = ({ + chartWidth, + chartHeight, + margins, +}: GetChartInnerDimensionsParams): ChartInnerDimensions => { + return { + innerWidth: chartWidth - margins.left - margins.right, + innerHeight: chartHeight - margins.top - margins.bottom, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/getPointerPosition.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/getPointerPosition.ts new file mode 100644 index 0000000000..71bc256291 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/getPointerPosition.ts @@ -0,0 +1,13 @@ +export const getPointerPosition = ({ + event, + element, +}: { + event: { clientX: number; clientY: number }; + element: HTMLElement; +}): { x: number; y: number } => { + const rect = element.getBoundingClientRect(); + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/isPointInChartArea.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/isPointInChartArea.ts new file mode 100644 index 0000000000..04fad4a32f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/isPointInChartArea.ts @@ -0,0 +1,13 @@ +export const isPointInChartArea = ({ + x, + y, + innerWidth, + innerHeight, +}: { + x: number; + y: number; + innerWidth: number; + innerHeight: number; +}): boolean => { + return x >= 0 && y >= 0 && x <= innerWidth && y <= innerHeight; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/renderGridLayer.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/renderGridLayer.ts new file mode 100644 index 0000000000..1be17c2aa8 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/chart-core/utils/renderGridLayer.ts @@ -0,0 +1,53 @@ +type RenderGridLayerParams = { + ctx: CanvasRenderingContext2D; + innerWidth: number; + innerHeight: number; + valueTickValues: number[]; + valueDomain: { min: number; max: number }; + isVertical: boolean; + gridColor: string; + lineWidth: number; + dashLength: number; + dashGap: number; +}; + +export const renderGridLayer = ({ + ctx, + innerWidth, + innerHeight, + valueTickValues, + valueDomain, + isVertical, + gridColor, + lineWidth, + dashLength, + dashGap, +}: RenderGridLayerParams): void => { + ctx.strokeStyle = gridColor; + ctx.lineWidth = lineWidth; + ctx.setLineDash([dashLength, dashGap]); + + const range = valueDomain.max - valueDomain.min; + if (range === 0) { + ctx.setLineDash([]); + return; + } + + for (const tickValue of valueTickValues) { + const normalizedPosition = (tickValue - valueDomain.min) / range; + + ctx.beginPath(); + if (isVertical) { + const y = innerHeight * (1 - normalizedPosition); + ctx.moveTo(0, y); + ctx.lineTo(innerWidth, y); + } else { + const x = innerWidth * normalizedPosition; + ctx.moveTo(x, 0); + ctx.lineTo(x, innerHeight); + } + ctx.stroke(); + } + + ctx.setLineDash([]); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChart.tsx new file mode 100644 index 0000000000..b10e57e79f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChart.tsx @@ -0,0 +1,246 @@ +import { BarChartLayers } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartLayers'; +import { useBarChartTheme } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme'; +import { useMemoizedBarPositions } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useMemoizedBarPositions'; +import { useBarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartLayout'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; +import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; +import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; +import { type BarChartSliceHoverData } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSliceHoverData'; +import { computeAllCategorySlices } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeAllCategorySlices'; +import { getSliceHoverDataFromMouseEvent } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getSliceHoverDataFromMouseEvent'; +import { hasNegativeValuesInData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/hasNegativeValuesInData'; +import { graphWidgetHighlightedLegendIdComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState'; +import { + formatGraphValue, + type GraphValueFormatOptions, +} from '@/page-layout/widgets/graph/utils/graphFormatters'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { type MouseEvent } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { BarChartLayout } from '~/generated/graphql'; + +type BarChartProps = { + data: BarChartDatum[]; + indexBy: string; + keys: string[]; + enrichedKeysMap: Map; + chartWidth: number; + chartHeight: number; + layout: BarChartLayout; + groupMode: 'grouped' | 'stacked'; + effectiveValueRange: { minimum: number; maximum: number }; + formatOptions: GraphValueFormatOptions; + axisConfig?: { + xAxisLabel?: string; + yAxisLabel?: string; + showGrid?: boolean; + }; + dataLabelsConfig?: { + show: boolean; + omitNullValues: boolean; + }; + hoveredSliceIndexValue: string | null; + onSliceHover: (data: BarChartSliceHoverData | null) => void; + onSliceClick?: (slice: BarChartSlice) => void; + onSliceLeave: () => void; + allowDataTransitions: boolean; + hasNoData?: boolean; +}; + +const StyledCanvasContainer = styled.div<{ isClickable: boolean }>` + cursor: ${({ isClickable }) => (isClickable ? 'pointer' : 'default')}; + height: 100%; + position: relative; + width: 100%; +`; + +export const BarChart = ({ + data, + indexBy, + keys, + enrichedKeysMap, + chartWidth, + chartHeight, + layout, + groupMode, + effectiveValueRange, + formatOptions, + axisConfig, + dataLabelsConfig, + hoveredSliceIndexValue, + onSliceHover, + onSliceClick, + onSliceLeave, + allowDataTransitions, + hasNoData = false, +}: BarChartProps) => { + const theme = useTheme(); + const highlightedLegendId = useRecoilComponentValue( + graphWidgetHighlightedLegendIdComponentState, + ); + + const chartTheme = useBarChartTheme(); + const { + axisBottomConfiguration, + axisLayerConfig, + categoryValues, + formatBottomTick, + formatLeftTick, + innerHeight, + innerPadding, + innerWidth, + margins, + resolvedCategoryTickValues, + valueDomain, + valueTickValues, + } = useBarChartLayout({ + axisTheme: chartTheme.axis, + axisConfig, + chartHeight, + chartWidth, + data, + effectiveValueRange, + formatOptions, + groupMode, + indexBy, + keys, + layout, + }); + + const isVerticalLayout = layout === BarChartLayout.VERTICAL; + + const hasNegativeValues = hasNegativeValuesInData(data, keys); + + const showGrid = axisConfig?.showGrid ?? true; + const showValues = dataLabelsConfig?.show ?? false; + const omitNullValues = dataLabelsConfig?.omitNullValues ?? false; + const showTotalsValues = showValues && !hasNoData; + + const shouldIncludeZeroValuesForLabels = + showValues && !hasNoData && !omitNullValues; + + const barsWithOptionalZeroValues = useMemoizedBarPositions({ + data, + indexBy, + keys, + enrichedKeysMap, + chartWidth, + chartHeight, + margins, + layout, + groupMode, + valueDomain, + innerPadding, + includeZeroValues: shouldIncludeZeroValuesForLabels, + }); + + const bars = shouldIncludeZeroValuesForLabels + ? barsWithOptionalZeroValues.filter((bar) => bar.value !== 0) + : barsWithOptionalZeroValues; + + const labelBars = shouldIncludeZeroValuesForLabels + ? barsWithOptionalZeroValues + : bars; + + const slices = computeAllCategorySlices({ + data, + indexBy, + bars, + isVerticalLayout: isVerticalLayout, + chartWidth, + chartHeight, + margins, + }); + + const hoveredSlice = isDefined(hoveredSliceIndexValue) + ? (slices.find((slice) => slice.indexValue === hoveredSliceIndexValue) ?? + null) + : null; + + const handleMouseMove = (event: MouseEvent) => { + const sliceHoverData = getSliceHoverDataFromMouseEvent({ + event, + margins, + innerWidth, + innerHeight, + slices, + isVerticalLayout, + }); + + if (!isDefined(sliceHoverData)) { + if (isDefined(hoveredSliceIndexValue)) { + onSliceHover(null); + } + return; + } + + if (sliceHoverData.slice.indexValue === hoveredSliceIndexValue) { + return; + } + + onSliceHover(sliceHoverData); + }; + + const handleClick = (event: MouseEvent) => { + if (!isDefined(onSliceClick)) { + return; + } + + const sliceHoverData = getSliceHoverDataFromMouseEvent({ + event, + margins, + innerWidth, + innerHeight, + slices, + isVerticalLayout, + }); + + if (isDefined(sliceHoverData)) { + onSliceClick(sliceHoverData.slice); + } + }; + const formatValue = (value: number) => formatGraphValue(value, formatOptions); + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartBaseLayer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartBaseLayer.tsx new file mode 100644 index 0000000000..6142cc5773 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartBaseLayer.tsx @@ -0,0 +1,61 @@ +import { BarChartBaseLayerEffect } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartBaseLayerEffect'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import styled from '@emotion/styled'; +import { useRef } from 'react'; +import { type BarChartLayout } from '~/generated/graphql'; + +type BarChartBaseLayerProps = { + bars: BarPosition[]; + chartWidth: number; + chartHeight: number; + margins: ChartMargins; + layout: BarChartLayout; + valueDomain: { min: number; max: number }; + valueTickValues: number[]; + showGrid: boolean; + highlightedLegendId: string | null; + allowDataTransitions: boolean; +}; + +const StyledBaseCanvas = styled.canvas` + display: block; + pointer-events: none; + position: absolute; + top: 0; + left: 0; +`; + +export const BarChartBaseLayer = ({ + bars, + chartWidth, + chartHeight, + margins, + layout, + valueDomain, + valueTickValues, + showGrid, + highlightedLegendId, + allowDataTransitions, +}: BarChartBaseLayerProps) => { + const canvasRef = useRef(null); + + return ( + <> + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartBaseLayerEffect.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartBaseLayerEffect.tsx new file mode 100644 index 0000000000..89035d46f9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartBaseLayerEffect.tsx @@ -0,0 +1,267 @@ +import { CHART_CORE_CONSTANTS } from '@/page-layout/widgets/graph/chart-core/constants/ChartCoreConstants'; +import { computeValueScale } from '@/page-layout/widgets/graph/chart-core/utils/computeValueScale'; +import { renderGridLayer } from '@/page-layout/widgets/graph/chart-core/utils/renderGridLayer'; +import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; +import { computeBaselineBar } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBaselineBar'; +import { interpolateBars } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/interpolateBars'; +import { renderBars } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/renderBars'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import { useTheme } from '@emotion/react'; +import { useCallback, useEffect, useState, type RefObject } from 'react'; +import { BarChartLayout } from '~/generated/graphql'; + +type BarChartBaseLayerEffectProps = { + bars: BarPosition[]; + chartWidth: number; + chartHeight: number; + margins: ChartMargins; + layout: BarChartLayout; + valueDomain: { min: number; max: number }; + valueTickValues: number[]; + showGrid: boolean; + highlightedLegendId: string | null; + allowDataTransitions: boolean; + canvasRef: RefObject; +}; + +type AnimationState = { + sourceBars: BarPosition[]; + targetBars: BarPosition[]; + startTime: number; + isAnimating: boolean; +}; + +export const BarChartBaseLayerEffect = ({ + bars, + chartWidth, + chartHeight, + margins, + layout, + valueDomain, + valueTickValues, + showGrid, + highlightedLegendId, + allowDataTransitions, + canvasRef, +}: BarChartBaseLayerEffectProps) => { + const theme = useTheme(); + + const borderRadius = parseInt(theme.border.radius.sm); + const gridColor = theme.border.color.light; + const isVertical = layout === BarChartLayout.VERTICAL; + const durationMs = + theme.animation.duration.normal * + CHART_CORE_CONSTANTS.MILLISECONDS_PER_SECOND; + + const [dpr] = useState( + () => + (typeof window !== 'undefined' ? window.devicePixelRatio : undefined) || + CHART_CORE_CONSTANTS.DEFAULT_DEVICE_PIXEL_RATIO, + ); + const [chartSize, setChartSize] = useState(() => ({ + width: chartWidth, + height: chartHeight, + })); + const [animationState, setAnimationState] = useState(() => ({ + sourceBars: bars, + targetBars: bars, + startTime: performance.now(), + isAnimating: false, + })); + + const innerWidth = chartWidth - margins.left - margins.right; + const innerHeight = chartHeight - margins.top - margins.bottom; + const axisLength = isVertical ? innerHeight : innerWidth; + const { valueToPixel } = computeValueScale({ + domain: valueDomain, + axisLength, + }); + const zeroPixel = valueToPixel(0); + + const toBaselineBar = useCallback( + (bar: BarPosition): BarPosition => + computeBaselineBar({ bar, innerHeight, zeroPixel, isVertical }), + [innerHeight, zeroPixel, isVertical], + ); + + useEffect(() => { + const sizeIsStable = + chartSize.width === chartWidth && chartSize.height === chartHeight; + + if (!sizeIsStable) { + setChartSize({ width: chartWidth, height: chartHeight }); + setAnimationState({ + sourceBars: bars, + targetBars: bars, + startTime: performance.now(), + isAnimating: false, + }); + return; + } + + if (!allowDataTransitions) { + setAnimationState({ + sourceBars: bars, + targetBars: bars, + startTime: performance.now(), + isAnimating: false, + }); + return; + } + + setAnimationState((prev) => { + const now = performance.now(); + + if (prev.targetBars === bars) { + return prev; + } + + if (bars.length === 0 && prev.targetBars.length === 0) { + return { + sourceBars: bars, + targetBars: bars, + startTime: now, + isAnimating: false, + }; + } + + const sourceBars = prev.isAnimating + ? interpolateBars( + prev.sourceBars, + prev.targetBars, + Math.min((now - prev.startTime) / durationMs, 1), + toBaselineBar, + ) + : prev.targetBars; + + return { + sourceBars, + targetBars: bars, + startTime: now, + isAnimating: true, + }; + }); + }, [ + allowDataTransitions, + bars, + chartHeight, + chartSize, + chartWidth, + durationMs, + toBaselineBar, + ]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + if (chartWidth <= 0 || chartHeight <= 0) { + return; + } + + canvas.width = chartWidth * dpr; + canvas.height = chartHeight * dpr; + canvas.style.width = `${chartWidth}px`; + canvas.style.height = `${chartHeight}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const render = (barsToRender: BarPosition[]) => { + const innerW = chartWidth - margins.left - margins.right; + const innerH = chartHeight - margins.top - margins.bottom; + + ctx.clearRect(0, 0, chartWidth, chartHeight); + ctx.save(); + ctx.translate(margins.left, margins.top); + + if (showGrid) { + renderGridLayer({ + ctx, + innerWidth: innerW, + innerHeight: innerH, + valueTickValues, + valueDomain, + isVertical, + gridColor, + lineWidth: BAR_CHART_CONSTANTS.GRID_LINE_WIDTH, + dashLength: BAR_CHART_CONSTANTS.GRID_DASH_LENGTH, + dashGap: BAR_CHART_CONSTANTS.GRID_DASH_GAP, + }); + } + + renderBars({ + ctx, + bars: barsToRender, + borderRadius, + isVertical, + highlightedLegendId, + }); + + ctx.restore(); + }; + + if ( + !animationState.isAnimating || + animationState.sourceBars === animationState.targetBars + ) { + render(animationState.targetBars); + return; + } + + let frameId = 0; + + const drawFrame = () => { + const elapsed = performance.now() - animationState.startTime; + const t = Math.min(elapsed / durationMs, 1); + + if (t >= 1) { + render(animationState.targetBars); + return; + } + + const interpolatedBars = interpolateBars( + animationState.sourceBars, + animationState.targetBars, + t, + toBaselineBar, + ); + + render(interpolatedBars); + + frameId = requestAnimationFrame(drawFrame); + }; + + frameId = requestAnimationFrame(drawFrame); + + return () => cancelAnimationFrame(frameId); + }, [ + animationState, + borderRadius, + chartHeight, + chartWidth, + dpr, + durationMs, + gridColor, + highlightedLegendId, + isVertical, + margins.bottom, + margins.left, + margins.right, + margins.top, + showGrid, + toBaselineBar, + valueDomain, + valueTickValues, + canvasRef, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartHoverLayer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartHoverLayer.tsx new file mode 100644 index 0000000000..23819ba35e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartHoverLayer.tsx @@ -0,0 +1,45 @@ +import { BarChartHoverLayerEffect } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartHoverLayerEffect'; +import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import styled from '@emotion/styled'; +import { useRef } from 'react'; +import { type BarChartLayout } from '~/generated/graphql'; + +type BarChartHoverLayerProps = { + hoveredSlice: BarChartSlice | null; + chartWidth: number; + chartHeight: number; + margins: ChartMargins; + layout: BarChartLayout; +}; + +const StyledHoverCanvas = styled.canvas` + display: block; + position: absolute; + top: 0; + left: 0; +`; + +export const BarChartHoverLayer = ({ + hoveredSlice, + chartWidth, + chartHeight, + margins, + layout, +}: BarChartHoverLayerProps) => { + const canvasRef = useRef(null); + + return ( + <> + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartHoverLayerEffect.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartHoverLayerEffect.tsx new file mode 100644 index 0000000000..0f6233f9ce --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartHoverLayerEffect.tsx @@ -0,0 +1,94 @@ +import { CHART_CORE_CONSTANTS } from '@/page-layout/widgets/graph/chart-core/constants/ChartCoreConstants'; +import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; +import { renderSliceHighlight } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/renderSliceHighlight'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import { useTheme } from '@emotion/react'; +import { useEffect, useState, type RefObject } from 'react'; +import { BarChartLayout } from '~/generated/graphql'; + +type BarChartHoverLayerEffectProps = { + hoveredSlice: BarChartSlice | null; + chartWidth: number; + chartHeight: number; + margins: ChartMargins; + layout: BarChartLayout; + canvasRef: RefObject; +}; + +export const BarChartHoverLayerEffect = ({ + hoveredSlice, + chartWidth, + chartHeight, + margins, + layout, + canvasRef, +}: BarChartHoverLayerEffectProps) => { + const theme = useTheme(); + const [dpr] = useState( + () => + (typeof window !== 'undefined' ? window.devicePixelRatio : undefined) || + CHART_CORE_CONSTANTS.DEFAULT_DEVICE_PIXEL_RATIO, + ); + + const isVertical = layout === BarChartLayout.VERTICAL; + const highlightColor = theme.background.transparent.medium; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + if (chartWidth <= 0 || chartHeight <= 0) { + return; + } + + canvas.width = chartWidth * dpr; + canvas.height = chartHeight * dpr; + canvas.style.width = `${chartWidth}px`; + canvas.style.height = `${chartHeight}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, chartWidth, chartHeight); + + if (!hoveredSlice) { + return; + } + + ctx.save(); + ctx.translate(margins.left, margins.top); + + const innerWidth = chartWidth - margins.left - margins.right; + const innerHeight = chartHeight - margins.top - margins.bottom; + + renderSliceHighlight({ + ctx, + slice: hoveredSlice, + innerWidth, + innerHeight, + isVertical, + highlightColor, + }); + + ctx.restore(); + }, [ + hoveredSlice, + chartWidth, + chartHeight, + margins.left, + margins.right, + margins.top, + margins.bottom, + isVertical, + highlightColor, + dpr, + canvasRef, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartLayers.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartLayers.tsx new file mode 100644 index 0000000000..5d74bedbe3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartLayers.tsx @@ -0,0 +1,148 @@ +import { AxisLayer } from '@/page-layout/widgets/graph/chart-core/layers/AxisLayer'; +import { type AxisLayerConfig } from '@/page-layout/widgets/graph/chart-core/types/AxisLayerConfig'; +import { NoDataLayer } from '@/page-layout/widgets/graph/components/NoDataLayer'; +import { BarChartBaseLayer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartBaseLayer'; +import { BarChartHoverLayer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartHoverLayer'; +import { BarChartTotalsLayer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartTotalsLayer'; +import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import styled from '@emotion/styled'; +import { BarChartLayout } from '~/generated/graphql'; + +const StyledNoDataOverlay = styled.svg` + height: 100%; + left: 0; + pointer-events: none; + position: absolute; + top: 0; + width: 100%; +`; + +type BarChartLayersProps = { + allowDataTransitions: boolean; + axisBottomTickRotation: number; + axisConfig: AxisLayerConfig; + bars: BarPosition[]; + categoryValues: (string | number)[]; + categoryTickValues: (string | number)[]; + chartHeight: number; + chartWidth: number; + formatBottomTick: (value: string | number) => string; + formatLeftTick: (value: string | number) => string; + formatValue: (value: number) => string; + groupMode: 'grouped' | 'stacked'; + hasNegativeValues: boolean; + hasNoData: boolean; + highlightedLegendId: string | null; + hoveredSlice: BarChartSlice | null; + innerHeight: number; + innerWidth: number; + labelBars: BarPosition[]; + layout: BarChartLayout; + margins: ChartMargins; + omitNullValues: boolean; + offset: number; + showGrid: boolean; + showValues: boolean; + valueDomain: { min: number; max: number }; + valueTickValues: number[]; + xAxisLabel?: string; + yAxisLabel?: string; +}; + +export const BarChartLayers = ({ + allowDataTransitions, + axisBottomTickRotation, + axisConfig, + bars, + categoryValues, + categoryTickValues, + chartHeight, + chartWidth, + formatBottomTick, + formatLeftTick, + formatValue, + groupMode, + hasNegativeValues, + hasNoData, + highlightedLegendId, + hoveredSlice, + innerHeight, + innerWidth, + labelBars, + layout, + margins, + omitNullValues, + offset, + showGrid, + showValues, + valueDomain, + valueTickValues, + xAxisLabel, + yAxisLabel, +}: BarChartLayersProps) => { + return ( + <> + + + + + {hasNoData && ( + + + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartTooltip.tsx similarity index 90% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip.tsx rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartTooltip.tsx index 83bd473f7a..7ad8de5c52 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartTooltip.tsx @@ -1,6 +1,7 @@ import { GraphWidgetFloatingTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetFloatingTooltip'; import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; import { graphWidgetBarTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; import { getBarChartTooltipData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData'; @@ -10,8 +11,9 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho import { type RefObject } from 'react'; import { isDefined } from 'twenty-shared/utils'; -type GraphBarChartTooltipProps = { +type BarChartTooltipProps = { containerRef: RefObject; + dataByIndexValue: Map; enrichedKeys: BarChartEnrichedKey[]; formatOptions: GraphValueFormatOptions; onSliceClick?: (slice: BarChartSlice) => void; @@ -19,14 +21,15 @@ type GraphBarChartTooltipProps = { onMouseLeave?: () => void; }; -export const GraphBarChartTooltip = ({ +export const BarChartTooltip = ({ containerRef, + dataByIndexValue, enrichedKeys, formatOptions, onSliceClick, onMouseEnter, onMouseLeave, -}: GraphBarChartTooltipProps) => { +}: BarChartTooltipProps) => { const tooltipState = useRecoilComponentValue( graphWidgetBarTooltipComponentState, ); @@ -48,6 +51,7 @@ export const GraphBarChartTooltip = ({ ? null : getBarChartTooltipData({ slice: tooltipState.slice, + dataByIndexValue, enrichedKeys, formatOptions, }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomTotalsLayer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartTotalsLayer.tsx similarity index 58% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomTotalsLayer.tsx rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartTotalsLayer.tsx index 1c9911d5f7..9ad7e35857 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomTotalsLayer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartTotalsLayer.tsx @@ -1,12 +1,18 @@ import { GraphDataLabel } from '@/page-layout/widgets/graph/components/GraphDataLabel'; import { type BarChartLabelData } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLabelData'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; import { computeBarChartGroupedLabels } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartGroupedLabels'; import { computeBarChartStackedLabels } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartStackedLabels'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; import { type GraphLabelData } from '@/page-layout/widgets/graph/types/GraphLabelData'; -import { type BarCustomLayerProps, type BarDatum } from '@nivo/bar'; +import styled from '@emotion/styled'; import { BarChartLayout } from '~/generated/graphql'; -type CustomTotalsLayerProps = Pick, 'bars'> & { +type BarChartTotalsLayerProps = { + bars: BarPosition[]; + margins: ChartMargins; + chartWidth: number; + chartHeight: number; formatValue?: (value: number) => string; offset?: number; layout?: BarChartLayout; @@ -15,6 +21,13 @@ type CustomTotalsLayerProps = Pick, 'bars'> & { showValues: boolean; }; +const StyledSvgLabels = styled.svg` + left: 0; + pointer-events: none; + position: absolute; + top: 0; +`; + const convertToGraphLabelData = ( barChartLabel: BarChartLabelData, isVerticalLayout: boolean, @@ -28,16 +41,19 @@ const convertToGraphLabelData = ( }; }; -export const CustomTotalsLayer = ({ +export const BarChartTotalsLayer = ({ bars, + margins, + chartWidth, + chartHeight, formatValue, offset = 0, layout = BarChartLayout.VERTICAL, groupMode = 'grouped', omitNullValues = false, showValues, -}: CustomTotalsLayerProps) => { - if (!showValues) { +}: BarChartTotalsLayerProps) => { + if (!showValues || bars.length === 0) { return null; } @@ -53,23 +69,25 @@ export const CustomTotalsLayer = ({ : barChartLabels; return ( - <> - {barChartLabelsToRender.map((barChartLabel) => { - const graphLabel = convertToGraphLabelData( - barChartLabel, - isVerticalLayout, - ); + + + {barChartLabelsToRender.map((barChartLabel) => { + const graphLabel = convertToGraphLabelData( + barChartLabel, + isVerticalLayout, + ); - return ( - - ); - })} - + return ( + + ); + })} + + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx deleted file mode 100644 index 1d64fc3b3b..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { LEGEND_HIGHLIGHT_DIMMED_OPACITY } from '@/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant'; -import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; -import { graphWidgetIsSliceHoveredComponentFamilySelector } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetIsSliceHoveredComponentFamilySelector'; -import { graphWidgetHighlightedLegendIdComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState'; -import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { type BarDatum, type BarItemProps } from '@nivo/bar'; -import { animated, to } from '@react-spring/web'; -import { isNumber } from '@sniptt/guards'; -import { useMemo } from 'react'; -import styled from 'styled-components'; -import { isDefined } from 'twenty-shared/utils'; -import { BarChartLayout } from '~/generated/graphql'; - -type CustomBarItemProps = BarItemProps & { - shouldRoundFreeEnd: boolean; - seriesIndex: number; - layout?: BarChartLayout; - chartId?: string; -}; - -const StyledBarRect = styled(animated.rect)<{ - $isInteractive?: boolean; - $isDimmed?: boolean; - $isSliceHovered?: boolean; -}>` - cursor: ${({ $isInteractive }) => ($isInteractive ? 'pointer' : 'default')}; - filter: ${({ $isSliceHovered, $isInteractive }) => - $isSliceHovered && $isInteractive - ? `brightness(${BAR_CHART_CONSTANTS.HOVER_BRIGHTNESS})` - : 'none'}; - opacity: ${({ $isDimmed }) => - $isDimmed ? LEGEND_HIGHLIGHT_DIMMED_OPACITY : 1}; - pointer-events: none; - transition: - filter 0.15s ease-in-out, - opacity 0.15s ease-in-out; -`; - -// This is a copy of the BarItem component from @nivo/bar with some design modifications -export const CustomBarItem = ({ - bar: { data: barData }, - style: { borderColor, color, height, transform, width }, - borderRadius, - borderWidth, - isInteractive, - isFocusable, - ariaLabel, - ariaLabelledBy, - ariaDescribedBy, - ariaDisabled, - ariaHidden, - shouldRoundFreeEnd, - seriesIndex, - layout = BarChartLayout.VERTICAL, - chartId, -}: CustomBarItemProps) => { - const highlightedLegendId = useRecoilComponentValue( - graphWidgetHighlightedLegendIdComponentState, - ); - - const isSliceHovered = useRecoilComponentFamilyValue( - graphWidgetIsSliceHoveredComponentFamilySelector, - String(barData.indexValue), - ); - - const isDimmed = - isDefined(highlightedLegendId) && - String(highlightedLegendId) !== String(barData.id); - - const isNegativeValue = isNumber(barData.value) && barData.value < 0; - - const isHorizontal = layout === BarChartLayout.HORIZONTAL; - const clipPathId = `round-corner-${chartId ?? 'chart'}-${barData.index}-${ - seriesIndex >= 0 ? seriesIndex : 'x' - }`; - - const clipPathX = !isHorizontal || isNegativeValue ? 0 : -borderRadius; - const clipPathY = isHorizontal || !isNegativeValue ? 0 : -borderRadius; - - const barInterpolations = useMemo(() => { - const unconstrainedThicknessDimension = isHorizontal ? height : width; - const unconstrainedValueDimension = isHorizontal ? width : height; - - const constrainedThicknessDimension = to( - unconstrainedThicknessDimension, - (dimension) => Math.min(dimension, BAR_CHART_CONSTANTS.MAXIMUM_WIDTH), - ); - - const centeringOffset = to(unconstrainedThicknessDimension, (dimension) => - dimension > BAR_CHART_CONSTANTS.MAXIMUM_WIDTH - ? (dimension - BAR_CHART_CONSTANTS.MAXIMUM_WIDTH) / 2 - : 0, - ); - - const centeringTransform = to(centeringOffset, (offset) => - isHorizontal ? `translate(0, ${offset})` : `translate(${offset}, 0)`, - ); - - const finalBarWidthDimension = isHorizontal - ? unconstrainedValueDimension - : constrainedThicknessDimension; - - const finalBarHeightDimension = isHorizontal - ? constrainedThicknessDimension - : unconstrainedValueDimension; - - const clampToZero = (value: number) => Math.max(value, 0); - - return { - centeringTransform, - finalBarWidth: to(finalBarWidthDimension, clampToZero), - finalBarHeight: to(finalBarHeightDimension, clampToZero), - finalBarWidthDimension, - finalBarHeightDimension, - }; - }, [width, height, isHorizontal]); - - const clipInterpolations = useMemo(() => { - if (!shouldRoundFreeEnd) { - return null; - } - - const { finalBarWidthDimension, finalBarHeightDimension } = - barInterpolations; - - const widthWithOffset = (value: number) => - Math.max(value + (isHorizontal ? borderRadius : 0), 0); - const heightWithOffset = (value: number) => - Math.max(value + (isHorizontal ? 0 : borderRadius), 0); - const clampRadius = (value: number) => Math.min(borderRadius, value / 2); - - return { - clipRectWidth: to(finalBarWidthDimension, widthWithOffset), - clipRectHeight: to(finalBarHeightDimension, heightWithOffset), - clipBorderRadiusX: to(finalBarWidthDimension, (value) => - clampRadius(widthWithOffset(value)), - ), - clipBorderRadiusY: to(finalBarHeightDimension, (value) => - clampRadius(heightWithOffset(value)), - ), - }; - }, [barInterpolations, shouldRoundFreeEnd, isHorizontal, borderRadius]); - - return ( - - - {clipInterpolations && ( - - - - - - )} - - - - - ); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomSliceHoverLayer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomSliceHoverLayer.tsx deleted file mode 100644 index 7a15daf825..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomSliceHoverLayer.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { graphWidgetHoveredSliceIndexComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetHoveredSliceIndexComponentState'; -import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; -import { computeSliceHighlightPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSliceHighlightPosition'; -import { computeSlicesFromBars } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSlicesFromBars'; -import { findSliceAtPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/findSliceAtPosition'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { useTheme } from '@emotion/react'; -import { type BarDatum, type ComputedBarDatum } from '@nivo/bar'; -import { AnimatePresence, motion } from 'framer-motion'; -import { useCallback, useMemo, type MouseEvent } from 'react'; -import { isDefined } from 'twenty-shared/utils'; -import { BarChartLayout } from '~/generated/graphql'; - -type SliceHoverCallbackData = { - slice: BarChartSlice; - offsetLeft: number; - offsetTop: number; -}; - -type CustomSliceHoverLayerProps = { - bars: readonly ComputedBarDatum[]; - innerWidth: number; - innerHeight: number; - marginLeft: number; - marginTop: number; - layout: BarChartLayout; - onSliceHover: (data: SliceHoverCallbackData | null) => void; - onSliceClick?: (slice: BarChartSlice) => void; - onSliceLeave: () => void; -}; - -export const CustomSliceHoverLayer = ({ - bars, - innerWidth, - innerHeight, - marginLeft, - marginTop, - layout, - onSliceHover, - onSliceClick, - onSliceLeave, -}: CustomSliceHoverLayerProps) => { - const theme = useTheme(); - - const hoveredSliceIndexValue = useRecoilComponentValue( - graphWidgetHoveredSliceIndexComponentState, - ); - - const isVerticalLayout = layout === BarChartLayout.VERTICAL; - - const slices = useMemo( - () => computeSlicesFromBars({ bars, isVerticalLayout }), - [bars, isVerticalLayout], - ); - - const handleMouseMove = useCallback( - (event: MouseEvent) => { - const sliceData = findSliceAtPosition({ - event, - slices, - marginLeft, - marginTop, - isVerticalLayout, - }); - - if (!isDefined(sliceData)) { - if (isDefined(hoveredSliceIndexValue)) { - onSliceHover(null); - } - return; - } - - if (sliceData.slice.indexValue === hoveredSliceIndexValue) { - return; - } - - onSliceHover(sliceData); - }, - [ - onSliceHover, - slices, - marginLeft, - marginTop, - isVerticalLayout, - hoveredSliceIndexValue, - ], - ); - - const handleMouseLeave = () => { - onSliceLeave(); - }; - - const handleClick = (event: MouseEvent) => { - if (!isDefined(onSliceClick)) { - return; - } - - const sliceData = findSliceAtPosition({ - event, - slices, - marginLeft, - marginTop, - isVerticalLayout, - }); - - if (!isDefined(sliceData)) { - return; - } - - onSliceClick(sliceData.slice); - }; - - const hoveredSlice = useMemo(() => { - if (!isDefined(hoveredSliceIndexValue)) { - return null; - } - return slices.find((slice) => slice.indexValue === hoveredSliceIndexValue); - }, [slices, hoveredSliceIndexValue]); - - const highlightPosition = computeSliceHighlightPosition({ - sliceCenter: hoveredSlice?.sliceCenter ?? null, - isVerticalLayout, - innerWidth, - innerHeight, - }); - - if (bars.length === 0) { - return null; - } - - return ( - - - {isDefined(hoveredSlice) && ( - - - - )} - - - - - ); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx index 867254e470..65f62c3895 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx @@ -1,69 +1,53 @@ +import { isSidePanelAnimatingState } from '@/command-menu/states/isSidePanelAnimatingState'; +import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/pageLayoutDraggingWidgetIdComponentState'; +import { pageLayoutResizingWidgetIdComponentState } from '@/page-layout/states/pageLayoutResizingWidgetIdComponentState'; import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer'; import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend'; -import { NoDataLayer } from '@/page-layout/widgets/graph/components/NoDataLayer'; -import { CHART_MOTION_CONFIG } from '@/page-layout/widgets/graph/constants/ChartMotionConfig'; -import { CustomBarItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem'; -import { CustomSliceHoverLayer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/CustomSliceHoverLayer'; -import { CustomTotalsLayer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/CustomTotalsLayer'; -import { GraphBarChartTooltip } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip'; -import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { BarChart } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/BarChart'; +import { BarChartTooltip } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartTooltip'; import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData'; -import { useBarChartTheme } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme'; import { graphWidgetBarTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState'; import { graphWidgetHoveredSliceIndexComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetHoveredSliceIndexComponentState'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; +import { type BarChartSliceHoverData } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSliceHoverData'; import { calculateStackedBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange'; import { calculateValueRangeFromBarChartKeys } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys'; -import { computeShouldRoundFreeEndMap } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeShouldRoundFreeEndMap'; -import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor'; -import { getBarChartInnerPadding } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartInnerPadding'; -import { getBarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartLayout'; import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; import { computeEffectiveValueRange } from '@/page-layout/widgets/graph/utils/computeEffectiveValueRange'; import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry'; -import { - formatGraphValue, - type GraphValueFormatOptions, -} from '@/page-layout/widgets/graph/utils/graphFormatters'; +import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters'; import { NodeDimensionEffect } from '@/ui/utilities/dimensions/components/NodeDimensionEffect'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { - ResponsiveBar, - type BarCustomLayerProps, - type BarDatum, - type BarItemProps, - type ComputedBarDatum, -} from '@nivo/bar'; import { useMemo, useRef, useState } from 'react'; +import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; import { useDebouncedCallback } from 'use-debounce'; import { BarChartLayout } from '~/generated/graphql'; -type NoDataLayerWrapperProps = BarCustomLayerProps; -type SliceHoverLayerWrapperProps = BarCustomLayerProps; - type GraphWidgetBarChartProps = { - data: BarDatum[]; + colorMode: GraphColorMode; + data: BarChartDatum[]; + groupMode?: 'grouped' | 'stacked'; + id: string; indexBy: string; keys: string[]; + layout?: BarChartLayout; + omitNullValues?: boolean; + onSliceClick?: (slice: BarChartSlice) => void; + rangeMax?: number; + rangeMin?: number; series?: BarChartSeriesWithColor[]; - showLegend?: boolean; + seriesLabels?: Record; showGrid?: boolean; + showLegend?: boolean; showValues?: boolean; xAxisLabel?: string; yAxisLabel?: string; - id: string; - layout?: BarChartLayout; - groupMode?: 'grouped' | 'stacked'; - colorMode: GraphColorMode; - seriesLabels?: Record; - rangeMin?: number; - rangeMax?: number; - omitNullValues?: boolean; - onSliceClick?: (slice: BarChartSlice) => void; } & GraphValueFormatOptions; const StyledContainer = styled.div` @@ -87,7 +71,7 @@ export const GraphWidgetBarChart = ({ yAxisLabel, id, layout = BarChartLayout.VERTICAL, - groupMode, + groupMode = 'grouped', colorMode, seriesLabels, rangeMin, @@ -115,16 +99,33 @@ export const GraphWidgetBarChart = ({ graphWidgetHoveredSliceIndexComponentState, ); + const hoveredSliceIndexValue = useRecoilComponentValue( + graphWidgetHoveredSliceIndexComponentState, + ); + + const draggingWidgetId = useRecoilComponentValue( + pageLayoutDraggingWidgetIdComponentState, + ); + + const resizingWidgetId = useRecoilComponentValue( + pageLayoutResizingWidgetIdComponentState, + ); + + const isSidePanelAnimating = useRecoilValue(isSidePanelAnimatingState); + + const isLayoutAnimating = + isSidePanelAnimating || draggingWidgetId === id || resizingWidgetId === id; + + const allowDataTransitions = !isLayoutAnimating; + const formatOptions: GraphValueFormatOptions = { - displayType, + customFormatter, decimals, + displayType, prefix, suffix, - customFormatter, }; - const chartTheme = useBarChartTheme(); - const { enrichedKeysMap, enrichedKeys, legendItems, visibleKeys } = useBarChartData({ keys, series, colorRegistry, seriesLabels, colorMode }); @@ -133,23 +134,6 @@ export const GraphWidgetBarChart = ({ ? visibleKeys.toReversed() : visibleKeys; - const shouldRoundFreeEndMap = useMemo( - () => - computeShouldRoundFreeEndMap({ - data, - orderedKeys, - indexBy, - groupMode, - }), - [groupMode, orderedKeys, data, indexBy], - ); - - const keyToIndexMap = useMemo(() => { - return new Map( - orderedKeys?.map((key, index) => [key, index]) ?? [], - ); - }, [orderedKeys]); - const calculatedValueRange = groupMode === 'stacked' ? calculateStackedBarChartValueRange(data, visibleKeys) @@ -159,31 +143,16 @@ export const GraphWidgetBarChart = ({ const { effectiveMinimumValue, effectiveMaximumValue } = computeEffectiveValueRange({ - calculatedMinimum: calculatedValueRange.minimum, calculatedMaximum: calculatedValueRange.maximum, - rangeMin, + calculatedMinimum: calculatedValueRange.minimum, rangeMax, + rangeMin, }); - const { - margins, - axisBottomConfiguration, - axisLeftConfiguration, - valueTickValues, - valueDomain, - } = getBarChartLayout({ - axisTheme: chartTheme.axis, - chartWidth, - chartHeight, - data, - indexBy, - layout, - xAxisLabel, - yAxisLabel, - formatOptions, - effectiveMinimumValue, - effectiveMaximumValue, - }); + const dataByIndexValue = useMemo( + () => new Map(data.map((row) => [String(row[indexBy]), row])), + [data, indexBy], + ); const hasClickableItems = isDefined(onSliceClick); @@ -200,20 +169,14 @@ export const GraphWidgetBarChart = ({ const handleTooltipMouseLeave = debouncedHideTooltip; - const handleSliceHover = ( - sliceData: { - slice: BarChartSlice; - offsetLeft: number; - offsetTop: number; - } | null, - ) => { + const handleSliceHover = (sliceData: BarChartSliceHoverData | null) => { if (isDefined(sliceData)) { debouncedHideTooltip.cancel(); setHoveredSliceIndex(sliceData.slice.indexValue); setActiveBarTooltip({ - slice: sliceData.slice, offsetLeft: sliceData.offsetLeft, offsetTop: sliceData.offsetTop, + slice: sliceData.slice, }); } else { debouncedHideTooltip(); @@ -224,186 +187,66 @@ export const GraphWidgetBarChart = ({ debouncedHideTooltip(); }; - const MemoizedBarItem = useMemo( - () => (props: BarItemProps) => { - if (props.bar.data.value === 0) { - return null; - } - - const barKey = JSON.stringify([ - props.bar.data.indexValue, - props.bar.data.id, - ]); - const shouldRoundFreeEnd = shouldRoundFreeEndMap?.get(barKey) ?? true; - const seriesIndex = keyToIndexMap.get(String(props.bar.data.id)) ?? -1; - - return ( - - ); - }, - [shouldRoundFreeEndMap, keyToIndexMap, layout, id], - ); - - const TotalsLayer = ({ - bars, - }: { - bars: readonly ComputedBarDatum[]; - }) => { - if (hasNoData) { - return null; - } - - return ( - formatGraphValue(value, formatOptions)} - offset={theme.spacingMultiplicator * 2} - layout={layout} - groupMode={groupMode} - omitNullValues={omitNullValues} - showValues={showValues} - /> - ); - }; - - const NoDataLayerWrapper = (layerProps: NoDataLayerWrapperProps) => ( - - ); - - const SliceHoverLayerWrapper = (layerProps: SliceHoverLayerWrapperProps) => - hasNoData ? null : ( - - ); - - const hasNegativeValues = calculatedValueRange.minimum < 0; - const zeroMarker = hasNegativeValues - ? [ - { - axis: (layout === BarChartLayout.VERTICAL ? 'y' : 'x') as 'y' | 'x', - value: 0, - lineStyle: { - stroke: theme.border.color.medium, - strokeWidth: 1, - }, - }, - ] - : undefined; - return ( { - setChartWidth(width); + onDimensionChange={({ height, width }) => { setChartHeight(height); + setChartWidth(width); }} /> - getBarChartColor(datum, enrichedKeysMap, theme)} - animate - motionConfig={CHART_MOTION_CONFIG} - layers={[ - 'grid', - 'markers', - 'axes', - SliceHoverLayerWrapper, - 'bars', - 'legends', - TotalsLayer, - NoDataLayerWrapper, - ]} - markers={zeroMarker} - axisTop={null} - axisRight={null} - axisBottom={axisBottomConfiguration} - axisLeft={axisLeftConfiguration} - enableGridX={layout === BarChartLayout.HORIZONTAL && showGrid} - enableGridY={layout === BarChartLayout.VERTICAL && showGrid} - gridXValues={ - layout === BarChartLayout.HORIZONTAL ? valueTickValues : undefined - } - gridYValues={ - layout === BarChartLayout.VERTICAL ? valueTickValues : undefined - } - enableLabel={false} - labelSkipWidth={12} - innerPadding={getBarChartInnerPadding({ - chartWidth, - chartHeight, - dataLength: data.length, - keysLength: visibleKeys.length, - layout, - margins, - groupMode, - })} - labelSkipHeight={12} - valueFormat={(value) => - formatGraphValue(Number(value), formatOptions) - } - labelTextColor={theme.font.color.primary} - label={(barDatumCandidate) => - formatGraphValue(Number(barDatumCandidate.value), formatOptions) - } - tooltip={() => null} - theme={chartTheme} - borderRadius={parseInt(theme.border.radius.sm)} - /> + {chartWidth > 0 && chartHeight > 0 && ( + + )} - 0 && keys.length > 0} items={legendItems} + show={showLegend && data.length > 0 && keys.length > 0} /> ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants.ts index 54bc36f032..d5d5409bd4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants.ts @@ -7,6 +7,7 @@ export const BAR_CHART_CONSTANTS = { MAXIMUM_NUMBER_OF_GROUPS_PER_BAR: 50, MAXIMUM_WIDTH: 32, OUTER_PADDING_RATIO: 0.05, + OUTER_PADDING_PX: 4, DEFAULT_INNER_PADDING: 4, MAXIMUM_VALUE_TICK_COUNT: 6, MINIMUM_VALUE_TICK_COUNT: 2, @@ -16,6 +17,10 @@ export const BAR_CHART_CONSTANTS = { TOOLTIP_SCROLLABLE_ITEM_THRESHOLD: 5, HOVER_BRIGHTNESS: 0.85, SLICE_HIGHLIGHT_THICKNESS: 1, + GRID_LINE_WIDTH: 1, + GRID_DASH_LENGTH: 4, + GRID_DASH_GAP: 4, + ANIMATION_EASING_EXPONENT: 3, MINIMUM_BAR_WIDTH: 2, DATE_GRANULARITIES_WITHOUT_GAP_FILLING: new Set([ ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartLayout.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartLayout.ts new file mode 100644 index 0000000000..8a6ac53d97 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartLayout.ts @@ -0,0 +1,160 @@ +import { type AxisLayerConfig } from '@/page-layout/widgets/graph/chart-core/types/AxisLayerConfig'; +import { getChartInnerDimensions } from '@/page-layout/widgets/graph/chart-core/utils/getChartInnerDimensions'; +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; +import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; +import { getBarChartInnerPadding } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartInnerPadding'; +import { getBarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartLayout'; +import { type ChartAxisTheme } from '@/page-layout/widgets/graph/types/ChartAxisTheme'; +import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters'; +import { resolveAxisFontSizes } from '@/page-layout/widgets/graph/utils/resolveAxisFontSizes'; +import { useMemo } from 'react'; +import { BarChartLayout } from '~/generated/graphql'; + +type UseBarChartLayoutParams = { + axisTheme: ChartAxisTheme; + axisConfig?: { + xAxisLabel?: string; + yAxisLabel?: string; + }; + chartHeight: number; + chartWidth: number; + data: BarChartDatum[]; + effectiveValueRange: { minimum: number; maximum: number }; + formatOptions: GraphValueFormatOptions; + groupMode: 'grouped' | 'stacked'; + indexBy: string; + keys: string[]; + layout: BarChartLayout; +}; + +type UseBarChartLayoutResult = { + axisBottomConfiguration: ReturnType< + typeof getBarChartLayout + >['axisBottomConfiguration']; + axisLayerConfig: AxisLayerConfig; + categoryValues: string[]; + innerHeight: number; + innerPadding: number; + innerWidth: number; + margins: ReturnType['margins']; + resolvedCategoryTickValues: (string | number)[]; + formatBottomTick: (value: string | number) => string; + formatLeftTick: (value: string | number) => string; + valueDomain: ReturnType['valueDomain']; + valueTickValues: ReturnType['valueTickValues']; +}; + +export const useBarChartLayout = ({ + axisTheme, + axisConfig, + chartHeight, + chartWidth, + data, + effectiveValueRange, + formatOptions, + groupMode, + indexBy, + keys, + layout, +}: UseBarChartLayoutParams): UseBarChartLayoutResult => { + const { tickFontSize, legendFontSize } = resolveAxisFontSizes(axisTheme); + + const { + margins, + axisBottomConfiguration, + axisLeftConfiguration, + valueTickValues, + valueDomain, + } = getBarChartLayout({ + axisTheme, + chartHeight, + chartWidth, + data, + effectiveMaximumValue: effectiveValueRange.maximum, + effectiveMinimumValue: effectiveValueRange.minimum, + formatOptions, + indexBy, + layout, + xAxisLabel: axisConfig?.xAxisLabel, + yAxisLabel: axisConfig?.yAxisLabel, + }); + + const innerPadding = getBarChartInnerPadding({ + chartHeight, + chartWidth, + dataLength: data.length, + groupMode, + keysLength: keys.length, + layout, + margins, + }); + + const { innerWidth, innerHeight } = getChartInnerDimensions({ + chartWidth, + chartHeight, + margins, + }); + + const categoryValues = useMemo( + () => data.map((item) => String(item[indexBy] ?? '')), + [data, indexBy], + ); + + const categoryTickValues = + layout === BarChartLayout.VERTICAL + ? axisBottomConfiguration.tickValues + : axisLeftConfiguration.tickValues; + + const resolvedCategoryTickValues = useMemo( + () => + Array.isArray(categoryTickValues) ? categoryTickValues : categoryValues, + [categoryTickValues, categoryValues], + ); + + const isVerticalLayout = layout === BarChartLayout.VERTICAL; + + const formatBottomTick = (value: string | number): string => { + const formattedValue = axisBottomConfiguration.format?.( + isVerticalLayout ? value : Number(value), + ); + return String(formattedValue ?? value); + }; + + const formatLeftTick = (value: string | number): string => { + const formattedValue = axisLeftConfiguration.format?.( + isVerticalLayout ? Number(value) : value, + ); + return String(formattedValue ?? value); + }; + + const axisLayerConfig: AxisLayerConfig = { + tickFontSize, + legendFontSize, + tickPadding: BAR_CHART_CONSTANTS.TICK_PADDING, + rotatedLabelsExtraMargin: + BAR_CHART_CONSTANTS.ROTATED_LABELS_EXTRA_BOTTOM_MARGIN, + bottomAxisLegendOffset: BAR_CHART_CONSTANTS.BOTTOM_AXIS_LEGEND_OFFSET, + leftAxisLegendOffsetPadding: + BAR_CHART_CONSTANTS.LEFT_AXIS_LEGEND_OFFSET_PADDING, + legendOffsetMarginBuffer: + COMMON_CHART_CONSTANTS.LEGEND_OFFSET_MARGIN_BUFFER, + categoryPadding: BAR_CHART_CONSTANTS.OUTER_PADDING_RATIO, + categoryOuterPaddingPx: BAR_CHART_CONSTANTS.OUTER_PADDING_PX, + }; + + return { + axisBottomConfiguration, + axisLayerConfig, + categoryValues, + innerHeight, + innerPadding, + innerWidth, + margins, + resolvedCategoryTickValues, + formatBottomTick, + formatLeftTick, + valueDomain, + valueTickValues, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme.ts index 7aac379ee3..bff6fbee28 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme.ts @@ -1,9 +1,10 @@ +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; import { parseFontSizeToPx } from '@/page-layout/widgets/graph/utils/parseFontSizeToPx'; import { useTheme } from '@emotion/react'; export const useBarChartTheme = () => { const theme = useTheme(); - const tickFontSize = 11; + const tickFontSize = COMMON_CHART_CONSTANTS.AXIS_FONT_SIZE; const legendFontSize = parseFontSizeToPx(theme.font.size.sm, tickFontSize); return { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts index a024b25b0c..e8cee0ac57 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts @@ -4,6 +4,7 @@ import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetad import { BAR_CHART_DATA } from '@/page-layout/widgets/graph/graphql/queries/barChartData'; import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; import { getEffectiveGroupMode } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getEffectiveGroupMode'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; import { determineChartItemColor } from '@/page-layout/widgets/graph/utils/determineChartItemColor'; @@ -11,7 +12,6 @@ import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/deter import { extractBarChartDataConfiguration } from '@/page-layout/widgets/graph/utils/extractBarChartDataConfiguration'; import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor'; import { useQuery } from '@apollo/client'; -import { type BarDatum } from '@nivo/bar'; import { isString } from '@sniptt/guards'; import { useMemo } from 'react'; import { FieldMetadataType } from 'twenty-shared/types'; @@ -28,7 +28,7 @@ type UseGraphBarChartWidgetDataProps = { }; type UseGraphBarChartWidgetDataResult = { - data: BarDatum[]; + data: BarChartDatum[]; indexBy: string; keys: string[]; series: BarChartSeriesWithColor[]; @@ -39,6 +39,7 @@ type UseGraphBarChartWidgetDataResult = { layout?: BarChartLayout; groupMode: 'grouped' | 'stacked' | undefined; loading: boolean; + isRefetching: boolean; error?: Error; hasTooManyGroups: boolean; formattedToRawLookup: Map; @@ -65,6 +66,7 @@ export const useGraphBarChartWidgetData = ({ const { data: queryData, + previousData, loading, error, } = useQuery(BAR_CHART_DATA, { @@ -77,10 +79,18 @@ export const useGraphBarChartWidgetData = ({ }, }); - const chartData = (queryData?.barChartData?.data as BarDatum[]) ?? []; + const effectiveQueryData = queryData ?? previousData; - const formattedToRawLookup = queryData?.barChartData?.formattedToRawLookup - ? new Map(Object.entries(queryData.barChartData.formattedToRawLookup)) + const indexBy = effectiveQueryData?.barChartData?.indexBy ?? 'id'; + const keys = effectiveQueryData?.barChartData?.keys ?? []; + const chartData = + (effectiveQueryData?.barChartData?.data as BarChartDatum[]) ?? []; + + const formattedToRawLookup = effectiveQueryData?.barChartData + ?.formattedToRawLookup + ? new Map( + Object.entries(effectiveQueryData.barChartData.formattedToRawLookup), + ) : new Map(); const colorDeterminingFieldId = isDefined( @@ -116,7 +126,7 @@ export const useGraphBarChartWidgetData = ({ selectFieldOptions, }); - const series = queryData?.barChartData?.series?.map( + const series = effectiveQueryData?.barChartData?.series?.map( (seriesItem: BarChartSeries): BarChartSeriesWithColor => { const rawValue = formattedToRawLookup.get(seriesItem.key); @@ -136,23 +146,25 @@ export const useGraphBarChartWidgetData = ({ return { data: chartData, - indexBy: queryData?.barChartData?.indexBy ?? 'id', - keys: queryData?.barChartData?.keys ?? [], + indexBy, + keys, series, - xAxisLabel: queryData?.barChartData?.xAxisLabel ?? '', - yAxisLabel: queryData?.barChartData?.yAxisLabel ?? '', + xAxisLabel: effectiveQueryData?.barChartData?.xAxisLabel ?? '', + yAxisLabel: effectiveQueryData?.barChartData?.yAxisLabel ?? '', showDataLabels: configuration.displayDataLabel ?? false, showLegend: configuration.displayLegend ?? true, - layout: queryData?.barChartData?.layout, + layout: effectiveQueryData?.barChartData?.layout, groupMode: getEffectiveGroupMode( configuration.groupMode, configuration.secondaryAxisGroupByFieldMetadataId, ), - hasTooManyGroups: queryData?.barChartData?.hasTooManyGroups ?? false, + hasTooManyGroups: + effectiveQueryData?.barChartData?.hasTooManyGroups ?? false, colorMode, formattedToRawLookup, objectMetadataItem, - loading, + loading: loading && !previousData, + isRefetching: loading && !!previousData, error, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useMemoizedBarPositions.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useMemoizedBarPositions.ts new file mode 100644 index 0000000000..3242b0fc1b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useMemoizedBarPositions.ts @@ -0,0 +1,74 @@ +import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; +import { computeBarPositions } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositions'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import { useMemo } from 'react'; +import { type BarChartLayout } from '~/generated/graphql'; + +type UseBarPositionsParams = { + data: BarChartDatum[]; + indexBy: string; + keys: string[]; + enrichedKeysMap: Map; + chartWidth: number; + chartHeight: number; + margins: ChartMargins; + layout: BarChartLayout; + groupMode: 'grouped' | 'stacked'; + valueDomain: { min: number; max: number }; + innerPadding: number; + includeZeroValues?: boolean; + enabled?: boolean; +}; + +export const useMemoizedBarPositions = ({ + data, + indexBy, + keys, + enrichedKeysMap, + chartWidth, + chartHeight, + margins, + layout, + groupMode, + valueDomain, + innerPadding, + includeZeroValues = false, + enabled = true, +}: UseBarPositionsParams): BarPosition[] => { + return useMemo(() => { + if (!enabled) { + return []; + } + + return computeBarPositions({ + data, + indexBy, + keys, + enrichedKeysMap, + chartWidth, + chartHeight, + margins, + layout, + groupMode, + valueDomain, + innerPadding, + includeZeroValues, + }); + }, [ + data, + indexBy, + keys, + enrichedKeysMap, + chartWidth, + chartHeight, + margins, + layout, + groupMode, + valueDomain, + innerPadding, + includeZeroValues, + enabled, + ]); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState.ts index cb3492f922..36e45c11ec 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState.ts @@ -1,13 +1,10 @@ -import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; +import { type BarChartSliceHoverData } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSliceHoverData'; import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext'; import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; -export const graphWidgetBarTooltipComponentState = createComponentState<{ - slice: BarChartSlice; - offsetLeft: number; - offsetTop: number; -} | null>({ - key: 'graphWidgetBarTooltipComponentState', - defaultValue: null, - componentInstanceContext: WidgetComponentInstanceContext, -}); +export const graphWidgetBarTooltipComponentState = + createComponentState({ + key: 'graphWidgetBarTooltipComponentState', + defaultValue: null, + componentInstanceContext: WidgetComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetIsSliceHoveredComponentFamilySelector.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetIsSliceHoveredComponentFamilySelector.ts deleted file mode 100644 index 85aa813dd4..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetIsSliceHoveredComponentFamilySelector.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { graphWidgetHoveredSliceIndexComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetHoveredSliceIndexComponentState'; -import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext'; -import { createComponentFamilySelector } from '@/ui/utilities/state/component-state/utils/createComponentFamilySelector'; -import { isDefined } from 'twenty-shared/utils'; - -export const graphWidgetIsSliceHoveredComponentFamilySelector = - createComponentFamilySelector({ - key: 'graphWidgetIsSliceHoveredComponentFamilySelector', - componentInstanceContext: WidgetComponentInstanceContext, - get: - ({ instanceId, familyKey }: { instanceId: string; familyKey: string }) => - ({ get }) => { - const hoveredSliceIndex = get( - graphWidgetHoveredSliceIndexComponentState.atomFamily({ instanceId }), - ); - - return isDefined(hoveredSliceIndex) && hoveredSliceIndex === familyKey; - }, - }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum.ts new file mode 100644 index 0000000000..8fb197e4d9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum.ts @@ -0,0 +1 @@ +export type BarChartDatum = Record; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice.ts index 2f147071ad..6c99470f01 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice.ts @@ -1,8 +1,8 @@ -import { type BarDatum, type ComputedBarDatum } from '@nivo/bar'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; export type BarChartSlice = { indexValue: string; - bars: ComputedBarDatum[]; + bars: BarPosition[]; sliceLeft: number; sliceRight: number; sliceCenter: number; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSliceHoverData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSliceHoverData.ts new file mode 100644 index 0000000000..48696716bc --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSliceHoverData.ts @@ -0,0 +1,7 @@ +import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; + +export type BarChartSliceHoverData = { + slice: BarChartSlice; + offsetLeft: number; + offsetTop: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition.ts new file mode 100644 index 0000000000..cf5490b7a2 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition.ts @@ -0,0 +1,12 @@ +export type BarPosition = { + x: number; + y: number; + width: number; + height: number; + value: number; + indexValue: string; + seriesId: string; + color: string; + shouldRoundFreeEnd: boolean; + seriesIndex: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartValueRange.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartValueRange.test.ts index cbe40d0802..50862950d6 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartValueRange.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartValueRange.test.ts @@ -1,9 +1,9 @@ -import { type BarDatum } from '@nivo/bar'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; import { calculateValueRangeFromBarChartKeys } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys'; describe('calculateValueRangeFromBarChartKeys (essential cases)', () => { it('returns minimum=0 and maximum=highest value for all positive values', () => { - const data: BarDatum[] = [ + const data: BarChartDatum[] = [ { category: 'A', v1: 10, v2: 20 }, { category: 'B', v1: 30, v2: 15 }, { category: 'C', v1: 25, v2: 40 }, @@ -17,7 +17,7 @@ describe('calculateValueRangeFromBarChartKeys (essential cases)', () => { }); it('returns minimum=lowest and maximum=0 for all negative values', () => { - const data: BarDatum[] = [ + const data: BarChartDatum[] = [ { category: 'A', v1: -10, v2: -20 }, { category: 'B', v1: -30, v2: -15 }, { category: 'C', v1: -25, v2: -40 }, @@ -31,7 +31,7 @@ describe('calculateValueRangeFromBarChartKeys (essential cases)', () => { }); it('includes zero and spans minimum/maximum when values cross zero', () => { - const data: BarDatum[] = [ + const data: BarChartDatum[] = [ { category: 'A', v1: -20, v2: 30 }, { category: 'B', v1: 15, v2: -10 }, { category: 'C', v1: -5, v2: 25 }, @@ -58,7 +58,7 @@ describe('calculateValueRangeFromBarChartKeys (essential cases)', () => { }); it('ignores NaN/missing values', () => { - const data: BarDatum[] = [ + const data: BarChartDatum[] = [ { category: 'A', v1: 10, v2: NaN }, { category: 'B', v1: 20, v2: 30 }, { category: 'C', v1: undefined as unknown as number }, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateStackedBarChartValueRange.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateStackedBarChartValueRange.test.ts index f3b39dfd0d..c629617cb9 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateStackedBarChartValueRange.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateStackedBarChartValueRange.test.ts @@ -1,9 +1,9 @@ -import { type BarDatum } from '@nivo/bar'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; import { calculateStackedBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange'; describe('calculateStackedBarChartValueRange (essential cases)', () => { it('returns minimum=0 and maximum=largest positive stack', () => { - const data: BarDatum[] = [ + const data: BarChartDatum[] = [ { cat: 'A', v1: 100, v2: 200, v3: 50 }, { cat: 'B', v1: 150, v2: 25, v3: 75 }, { cat: 'C', v1: 300, v2: 10, v3: 0 }, @@ -17,7 +17,7 @@ describe('calculateStackedBarChartValueRange (essential cases)', () => { }); it('returns minimum=most negative stack and maximum=0 for all negative values', () => { - const data: BarDatum[] = [ + const data: BarChartDatum[] = [ { cat: 'A', v1: -100, v2: -200, v3: 0 }, { cat: 'B', v1: -50, v2: -25, v3: -75 }, ]; @@ -30,7 +30,7 @@ describe('calculateStackedBarChartValueRange (essential cases)', () => { }); it('sums positives and negatives per index to compute range when values mix', () => { - const data: BarDatum[] = [ + const data: BarChartDatum[] = [ { cat: 'A', v1: 100, v2: -60, v3: 20 }, { cat: 'B', v1: 50, v2: -80, v3: -30 }, { cat: 'C', v1: 10, v2: 0, v3: 0 }, @@ -54,7 +54,7 @@ describe('calculateStackedBarChartValueRange (essential cases)', () => { }); it('ignores missing keys and NaN values', () => { - const data: BarDatum[] = [ + const data: BarChartDatum[] = [ { cat: 'A', v1: 10 }, { cat: 'B', v2: 30 }, { cat: 'C', v1: NaN as number }, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeAllCategorySlices.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeAllCategorySlices.test.ts new file mode 100644 index 0000000000..d75144f496 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeAllCategorySlices.test.ts @@ -0,0 +1,264 @@ +import { computeAllCategorySlices } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeAllCategorySlices'; + +describe('computeAllCategorySlices', () => { + const defaultMargins = { top: 20, right: 20, bottom: 40, left: 60 }; + + describe('empty data handling', () => { + it('should return empty array when data is empty', () => { + const result = computeAllCategorySlices({ + data: [], + indexBy: 'category', + bars: [], + isVerticalLayout: true, + chartWidth: 500, + chartHeight: 300, + margins: defaultMargins, + }); + + expect(result).toEqual([]); + }); + }); + + describe('vertical layout', () => { + it('should create slices for each data point', () => { + const data = [ + { category: 'A', value: 10 }, + { category: 'B', value: 20 }, + { category: 'C', value: 30 }, + ]; + + const result = computeAllCategorySlices({ + data, + indexBy: 'category', + bars: [], + isVerticalLayout: true, + chartWidth: 500, + chartHeight: 300, + margins: defaultMargins, + }); + + expect(result).toHaveLength(3); + expect(result.map((s) => s.indexValue)).toEqual(['A', 'B', 'C']); + }); + + it('should assign bars to correct slices', () => { + const data = [ + { category: 'A', value: 10 }, + { category: 'B', value: 20 }, + ]; + const bars = [ + { + x: 50, + y: 100, + width: 40, + height: 60, + value: 10, + indexValue: 'A', + seriesId: 'value', + color: 'red', + shouldRoundFreeEnd: true, + seriesIndex: 0, + }, + { + x: 150, + y: 80, + width: 40, + height: 80, + value: 20, + indexValue: 'B', + seriesId: 'value', + color: 'red', + shouldRoundFreeEnd: true, + seriesIndex: 0, + }, + ]; + + const result = computeAllCategorySlices({ + data, + indexBy: 'category', + bars, + isVerticalLayout: true, + chartWidth: 500, + chartHeight: 300, + margins: defaultMargins, + }); + + expect(result[0].bars).toHaveLength(1); + expect(result[0].bars[0].indexValue).toBe('A'); + expect(result[1].bars).toHaveLength(1); + expect(result[1].bars[0].indexValue).toBe('B'); + }); + + it('should calculate slice boundaries correctly', () => { + const data = [{ category: 'A', value: 10 }]; + + const result = computeAllCategorySlices({ + data, + indexBy: 'category', + bars: [], + isVerticalLayout: true, + chartWidth: 500, + chartHeight: 300, + margins: defaultMargins, + }); + + expect(result[0].sliceLeft).toBeDefined(); + expect(result[0].sliceRight).toBeDefined(); + expect(result[0].sliceCenter).toBeDefined(); + expect(result[0].sliceLeft).toBeLessThan(result[0].sliceCenter); + expect(result[0].sliceCenter).toBeLessThan(result[0].sliceRight); + }); + }); + + describe('horizontal layout', () => { + it('should assign bars to correct slices in horizontal layout', () => { + const data = [ + { category: 'A', value: 10 }, + { category: 'B', value: 20 }, + ]; + const bars = [ + { + x: 0, + y: 100, + width: 60, + height: 40, + value: 10, + indexValue: 'A', + seriesId: 'value', + color: 'red', + shouldRoundFreeEnd: true, + seriesIndex: 0, + }, + { + x: 0, + y: 50, + width: 80, + height: 40, + value: 20, + indexValue: 'B', + seriesId: 'value', + color: 'red', + shouldRoundFreeEnd: true, + seriesIndex: 0, + }, + ]; + + const result = computeAllCategorySlices({ + data, + indexBy: 'category', + bars, + isVerticalLayout: false, + chartWidth: 500, + chartHeight: 300, + margins: defaultMargins, + }); + + expect(result[0].bars).toHaveLength(1); + expect(result[0].bars[0].indexValue).toBe('A'); + expect(result[1].bars).toHaveLength(1); + expect(result[1].bars[0].indexValue).toBe('B'); + expect(result[0].sliceLeft).toBeGreaterThan(result[1].sliceLeft); + }); + }); + + describe('multiple bars per category', () => { + it('should group multiple bars under the same slice', () => { + const data = [{ category: 'A', value1: 10, value2: 20 }]; + const bars = [ + { + x: 50, + y: 100, + width: 20, + height: 60, + value: 10, + indexValue: 'A', + seriesId: 'value1', + color: 'red', + shouldRoundFreeEnd: true, + seriesIndex: 0, + }, + { + x: 75, + y: 80, + width: 20, + height: 80, + value: 20, + indexValue: 'A', + seriesId: 'value2', + color: 'green', + shouldRoundFreeEnd: true, + seriesIndex: 1, + }, + ]; + + const result = computeAllCategorySlices({ + data, + indexBy: 'category', + bars, + isVerticalLayout: true, + chartWidth: 500, + chartHeight: 300, + margins: defaultMargins, + }); + + expect(result).toHaveLength(1); + expect(result[0].bars).toHaveLength(2); + }); + }); + + describe('categories without bars', () => { + it('should create slices even when no bars exist for a category', () => { + const data = [ + { category: 'A', value: 10 }, + { category: 'B', value: 0 }, + ]; + const bars = [ + { + x: 50, + y: 100, + width: 40, + height: 60, + value: 10, + indexValue: 'A', + seriesId: 'value', + color: 'red', + shouldRoundFreeEnd: true, + seriesIndex: 0, + }, + ]; + + const result = computeAllCategorySlices({ + data, + indexBy: 'category', + bars, + isVerticalLayout: true, + chartWidth: 500, + chartHeight: 300, + margins: defaultMargins, + }); + + expect(result).toHaveLength(2); + expect(result[0].bars).toHaveLength(1); + expect(result[1].bars).toHaveLength(0); + expect(result[1].indexValue).toBe('B'); + }); + }); + + describe('index value conversion', () => { + it('should convert numeric index values to strings', () => { + const data = [{ id: 1, value: 10 }]; + + const result = computeAllCategorySlices({ + data, + indexBy: 'id', + bars: [], + isVerticalLayout: true, + chartWidth: 500, + chartHeight: 300, + margins: defaultMargins, + }); + + expect(result[0].indexValue).toBe('1'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBarChartGroupedLabels.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBarChartGroupedLabels.test.ts index 11714e7582..ae4bcd6652 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBarChartGroupedLabels.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBarChartGroupedLabels.test.ts @@ -1,37 +1,26 @@ import { computeBarChartGroupedLabels } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartGroupedLabels'; -import { type BarDatum, type ComputedBarDatum } from '@nivo/bar'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; -type MockBarData = { - id?: string; - indexValue?: string; - value?: number; -}; +const createMockBar = (overrides: Partial = {}): BarPosition => ({ + x: 0, + y: 0, + width: 50, + height: 100, + value: 100, + indexValue: 'Category1', + seriesId: 'series1', + color: 'red', + shouldRoundFreeEnd: false, + seriesIndex: 0, + ...overrides, +}); describe('computeBarChartGroupedLabels', () => { - const createMockBar = (overrides: { - x?: number; - y?: number; - width?: number; - height?: number; - data?: MockBarData; - }): ComputedBarDatum => - ({ - x: overrides.x ?? 0, - y: overrides.y ?? 0, - width: overrides.width ?? 50, - height: overrides.height ?? 100, - data: { - id: overrides.data?.id ?? 'bar1', - indexValue: overrides.data?.indexValue ?? 'Category1', - value: overrides.data?.value ?? 100, - }, - }) as unknown as ComputedBarDatum; - describe('basic label computation', () => { it('should return labels for each bar', () => { const bars = [ - createMockBar({ data: { id: 'bar1', indexValue: 'Cat1', value: 100 } }), - createMockBar({ data: { id: 'bar2', indexValue: 'Cat2', value: 200 } }), + createMockBar({ seriesId: 'bar1', indexValue: 'Cat1', value: 100 }), + createMockBar({ seriesId: 'bar2', indexValue: 'Cat2', value: 200 }), ]; const result = computeBarChartGroupedLabels(bars); @@ -41,8 +30,8 @@ describe('computeBarChartGroupedLabels', () => { it('should generate unique keys for each label', () => { const bars = [ - createMockBar({ data: { id: 'sales', indexValue: 'Jan', value: 100 } }), - createMockBar({ data: { id: 'sales', indexValue: 'Feb', value: 150 } }), + createMockBar({ seriesId: 'sales', indexValue: 'Jan', value: 100 }), + createMockBar({ seriesId: 'sales', indexValue: 'Feb', value: 150 }), ]; const result = computeBarChartGroupedLabels(bars); @@ -70,13 +59,7 @@ describe('computeBarChartGroupedLabels', () => { }); it('should set verticalY to top of bar for positive values', () => { - const bars = [ - createMockBar({ - y: 50, - height: 100, - data: { id: 'bar1', indexValue: 'Cat1', value: 100 }, - }), - ]; + const bars = [createMockBar({ y: 50, height: 100, value: 100 })]; const result = computeBarChartGroupedLabels(bars); @@ -85,13 +68,7 @@ describe('computeBarChartGroupedLabels', () => { }); it('should set verticalY to bottom of bar for negative values', () => { - const bars = [ - createMockBar({ - y: 50, - height: 100, - data: { id: 'bar1', indexValue: 'Cat1', value: -100 }, - }), - ]; + const bars = [createMockBar({ y: 50, height: 100, value: -100 })]; const result = computeBarChartGroupedLabels(bars); @@ -100,13 +77,7 @@ describe('computeBarChartGroupedLabels', () => { }); it('should set horizontalX to right edge for positive values', () => { - const bars = [ - createMockBar({ - x: 50, - width: 100, - data: { id: 'bar1', indexValue: 'Cat1', value: 100 }, - }), - ]; + const bars = [createMockBar({ x: 50, width: 100, value: 100 })]; const result = computeBarChartGroupedLabels(bars); @@ -114,13 +85,7 @@ describe('computeBarChartGroupedLabels', () => { }); it('should set horizontalX to left edge for negative values', () => { - const bars = [ - createMockBar({ - x: 50, - width: 100, - data: { id: 'bar1', indexValue: 'Cat1', value: -100 }, - }), - ]; + const bars = [createMockBar({ x: 50, width: 100, value: -100 })]; const result = computeBarChartGroupedLabels(bars); @@ -129,10 +94,8 @@ describe('computeBarChartGroupedLabels', () => { }); describe('value handling', () => { - it('should extract numeric value from bar data', () => { - const bars = [ - createMockBar({ data: { id: 'bar1', indexValue: 'Cat1', value: 42 } }), - ]; + it('should extract numeric value from bar', () => { + const bars = [createMockBar({ value: 42 })]; const result = computeBarChartGroupedLabels(bars); @@ -140,9 +103,7 @@ describe('computeBarChartGroupedLabels', () => { }); it('should handle zero values', () => { - const bars = [ - createMockBar({ data: { id: 'bar1', indexValue: 'Cat1', value: 0 } }), - ]; + const bars = [createMockBar({ value: 0 })]; const result = computeBarChartGroupedLabels(bars); @@ -151,11 +112,7 @@ describe('computeBarChartGroupedLabels', () => { }); it('should handle decimal values', () => { - const bars = [ - createMockBar({ - data: { id: 'bar1', indexValue: 'Cat1', value: 123.456 }, - }), - ]; + const bars = [createMockBar({ value: 123.456 })]; const result = computeBarChartGroupedLabels(bars); @@ -171,15 +128,7 @@ describe('computeBarChartGroupedLabels', () => { }); it('should handle bars with zero dimensions', () => { - const bars = [ - createMockBar({ - x: 0, - y: 0, - width: 0, - height: 0, - data: { id: 'bar1', indexValue: 'Cat1', value: 100 }, - }), - ]; + const bars = [createMockBar({ x: 0, y: 0, width: 0, height: 0 })]; const result = computeBarChartGroupedLabels(bars); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBarChartStackedLabels.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBarChartStackedLabels.test.ts index 1d8c588162..b5a3e1a48c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBarChartStackedLabels.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBarChartStackedLabels.test.ts @@ -1,34 +1,30 @@ -import { type BarDatum, type ComputedBarDatum } from '@nivo/bar'; import { computeBarChartStackedLabels } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartStackedLabels'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; const createMockBar = ( - id: string, - indexValue: string | number, + seriesId: string, + indexValue: string, value: number, x: number, y: number, width: number, height: number, -): ComputedBarDatum => - ({ - id, - key: id, - index: 0, - data: { id, indexValue, value }, - x, - y, - absX: x, - absY: y, - width, - height, - color: 'blue', - label: String(indexValue), - formattedValue: String(value), - }) as unknown as ComputedBarDatum; +): BarPosition => ({ + seriesId, + indexValue, + value, + x, + y, + width, + height, + color: 'red', + shouldRoundFreeEnd: false, + seriesIndex: 0, +}); -describe('computeBarChartStackedLabels (essential cases)', () => { +describe('computeBarChartStackedLabels', () => { it('returns total for single index with all positive values', () => { - const bars: ComputedBarDatum[] = [ + const bars: BarPosition[] = [ createMockBar('series1', 'Jan', 100, 50, 200, 30, 100), createMockBar('series2', 'Jan', 50, 50, 100, 30, 100), createMockBar('series3', 'Jan', 25, 50, 50, 30, 50), @@ -47,7 +43,7 @@ describe('computeBarChartStackedLabels (essential cases)', () => { }); it('returns total for single index with all negative values', () => { - const bars: ComputedBarDatum[] = [ + const bars: BarPosition[] = [ createMockBar('series1', 'Jan', -100, 50, 400, 30, 100), createMockBar('series2', 'Jan', -50, 50, 500, 30, 100), createMockBar('series3', 'Jan', -25, 50, 575, 30, 50), @@ -60,13 +56,13 @@ describe('computeBarChartStackedLabels (essential cases)', () => { expect(result[0].value).toBe(-175); expect(result[0].verticalX).toBeCloseTo(65, 1); expect(result[0].verticalY).toBe(625); - expect(result[0].horizontalX).toBe(80); + expect(result[0].horizontalX).toBe(50); expect(result[0].horizontalY).toBeCloseTo(533.33, 1); expect(result[0].shouldRenderBelow).toBe(true); }); it('returns total for single index with mixed positive/negative (net positive)', () => { - const bars: ComputedBarDatum[] = [ + const bars: BarPosition[] = [ createMockBar('series1', 'Jan', 100, 50, 200, 30, 100), createMockBar('series2', 'Jan', -30, 50, 370, 30, 70), createMockBar('series3', 'Jan', 20, 50, 250, 30, 50), @@ -85,7 +81,7 @@ describe('computeBarChartStackedLabels (essential cases)', () => { }); it('returns total for single index with mixed positive/negative (net negative)', () => { - const bars: ComputedBarDatum[] = [ + const bars: BarPosition[] = [ createMockBar('series1', 'Jan', -100, 50, 400, 30, 100), createMockBar('series2', 'Jan', 30, 50, 270, 30, 70), createMockBar('series3', 'Jan', 20, 50, 280, 30, 50), @@ -98,13 +94,13 @@ describe('computeBarChartStackedLabels (essential cases)', () => { expect(result[0].value).toBe(-50); expect(result[0].verticalX).toBeCloseTo(65, 1); expect(result[0].verticalY).toBe(500); - expect(result[0].horizontalX).toBe(80); + expect(result[0].horizontalX).toBe(50); expect(result[0].horizontalY).toBeCloseTo(353.33, 1); expect(result[0].shouldRenderBelow).toBe(true); }); it('returns multiple totals for multiple indices', () => { - const bars: ComputedBarDatum[] = [ + const bars: BarPosition[] = [ createMockBar('series1', 'Jan', 100, 50, 200, 30, 100), createMockBar('series2', 'Jan', 50, 50, 100, 30, 100), createMockBar('series1', 'Feb', 75, 150, 225, 30, 75), @@ -138,7 +134,7 @@ describe('computeBarChartStackedLabels (essential cases)', () => { }); it('handles single bar per index (no stacking)', () => { - const bars: ComputedBarDatum[] = [ + const bars: BarPosition[] = [ createMockBar('series1', 'Jan', 100, 50, 200, 30, 100), createMockBar('series1', 'Feb', 150, 150, 150, 30, 150), createMockBar('series1', 'Mar', -50, 250, 400, 30, 50), @@ -170,7 +166,7 @@ describe('computeBarChartStackedLabels (essential cases)', () => { value: -50, verticalX: 265, verticalY: 450, - horizontalX: 280, + horizontalX: 250, horizontalY: 425, shouldRenderBelow: true, }, @@ -178,7 +174,7 @@ describe('computeBarChartStackedLabels (essential cases)', () => { }); it('handles zero values correctly', () => { - const bars: ComputedBarDatum[] = [ + const bars: BarPosition[] = [ createMockBar('series1', 'Jan', 100, 50, 200, 30, 100), createMockBar('series2', 'Jan', 0, 50, 300, 30, 0), createMockBar('series3', 'Jan', -100, 50, 400, 30, 100), @@ -201,26 +197,8 @@ describe('computeBarChartStackedLabels (essential cases)', () => { expect(result).toEqual([]); }); - it('handles numeric index values (not just strings)', () => { - const bars: ComputedBarDatum[] = [ - createMockBar('series1', 2024, 100, 50, 200, 30, 100), - createMockBar('series2', 2024, 50, 50, 100, 30, 100), - createMockBar('series1', 2025, 75, 150, 225, 30, 75), - ]; - - const result = computeBarChartStackedLabels(bars); - - expect(result).toHaveLength(2); - expect(result[0].key).toBe('total-2024'); - expect(result[0].value).toBe(150); - expect(result[0].horizontalY).toBeCloseTo(200, 1); - expect(result[1].key).toBe('total-2025'); - expect(result[1].value).toBe(75); - expect(result[1].horizontalY).toBeCloseTo(262.5, 1); - }); - it('tracks minimum Y position correctly for positive totals', () => { - const bars: ComputedBarDatum[] = [ + const bars: BarPosition[] = [ createMockBar('series1', 'Jan', 50, 100, 250, 30, 50), createMockBar('series2', 'Jan', 100, 100, 150, 30, 100), createMockBar('series3', 'Jan', 25, 100, 325, 30, 25), @@ -232,7 +210,7 @@ describe('computeBarChartStackedLabels (essential cases)', () => { }); it('tracks maximum bottom Y position correctly for negative totals', () => { - const bars: ComputedBarDatum[] = [ + const bars: BarPosition[] = [ createMockBar('series1', 'Jan', -50, 100, 350, 30, 50), createMockBar('series2', 'Jan', -100, 100, 400, 30, 100), createMockBar('series3', 'Jan', -25, 100, 325, 30, 25), @@ -244,14 +222,14 @@ describe('computeBarChartStackedLabels (essential cases)', () => { }); it('calculates center positions correctly', () => { - const bars: ComputedBarDatum[] = [ + const bars: BarPosition[] = [ createMockBar('series1', 'Jan', 100, 50, 200, 40, 100), createMockBar('series2', 'Jan', 50, 60, 150, 20, 50), ]; const result = computeBarChartStackedLabels(bars); - expect(result[0].verticalX).toBe((70 + 70) / 2); // (50+20, 60+10) avg - expect(result[0].horizontalX).toBe(90); // max(50+40, 60+20) = 90 + expect(result[0].verticalX).toBe(70); + expect(result[0].horizontalX).toBe(90); }); }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBarPositions.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBarPositions.test.ts new file mode 100644 index 0000000000..a5bbe9e2ba --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBarPositions.test.ts @@ -0,0 +1,145 @@ +import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; +import { computeBarPositions } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositions'; +import { BarChartLayout } from '~/generated/graphql'; + +describe('computeBarPositions', () => { + const defaultMargins = { top: 20, right: 20, bottom: 40, left: 60 }; + + const redColorScheme = { + name: 'red', + solid: 'redSolid', + variations: [ + 'red1', + 'red2', + 'red3', + 'red4', + 'red5', + 'red6', + 'red7', + 'red8', + 'red9', + 'red10', + 'red11', + 'red12', + ] as [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + ], + }; + + const greenColorScheme = { + name: 'green', + solid: 'greenSolid', + variations: [ + 'green1', + 'green2', + 'green3', + 'green4', + 'green5', + 'green6', + 'green7', + 'green8', + 'green9', + 'green10', + 'green11', + 'green12', + ] as [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + ], + }; + + const defaultEnrichedKeysMap = new Map([ + [ + 'value1', + { key: 'value1', label: 'Value 1', colorScheme: redColorScheme }, + ], + [ + 'value2', + { key: 'value2', label: 'Value 2', colorScheme: greenColorScheme }, + ], + ]); + + it('returns empty array for empty data', () => { + const result = computeBarPositions({ + data: [], + indexBy: 'category', + keys: ['value1'], + enrichedKeysMap: defaultEnrichedKeysMap, + chartWidth: 500, + chartHeight: 300, + margins: defaultMargins, + layout: BarChartLayout.VERTICAL, + groupMode: 'grouped', + valueDomain: { min: 0, max: 100 }, + innerPadding: 2, + }); + + expect(result).toEqual([]); + }); + + it('builds grouped bars with enriched colors', () => { + const result = computeBarPositions({ + data: [{ category: 'A', value1: 50 }], + indexBy: 'category', + keys: ['value1'], + enrichedKeysMap: defaultEnrichedKeysMap, + chartWidth: 500, + chartHeight: 300, + margins: defaultMargins, + layout: BarChartLayout.VERTICAL, + groupMode: 'grouped', + valueDomain: { min: 0, max: 100 }, + innerPadding: 2, + }); + + expect(result).toHaveLength(1); + expect(result[0].indexValue).toBe('A'); + expect(result[0].seriesId).toBe('value1'); + expect(result[0].value).toBe(50); + expect(result[0].color).toBe('redSolid'); + }); + + it('stacks bars in stacked mode', () => { + const result = computeBarPositions({ + data: [{ category: 'A', value1: 50, value2: 30 }], + indexBy: 'category', + keys: ['value1', 'value2'], + enrichedKeysMap: defaultEnrichedKeysMap, + chartWidth: 500, + chartHeight: 300, + margins: defaultMargins, + layout: BarChartLayout.VERTICAL, + groupMode: 'stacked', + valueDomain: { min: 0, max: 100 }, + innerPadding: 2, + }); + + expect(result).toHaveLength(2); + const bar1 = result.find((bar) => bar.seriesId === 'value1'); + const bar2 = result.find((bar) => bar.seriesId === 'value2'); + expect(bar1).toBeDefined(); + expect(bar2).toBeDefined(); + expect(bar1!.y).toBeGreaterThan(bar2!.y); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBaselineBar.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBaselineBar.test.ts new file mode 100644 index 0000000000..37b1cc994a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBaselineBar.test.ts @@ -0,0 +1,133 @@ +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; +import { computeBaselineBar } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBaselineBar'; + +describe('computeBaselineBar', () => { + const createBar = (overrides: Partial = {}): BarPosition => ({ + x: 50, + y: 100, + width: 40, + height: 80, + value: 50, + indexValue: 'A', + seriesId: 'value1', + color: 'red', + shouldRoundFreeEnd: true, + seriesIndex: 0, + ...overrides, + }); + + describe('vertical layout', () => { + it('should set y to baseline and height to 0', () => { + const bar = createBar({ y: 50, height: 100, value: 75 }); + + const result = computeBaselineBar({ + bar, + innerHeight: 200, + zeroPixel: 100, + isVertical: true, + }); + + expect(result.y).toBe(100); + expect(result.height).toBe(0); + expect(result.value).toBe(0); + }); + + it('should preserve x and width', () => { + const bar = createBar({ x: 75, width: 50 }); + + const result = computeBaselineBar({ + bar, + innerHeight: 200, + zeroPixel: 100, + isVertical: true, + }); + + expect(result.x).toBe(75); + expect(result.width).toBe(50); + }); + + it('should preserve other properties', () => { + const bar = createBar({ + indexValue: 'B', + seriesId: 'value2', + color: 'green', + shouldRoundFreeEnd: false, + seriesIndex: 2, + }); + + const result = computeBaselineBar({ + bar, + innerHeight: 200, + zeroPixel: 100, + isVertical: true, + }); + + expect(result.indexValue).toBe('B'); + expect(result.seriesId).toBe('value2'); + expect(result.color).toBe('green'); + expect(result.shouldRoundFreeEnd).toBe(false); + expect(result.seriesIndex).toBe(2); + }); + }); + + describe('horizontal layout', () => { + it('should set x to zeroPixel and width to 0', () => { + const bar = createBar({ x: 50, width: 100, value: 75 }); + + const result = computeBaselineBar({ + bar, + innerHeight: 200, + zeroPixel: 80, + isVertical: false, + }); + + expect(result.x).toBe(80); + expect(result.width).toBe(0); + expect(result.value).toBe(0); + }); + + it('should preserve y and height', () => { + const bar = createBar({ y: 60, height: 40 }); + + const result = computeBaselineBar({ + bar, + innerHeight: 200, + zeroPixel: 80, + isVertical: false, + }); + + expect(result.y).toBe(60); + expect(result.height).toBe(40); + }); + }); + + describe('edge cases', () => { + it('should handle zeroPixel at 0', () => { + const bar = createBar(); + + const result = computeBaselineBar({ + bar, + innerHeight: 200, + zeroPixel: 0, + isVertical: true, + }); + + expect(result.y).toBe(200); + expect(result.height).toBe(0); + }); + + it('should handle zeroPixel equal to innerHeight', () => { + const bar = createBar(); + + const result = computeBaselineBar({ + bar, + innerHeight: 200, + zeroPixel: 200, + isVertical: true, + }); + + expect(result.y).toBe(0); + expect(result.height).toBe(0); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeGroupedBarLayout.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeGroupedBarLayout.test.ts new file mode 100644 index 0000000000..79a60d2ab4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeGroupedBarLayout.test.ts @@ -0,0 +1,54 @@ +import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { type BarPositionContext } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionContext'; +import { computeGroupedBarLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getGroupedBarDimensions'; + +const createContext = ( + overrides: Partial = {}, +): BarPositionContext => ({ + isVertical: true, + dataLength: 1, + keysLength: 2, + categoryStep: 10, + categoryWidth: 90, + outerPadding: 0, + valueAxisLength: 100, + valueToPixel: (value: number) => value, + zeroPixel: 0, + ...overrides, +}); + +describe('computeGroupedBarLayout', () => { + it('computes bar thickness and centering from spacing inputs', () => { + const ctx = createContext({ keysLength: 3, categoryWidth: 90 }); + const innerPadding = 2; + const layout = computeGroupedBarLayout(ctx, innerPadding); + + const totalInnerPadding = innerPadding * (ctx.keysLength - 1); + const availableBarSpace = ctx.categoryWidth - totalInnerPadding; + const expectedBarThickness = Math.min( + Math.max( + availableBarSpace / ctx.keysLength, + BAR_CHART_CONSTANTS.MINIMUM_BAR_WIDTH, + ), + BAR_CHART_CONSTANTS.MAXIMUM_WIDTH, + ); + const expectedTotalWidth = + expectedBarThickness * ctx.keysLength + totalInnerPadding; + const expectedCenteringOffset = + (ctx.categoryWidth - expectedTotalWidth) / 2; + + expect(layout.barThickness).toBeCloseTo(expectedBarThickness, 5); + expect(layout.groupCenteringOffset).toBeCloseTo(expectedCenteringOffset, 5); + expect(layout.barStride).toBeCloseTo( + expectedBarThickness + innerPadding, + 5, + ); + }); + + it('caps bar thickness at the maximum width', () => { + const ctx = createContext({ keysLength: 1, categoryWidth: 500 }); + const layout = computeGroupedBarLayout(ctx, 0); + + expect(layout.barThickness).toBe(BAR_CHART_CONSTANTS.MAXIMUM_WIDTH); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeSliceTooltipPosition.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeSliceTooltipPosition.test.ts new file mode 100644 index 0000000000..a6d69cc0ab --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeSliceTooltipPosition.test.ts @@ -0,0 +1,138 @@ +import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; +import { computeSliceTooltipPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSliceTooltipPosition'; + +describe('computeSliceTooltipPosition', () => { + const defaultMargins = { top: 20, right: 20, bottom: 40, left: 60 }; + + const createSlice = ( + overrides: Partial = {}, + ): BarChartSlice => ({ + indexValue: 'A', + bars: [], + sliceLeft: 50, + sliceRight: 100, + sliceCenter: 75, + ...overrides, + }); + + const createBar = (x: number, y: number, width: number, height: number) => ({ + x, + y, + width, + height, + value: 50, + indexValue: 'A', + seriesId: 'value1', + color: 'red', + shouldRoundFreeEnd: true, + seriesIndex: 0, + }); + + describe('vertical layout with bars', () => { + it('should position tooltip at slice center and top of anchor bar', () => { + const slice = createSlice({ + bars: [createBar(50, 100, 40, 60)], + sliceCenter: 75, + }); + + const result = computeSliceTooltipPosition({ + slice, + margins: defaultMargins, + innerHeight: 200, + isVertical: true, + }); + + expect(result.offsetLeft).toBe(75 + defaultMargins.left); + expect(result.offsetTop).toBe(100 + defaultMargins.top); + }); + + it('should use the topmost bar as anchor when multiple bars exist', () => { + const slice = createSlice({ + bars: [createBar(50, 150, 40, 30), createBar(95, 80, 40, 100)], + sliceCenter: 75, + }); + + const result = computeSliceTooltipPosition({ + slice, + margins: defaultMargins, + innerHeight: 200, + isVertical: true, + }); + + expect(result.offsetTop).toBe(80 + defaultMargins.top); + }); + }); + + describe('horizontal layout with bars', () => { + it('should position tooltip at end of anchor bar and slice center', () => { + const slice = createSlice({ + bars: [createBar(0, 50, 100, 40)], + sliceCenter: 70, + }); + + const result = computeSliceTooltipPosition({ + slice, + margins: defaultMargins, + innerHeight: 200, + isVertical: false, + }); + + expect(result.offsetLeft).toBe(0 + 100 + defaultMargins.left); + expect(result.offsetTop).toBe(70 + defaultMargins.top); + }); + + it('should use the rightmost bar as anchor when multiple bars exist', () => { + const slice = createSlice({ + bars: [createBar(0, 50, 80, 40), createBar(0, 95, 120, 40)], + sliceCenter: 70, + }); + + const result = computeSliceTooltipPosition({ + slice, + margins: defaultMargins, + innerHeight: 200, + isVertical: false, + }); + + expect(result.offsetLeft).toBe(0 + 120 + defaultMargins.left); + }); + }); + + describe('vertical layout without bars', () => { + it('should position tooltip at slice center and bottom of chart', () => { + const slice = createSlice({ + bars: [], + sliceCenter: 75, + }); + + const result = computeSliceTooltipPosition({ + slice, + margins: defaultMargins, + innerHeight: 200, + isVertical: true, + }); + + expect(result.offsetLeft).toBe(75 + defaultMargins.left); + expect(result.offsetTop).toBe(200 + defaultMargins.top); + }); + }); + + describe('horizontal layout without bars', () => { + it('should position tooltip at left edge and slice center', () => { + const slice = createSlice({ + bars: [], + sliceCenter: 70, + }); + + const result = computeSliceTooltipPosition({ + slice, + margins: defaultMargins, + innerHeight: 200, + isVertical: false, + }); + + expect(result.offsetLeft).toBe(defaultMargins.left); + expect(result.offsetTop).toBe(70 + defaultMargins.top); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeStackedBarLayout.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeStackedBarLayout.test.ts new file mode 100644 index 0000000000..1c3b4e2083 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeStackedBarLayout.test.ts @@ -0,0 +1,31 @@ +import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { type BarPositionContext } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionContext'; +import { computeStackedBarLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getStackedBarDimensions'; + +const createContext = ( + overrides: Partial = {}, +): BarPositionContext => ({ + isVertical: true, + dataLength: 1, + keysLength: 2, + categoryStep: 10, + categoryWidth: 80, + outerPadding: 0, + valueAxisLength: 200, + valueToPixel: (value: number) => value, + zeroPixel: 0, + ...overrides, +}); + +describe('computeStackedBarLayout', () => { + it('computes thickness, centering, and stack scale', () => { + const ctx = createContext(); + const layout = computeStackedBarLayout(ctx, { min: -50, max: 150 }); + + expect(layout.barThickness).toBe(BAR_CHART_CONSTANTS.MAXIMUM_WIDTH); + expect(layout.categoryBarCenteringOffset).toBe(24); + expect(layout.stackRange).toBe(200); + expect(layout.stackValueToPixel(0)).toBe(0); + expect(layout.stackValueToPixel(200)).toBe(200); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartColor.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartColor.test.ts deleted file mode 100644 index 3542ae1387..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartColor.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; -import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor'; -import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme'; -import { type BarDatum, type ComputedDatum } from '@nivo/bar'; -import { type ThemeType } from 'twenty-ui/theme'; - -describe('getBarChartColor', () => { - const mockTheme = { - border: { - color: { - light: '#fallback', - }, - }, - } as unknown as ThemeType; - - const mockBlueColorScheme: GraphColorScheme = { - name: 'blue', - solid: '#solidBlue', - variations: [ - '#v0', - '#v1', - '#v2', - '#v3', - '#v4', - '#v5', - '#v6', - '#v7', - '#v8', - '#v9', - '#v10', - '#v11', - ], - }; - - const mockGreenColorScheme: GraphColorScheme = { - name: 'green', - solid: '#solidGreen', - variations: [ - '#v0', - '#v1', - '#v2', - '#v3', - '#v4', - '#v5', - '#v6', - '#v7', - '#v8', - '#v9', - '#v10', - '#v11', - ], - }; - - const mockEnrichedKeysMap = new Map([ - [ - 'sales', - { - key: 'sales', - label: 'Sales', - colorScheme: mockBlueColorScheme, - }, - ], - [ - 'revenue', - { - key: 'revenue', - label: 'Revenue', - colorScheme: mockGreenColorScheme, - }, - ], - ]); - - it('should return the correct color when datum matches enriched key', () => { - const datum: ComputedDatum = { - id: 'sales', - indexValue: 'January', - } as unknown as ComputedDatum; - - const result = getBarChartColor(datum, mockEnrichedKeysMap, mockTheme); - - expect(result).toBe('#solidBlue'); - }); - - it('should return different colors for different keys', () => { - const salesDatum: ComputedDatum = { - id: 'sales', - indexValue: 'January', - } as unknown as ComputedDatum; - - const revenueDatum: ComputedDatum = { - id: 'revenue', - indexValue: 'January', - } as unknown as ComputedDatum; - - const salesColor = getBarChartColor( - salesDatum, - mockEnrichedKeysMap, - mockTheme, - ); - const revenueColor = getBarChartColor( - revenueDatum, - mockEnrichedKeysMap, - mockTheme, - ); - - expect(salesColor).toBe('#solidBlue'); - expect(revenueColor).toBe('#solidGreen'); - }); - - it('should return theme fallback color when no matching key is found', () => { - const datum: ComputedDatum = { - id: 'unknown', - indexValue: 'January', - } as unknown as ComputedDatum; - - const result = getBarChartColor(datum, mockEnrichedKeysMap, mockTheme); - - expect(result).toBe('#fallback'); - }); - - it('should return fallback color when enrichedKeysMap is empty', () => { - const datum: ComputedDatum = { - id: 'sales', - indexValue: 'January', - } as unknown as ComputedDatum; - - const result = getBarChartColor( - datum, - new Map(), - mockTheme, - ); - - expect(result).toBe('#fallback'); - }); - - it('should return same color for same key regardless of indexValue', () => { - const januaryDatum: ComputedDatum = { - id: 'sales', - indexValue: 'January', - } as unknown as ComputedDatum; - - const februaryDatum: ComputedDatum = { - id: 'sales', - indexValue: 'February', - } as unknown as ComputedDatum; - - const januaryColor = getBarChartColor( - januaryDatum, - mockEnrichedKeysMap, - mockTheme, - ); - const februaryColor = getBarChartColor( - februaryDatum, - mockEnrichedKeysMap, - mockTheme, - ); - - expect(januaryColor).toBe('#solidBlue'); - expect(februaryColor).toBe('#solidBlue'); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartLayout.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartLayout.test.ts index 091c99ad38..c928e1e674 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartLayout.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartLayout.test.ts @@ -2,7 +2,7 @@ import { TEXT_MARGIN_LIMITS } from '@/page-layout/widgets/graph/constants/TextMa import { getBarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartLayout'; import { type ChartAxisTheme } from '@/page-layout/widgets/graph/types/ChartAxisTheme'; import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters'; -import { type BarDatum } from '@nivo/bar'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; import { BarChartLayout } from '~/generated/graphql'; describe('getBarChartLayout', () => { @@ -15,7 +15,7 @@ describe('getBarChartLayout', () => { displayType: 'number', }; - const defaultData: BarDatum[] = [ + const defaultData: BarChartDatum[] = [ { category: 'A', value: 10 }, { category: 'B', value: 20 }, { category: 'C', value: 30 }, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getGroupedBarDimensions.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getGroupedBarDimensions.test.ts new file mode 100644 index 0000000000..cecf15fb03 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getGroupedBarDimensions.test.ts @@ -0,0 +1,62 @@ +import { + computeGroupedBarLayout, + getGroupedBarDimensions, +} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getGroupedBarDimensions'; +import { type BarPositionContext } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionContext'; + +const createContext = ( + overrides: Partial = {}, +): BarPositionContext => ({ + isVertical: true, + dataLength: 1, + keysLength: 2, + categoryStep: 10, + categoryWidth: 40, + outerPadding: 0, + valueAxisLength: 100, + valueToPixel: (value: number) => value, + zeroPixel: 0, + ...overrides, +}); + +describe('getGroupedBarDimensions', () => { + it('computes vertical bar dimensions', () => { + const ctx = createContext({ isVertical: true }); + const layout = computeGroupedBarLayout(ctx, 2); + + const dimensions = getGroupedBarDimensions({ + ctx, + layout, + categoryStart: 10, + keyIndex: 1, + value: 20, + }); + + expect(dimensions).toEqual({ + x: 31, + y: 80, + width: 19, + height: 20, + }); + }); + + it('computes horizontal bar dimensions', () => { + const ctx = createContext({ isVertical: false, valueAxisLength: 100 }); + const layout = computeGroupedBarLayout(ctx, 2); + + const dimensions = getGroupedBarDimensions({ + ctx, + layout, + categoryStart: 10, + keyIndex: 0, + value: 30, + }); + + expect(dimensions).toEqual({ + x: 0, + y: 10, + width: 30, + height: 19, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getStackedBarDimensions.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getStackedBarDimensions.test.ts new file mode 100644 index 0000000000..69cdac2943 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getStackedBarDimensions.test.ts @@ -0,0 +1,117 @@ +import { + getStackedBarDimensions, + type StackedBarLayout, +} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getStackedBarDimensions'; +import { type BarPositionContext } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionContext'; + +const createContext = ( + overrides: Partial = {}, +): BarPositionContext => ({ + isVertical: true, + dataLength: 1, + keysLength: 2, + categoryStep: 10, + categoryWidth: 80, + outerPadding: 0, + valueAxisLength: 100, + valueToPixel: (value: number) => value, + zeroPixel: 0, + ...overrides, +}); + +describe('getStackedBarDimensions', () => { + const layout: StackedBarLayout = { + barThickness: 10, + categoryBarCenteringOffset: 2, + stackValueToPixel: (value) => value, + stackRange: 1, + }; + + it('computes vertical positive bar dimensions', () => { + const ctx = createContext({ isVertical: true }); + + const dimensions = getStackedBarDimensions({ + ctx, + layout, + categoryStart: 20, + value: 30, + positiveStackPixel: 50, + negativeStackPixel: 50, + }); + + expect(dimensions).toEqual({ + x: 22, + y: 20, + width: 10, + height: 30, + newPositiveStackPixel: 80, + newNegativeStackPixel: 50, + }); + }); + + it('computes vertical negative bar dimensions', () => { + const ctx = createContext({ isVertical: true }); + + const dimensions = getStackedBarDimensions({ + ctx, + layout, + categoryStart: 20, + value: -20, + positiveStackPixel: 50, + negativeStackPixel: 50, + }); + + expect(dimensions).toEqual({ + x: 22, + y: 50, + width: 10, + height: 20, + newPositiveStackPixel: 50, + newNegativeStackPixel: 30, + }); + }); + + it('computes horizontal positive bar dimensions', () => { + const ctx = createContext({ isVertical: false }); + + const dimensions = getStackedBarDimensions({ + ctx, + layout, + categoryStart: 20, + value: 20, + positiveStackPixel: 50, + negativeStackPixel: 50, + }); + + expect(dimensions).toEqual({ + x: 50, + y: 22, + width: 20, + height: 10, + newPositiveStackPixel: 70, + newNegativeStackPixel: 50, + }); + }); + + it('computes horizontal negative bar dimensions', () => { + const ctx = createContext({ isVertical: false }); + + const dimensions = getStackedBarDimensions({ + ctx, + layout, + categoryStart: 20, + value: -20, + positiveStackPixel: 50, + negativeStackPixel: 50, + }); + + expect(dimensions).toEqual({ + x: 30, + y: 22, + width: 20, + height: 10, + newPositiveStackPixel: 50, + newNegativeStackPixel: 30, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/hasNegativeValuesInData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/hasNegativeValuesInData.test.ts new file mode 100644 index 0000000000..89237c03c0 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/hasNegativeValuesInData.test.ts @@ -0,0 +1,84 @@ +import { hasNegativeValuesInData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/hasNegativeValuesInData'; + +describe('hasNegativeValuesInData', () => { + describe('positive values only', () => { + it('should return false when all values are positive', () => { + const data = [ + { category: 'A', value1: 10, value2: 20 }, + { category: 'B', value1: 30, value2: 40 }, + ]; + + const result = hasNegativeValuesInData(data, ['value1', 'value2']); + + expect(result).toBe(false); + }); + + it('should return false when all values are zero', () => { + const data = [ + { category: 'A', value1: 0, value2: 0 }, + { category: 'B', value1: 0, value2: 0 }, + ]; + + const result = hasNegativeValuesInData(data, ['value1', 'value2']); + + expect(result).toBe(false); + }); + }); + + describe('negative values', () => { + it('should return true when any value is negative', () => { + const data = [ + { category: 'A', value1: 10, value2: -20 }, + { category: 'B', value1: 30, value2: 40 }, + ]; + + const result = hasNegativeValuesInData(data, ['value1', 'value2']); + + expect(result).toBe(true); + }); + }); + + describe('empty data', () => { + it('should return false when data is empty', () => { + const result = hasNegativeValuesInData([], ['value1', 'value2']); + + expect(result).toBe(false); + }); + + it('should return false when keys is empty', () => { + const data = [{ category: 'A', value1: -10 }]; + + const result = hasNegativeValuesInData(data, []); + + expect(result).toBe(false); + }); + }); + + describe('missing keys', () => { + it('should return false when key does not exist in data', () => { + const data = [{ category: 'A', value1: 10 }]; + + const result = hasNegativeValuesInData(data, ['nonExistentKey']); + + expect(result).toBe(false); + }); + + it('should check only specified keys', () => { + const data = [{ category: 'A', value1: -10, value2: 20 }]; + + const result = hasNegativeValuesInData(data, ['value2']); + + expect(result).toBe(false); + }); + }); + + describe('non-numeric values', () => { + it('should skip string values', () => { + const data = [{ category: 'A', value1: 'not a number', value2: 20 }]; + + const result = hasNegativeValuesInData(data, ['value1', 'value2']); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/interpolateBars.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/interpolateBars.test.ts new file mode 100644 index 0000000000..38834db55e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/interpolateBars.test.ts @@ -0,0 +1,163 @@ +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; +import { interpolateBars } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/interpolateBars'; + +describe('interpolateBars', () => { + const createBar = (overrides: Partial = {}): BarPosition => ({ + x: 0, + y: 0, + width: 50, + height: 100, + value: 50, + indexValue: 'A', + seriesId: 'value1', + color: 'red', + shouldRoundFreeEnd: true, + seriesIndex: 0, + ...overrides, + }); + + const toBaselineBar = (bar: BarPosition): BarPosition => ({ + ...bar, + y: 200, + height: 0, + value: 0, + }); + + describe('basic interpolation', () => { + it('should return target bars when t = 1', () => { + const sourceBars = [createBar({ x: 0, y: 100, height: 50, value: 25 })]; + const targetBars = [createBar({ x: 0, y: 50, height: 100, value: 50 })]; + + const result = interpolateBars(sourceBars, targetBars, 1, toBaselineBar); + + expect(result[0].y).toBe(50); + expect(result[0].height).toBe(100); + expect(result[0].value).toBe(50); + }); + + it('should return source bars when t = 0', () => { + const sourceBars = [createBar({ x: 0, y: 100, height: 50, value: 25 })]; + const targetBars = [createBar({ x: 0, y: 50, height: 100, value: 50 })]; + + const result = interpolateBars(sourceBars, targetBars, 0, toBaselineBar); + + expect(result[0].y).toBe(100); + expect(result[0].height).toBe(50); + expect(result[0].value).toBe(25); + }); + + it('should interpolate values at t = 0.5', () => { + const sourceBars = [createBar({ x: 0, y: 100, height: 0, value: 0 })]; + const targetBars = [createBar({ x: 0, y: 0, height: 100, value: 100 })]; + + const result = interpolateBars( + sourceBars, + targetBars, + 0.5, + toBaselineBar, + ); + + expect(result[0].y).toBeGreaterThan(0); + expect(result[0].y).toBeLessThan(100); + expect(result[0].height).toBeGreaterThan(0); + expect(result[0].height).toBeLessThan(100); + }); + }); + + describe('bar matching', () => { + it('should match bars by indexValue and seriesId', () => { + const sourceBars = [ + createBar({ indexValue: 'A', seriesId: 'v1', value: 10 }), + createBar({ indexValue: 'B', seriesId: 'v1', value: 20 }), + ]; + const targetBars = [ + createBar({ indexValue: 'B', seriesId: 'v1', value: 40 }), + createBar({ indexValue: 'A', seriesId: 'v1', value: 30 }), + ]; + + const result = interpolateBars(sourceBars, targetBars, 1, toBaselineBar); + + expect(result).toHaveLength(2); + const barA = result.find((b) => b.indexValue === 'A'); + const barB = result.find((b) => b.indexValue === 'B'); + expect(barA?.value).toBe(30); + expect(barB?.value).toBe(40); + }); + }); + + describe('entering bars (new bars in target)', () => { + it('should animate new bars from baseline', () => { + const sourceBars: BarPosition[] = []; + const targetBars = [createBar({ y: 50, height: 100, value: 50 })]; + + const result = interpolateBars( + sourceBars, + targetBars, + 0.5, + toBaselineBar, + ); + + expect(result).toHaveLength(1); + expect(result[0].height).toBeGreaterThan(0); + expect(result[0].height).toBeLessThan(100); + }); + }); + + describe('exiting bars (bars removed from target)', () => { + it('should animate removed bars to baseline', () => { + const sourceBars = [createBar({ y: 50, height: 100, value: 50 })]; + const targetBars: BarPosition[] = []; + + const result = interpolateBars( + sourceBars, + targetBars, + 0.5, + toBaselineBar, + ); + + expect(result).toHaveLength(1); + expect(result[0].height).toBeGreaterThan(0); + expect(result[0].height).toBeLessThan(100); + }); + }); + + describe('property preservation', () => { + it('should preserve target color', () => { + const sourceBars = [createBar({ color: 'red' })]; + const targetBars = [createBar({ color: 'green' })]; + + const result = interpolateBars( + sourceBars, + targetBars, + 0.5, + toBaselineBar, + ); + + expect(result[0].color).toBe('green'); + }); + + it('should handle bars with different seriesIds as different bars', () => { + const sourceBars = [createBar({ indexValue: 'A', seriesId: 'old' })]; + const targetBars = [createBar({ indexValue: 'A', seriesId: 'new' })]; + + const result = interpolateBars( + sourceBars, + targetBars, + 0.5, + toBaselineBar, + ); + + // Different seriesId = different bar identity, so both are interpolated + expect(result).toHaveLength(2); + expect(result.map((b) => b.seriesId).sort()).toEqual(['new', 'old']); + }); + }); + + describe('edge cases', () => { + it('should handle empty source and target arrays', () => { + const result = interpolateBars([], [], 0.5, toBaselineBar); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/buildBars.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/buildBars.ts new file mode 100644 index 0000000000..604d13a75d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/buildBars.ts @@ -0,0 +1,97 @@ +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; +import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; +import { type BarPositionContext } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionContext'; +import { isNumber } from '@sniptt/guards'; + +type StackState = { + positiveStackPixel: number; + negativeStackPixel: number; +}; + +type GetBarDimensionsResult = { + dimensions: { x: number; y: number; width: number; height: number }; + nextStackState: StackState; +}; + +type BuildBarsParams = { + ctx: BarPositionContext; + data: BarChartDatum[]; + indexBy: string; + keys: string[]; + enrichedKeysMap: Map; + shouldRoundFreeEndMap: Map | null; + includeZeroValues: boolean; + getDimensions: (params: { + ctx: BarPositionContext; + categoryStart: number; + keyIndex: number; + value: number; + stackState: StackState; + }) => GetBarDimensionsResult; +}; + +export const buildBars = ({ + ctx, + data, + indexBy, + keys, + enrichedKeysMap, + shouldRoundFreeEndMap, + includeZeroValues, + getDimensions, +}: BuildBarsParams): BarPosition[] => { + const { isVertical, dataLength, keysLength, categoryStep, outerPadding } = + ctx; + const bars: BarPosition[] = []; + + for (let dataIndex = 0; dataIndex < dataLength; dataIndex++) { + const dataPoint = data[dataIndex]; + const indexValue = String(dataPoint[indexBy]); + const effectiveIndex = isVertical ? dataIndex : dataLength - 1 - dataIndex; + const categoryStart = outerPadding + effectiveIndex * categoryStep; + + let stackState: StackState = { + positiveStackPixel: ctx.zeroPixel, + negativeStackPixel: ctx.zeroPixel, + }; + + for (let keyIndex = 0; keyIndex < keysLength; keyIndex++) { + const key = keys[keyIndex]; + const rawValue = dataPoint[key]; + + if (!isNumber(rawValue) || (!includeZeroValues && rawValue === 0)) { + continue; + } + + const value = rawValue; + const enrichedKeyForSeries = enrichedKeysMap.get( + key, + ) as BarChartEnrichedKey; + const color = enrichedKeyForSeries.colorScheme.solid; + const barKey = JSON.stringify([indexValue, key]); + const shouldRoundFreeEnd = shouldRoundFreeEndMap?.get(barKey) ?? true; + + const { dimensions, nextStackState } = getDimensions({ + ctx, + categoryStart, + keyIndex, + value, + stackState, + }); + stackState = nextStackState; + + bars.push({ + ...dimensions, + value, + indexValue, + seriesId: key, + color, + shouldRoundFreeEnd, + seriesIndex: keyIndex, + }); + } + } + + return bars; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange.ts index 3338f14f68..51cb8442d8 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange.ts @@ -1,9 +1,9 @@ import { type ChartValueRange } from '@/page-layout/widgets/graph/types/ChartValueRange'; import { calculateValueRangeFromValues } from '@/page-layout/widgets/graph/utils/calculateValueRangeFromValues'; -import { type BarDatum } from '@nivo/bar'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; export const calculateStackedBarChartValueRange = ( - data: BarDatum[], + data: BarChartDatum[], keys: string[], ): ChartValueRange => { const stackedValues: number[] = []; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys.ts index 6267c3a2b1..c2153d1373 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys.ts @@ -1,9 +1,9 @@ import { type ChartValueRange } from '@/page-layout/widgets/graph/types/ChartValueRange'; import { calculateValueRangeFromValues } from '@/page-layout/widgets/graph/utils/calculateValueRangeFromValues'; -import { type BarDatum } from '@nivo/bar'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; export const calculateValueRangeFromBarChartKeys = ( - data: BarDatum[], + data: BarChartDatum[], keys: string[], ): ChartValueRange => { const values: number[] = []; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeAllCategorySlices.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeAllCategorySlices.ts new file mode 100644 index 0000000000..b85f377870 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeAllCategorySlices.ts @@ -0,0 +1,82 @@ +import { computeBandScale } from '@/page-layout/widgets/graph/chart-core/utils/computeBandScale'; +import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; +import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import { isDefined } from 'twenty-shared/utils'; + +export const computeAllCategorySlices = ({ + data, + indexBy, + bars, + isVerticalLayout, + chartWidth, + chartHeight, + margins, +}: { + data: BarChartDatum[]; + indexBy: string; + bars: BarPosition[]; + isVerticalLayout: boolean; + chartWidth: number; + chartHeight: number; + margins: ChartMargins; +}): BarChartSlice[] => { + if (data.length === 0) { + return []; + } + + const innerWidth = chartWidth - margins.left - margins.right; + const innerHeight = chartHeight - margins.top - margins.bottom; + const categoryAxisLength = isVerticalLayout ? innerWidth : innerHeight; + const dataLength = data.length; + + const { + step: categoryStep, + bandwidth: categoryWidth, + offset: outerPadding, + } = computeBandScale({ + axisLength: categoryAxisLength, + count: dataLength, + padding: BAR_CHART_CONSTANTS.OUTER_PADDING_RATIO, + outerPaddingPx: BAR_CHART_CONSTANTS.OUTER_PADDING_PX, + }); + + const barsByIndexValue = new Map(); + for (const bar of bars) { + const existingBars = barsByIndexValue.get(bar.indexValue); + if (isDefined(existingBars)) { + existingBars.push(bar); + } else { + barsByIndexValue.set(bar.indexValue, [bar]); + } + } + + const slices: BarChartSlice[] = []; + + for (let dataIndex = 0; dataIndex < dataLength; dataIndex++) { + const dataPoint = data[dataIndex]; + const indexValue = String(dataPoint[indexBy]); + + const effectiveIndex = isVerticalLayout + ? dataIndex + : dataLength - 1 - dataIndex; + const categoryStart = outerPadding + effectiveIndex * categoryStep; + const sliceLeft = categoryStart; + const sliceRight = categoryStart + categoryWidth; + const sliceCenter = (sliceLeft + sliceRight) / 2; + + const barsForCategory = barsByIndexValue.get(indexValue) ?? []; + + slices.push({ + indexValue, + bars: barsForCategory, + sliceLeft, + sliceRight, + sliceCenter, + }); + } + + return slices; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts index 76c95a39fc..c2f2a4cfa6 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts @@ -1,8 +1,8 @@ import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; import { computeMinHeightPerTick } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeMinHeightPerTick'; import { computeChartCategoryTickValues } from '@/page-layout/widgets/graph/utils/computeChartCategoryTickValues'; import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; -import { type BarDatum } from '@nivo/bar'; import { BarChartLayout } from '~/generated/graphql'; export const computeBarChartCategoryTickValues = ({ @@ -15,7 +15,7 @@ export const computeBarChartCategoryTickValues = ({ }: { axisSize: number; axisFontSize: number; - data: BarDatum[]; + data: BarChartDatum[]; indexBy: string; layout: BarChartLayout; margins: ChartMargins; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartGroupedLabels.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartGroupedLabels.ts index 07d4400889..3c37a848ea 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartGroupedLabels.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartGroupedLabels.ts @@ -1,17 +1,17 @@ import { type BarChartLabelData } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLabelData'; -import { type BarDatum, type ComputedBarDatum } from '@nivo/bar'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; export const computeBarChartGroupedLabels = ( - bars: readonly ComputedBarDatum[], + bars: BarPosition[], ): BarChartLabelData[] => { return bars.map((bar) => { - const value = Number(bar.data.value); + const value = bar.value; const shouldRenderBelow = value < 0; const centerX = bar.x + bar.width / 2; const centerY = bar.y + bar.height / 2; return { - key: `value-${bar.data.id}-${bar.data.indexValue}`, + key: `value-${bar.seriesId}-${bar.indexValue}`, value, verticalX: centerX, verticalY: shouldRenderBelow ? bar.y + bar.height : bar.y, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartStackedLabels.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartStackedLabels.ts index dd79d426b1..e03d9607cc 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartStackedLabels.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartStackedLabels.ts @@ -1,9 +1,9 @@ import { type BarChartLabelData } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLabelData'; -import { type BarDatum, type ComputedBarDatum } from '@nivo/bar'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; import { isDefined } from 'twenty-shared/utils'; export const computeBarChartStackedLabels = ( - bars: readonly ComputedBarDatum[], + bars: BarPosition[], ): BarChartLabelData[] => { const stackData = new Map< string, @@ -11,16 +11,18 @@ export const computeBarChartStackedLabels = ( total: number; minimumYPosition: number; maximumBottomYPosition: number; + minimumXPosition: number; maximumXPosition: number; - bars: ComputedBarDatum[]; + bars: BarPosition[]; } >(); for (const bar of bars) { - const groupKey = String(bar.data.indexValue); - const value = Number(bar.data.value); + const groupKey = bar.indexValue; + const value = bar.value; const barTopY = bar.y; const barBottomY = bar.y + bar.height; + const barLeftX = bar.x; const barRightX = bar.x + bar.width; const existingGroup = stackData.get(groupKey); @@ -34,6 +36,10 @@ export const computeBarChartStackedLabels = ( existingGroup.maximumBottomYPosition, barBottomY, ); + existingGroup.minimumXPosition = Math.min( + existingGroup.minimumXPosition, + barLeftX, + ); existingGroup.maximumXPosition = Math.max( existingGroup.maximumXPosition, barRightX, @@ -44,6 +50,7 @@ export const computeBarChartStackedLabels = ( total: value, minimumYPosition: barTopY, maximumBottomYPosition: barBottomY, + minimumXPosition: barLeftX, maximumXPosition: barRightX, bars: [bar], }); @@ -59,6 +66,7 @@ export const computeBarChartStackedLabels = ( total, minimumYPosition, maximumBottomYPosition, + minimumXPosition, maximumXPosition, bars: groupBars, }, @@ -76,7 +84,7 @@ export const computeBarChartStackedLabels = ( value: total, verticalX: centerX, verticalY: isNegativeTotal ? maximumBottomYPosition : minimumYPosition, - horizontalX: maximumXPosition, + horizontalX: isNegativeTotal ? minimumXPosition : maximumXPosition, horizontalY: centerY, shouldRenderBelow: isNegativeTotal, }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionContext.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionContext.ts new file mode 100644 index 0000000000..b0993afd2b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionContext.ts @@ -0,0 +1,73 @@ +import { computeBandScale } from '@/page-layout/widgets/graph/chart-core/utils/computeBandScale'; +import { computeValueScale } from '@/page-layout/widgets/graph/chart-core/utils/computeValueScale'; +import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; +import { + BarChartLayout as BarChartLayoutEnum, + type BarChartLayout, +} from '~/generated/graphql'; + +export type BarPositionContext = { + isVertical: boolean; + dataLength: number; + keysLength: number; + categoryStep: number; + categoryWidth: number; + outerPadding: number; + valueAxisLength: number; + valueToPixel: (value: number) => number; + zeroPixel: number; +}; + +type ComputeBarPositionContextParams = { + data: BarChartDatum[]; + keys: string[]; + innerWidth: number; + innerHeight: number; + layout: BarChartLayout; + valueDomain: { min: number; max: number }; +}; + +export const computeBarPositionContext = ({ + data, + keys, + innerWidth, + innerHeight, + layout, + valueDomain, +}: ComputeBarPositionContextParams): BarPositionContext | null => { + const dataLength = data.length; + const keysLength = keys.length; + const isVertical = layout === BarChartLayoutEnum.VERTICAL; + + if (dataLength === 0 || keysLength === 0) { + return null; + } + + const categoryAxisLength = isVertical ? innerWidth : innerHeight; + const valueAxisLength = isVertical ? innerHeight : innerWidth; + + const { step, bandwidth, offset } = computeBandScale({ + axisLength: categoryAxisLength, + count: dataLength, + padding: BAR_CHART_CONSTANTS.OUTER_PADDING_RATIO, + outerPaddingPx: BAR_CHART_CONSTANTS.OUTER_PADDING_PX, + }); + + const { valueToPixel } = computeValueScale({ + domain: valueDomain, + axisLength: valueAxisLength, + }); + + return { + isVertical, + dataLength, + keysLength, + categoryStep: step, + categoryWidth: bandwidth, + outerPadding: offset, + valueAxisLength, + valueToPixel, + zeroPixel: valueToPixel(0), + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositions.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositions.ts new file mode 100644 index 0000000000..790e6cfe19 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositions.ts @@ -0,0 +1,70 @@ +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; +import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; +import { computeBarPositionsByGroupMode } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionsByGroupMode'; +import { computeShouldRoundFreeEndMap } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeShouldRoundFreeEndMap'; +import { getChartInnerDimensions } from '@/page-layout/widgets/graph/chart-core/utils/getChartInnerDimensions'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import { type BarChartLayout } from '~/generated/graphql'; + +type ComputeBarPositionsParams = { + data: BarChartDatum[]; + indexBy: string; + keys: string[]; + enrichedKeysMap: Map; + chartWidth: number; + chartHeight: number; + margins: ChartMargins; + layout: BarChartLayout; + groupMode: 'grouped' | 'stacked'; + valueDomain: { min: number; max: number }; + innerPadding: number; + includeZeroValues?: boolean; +}; + +export const computeBarPositions = ({ + data, + indexBy, + keys, + enrichedKeysMap, + chartWidth, + chartHeight, + margins, + layout, + groupMode, + valueDomain, + innerPadding, + includeZeroValues = false, +}: ComputeBarPositionsParams): BarPosition[] => { + const { innerWidth, innerHeight } = getChartInnerDimensions({ + chartWidth, + chartHeight, + margins, + }); + + if (innerWidth <= 0 || innerHeight <= 0) { + return []; + } + + const shouldRoundFreeEndMap = computeShouldRoundFreeEndMap({ + data, + keys, + indexBy, + groupMode, + }); + + return computeBarPositionsByGroupMode({ + data, + indexBy, + keys, + enrichedKeysMap, + innerWidth, + innerHeight, + layout, + groupMode, + valueDomain, + innerPadding, + shouldRoundFreeEndMap, + includeZeroValues, + }); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionsByGroupMode.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionsByGroupMode.ts new file mode 100644 index 0000000000..97937cf3d4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionsByGroupMode.ts @@ -0,0 +1,74 @@ +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; +import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; +import { computeBarPositionContext } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionContext'; +import { computeGroupedBarPositions } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeGroupedBarPositions'; +import { computeStackedBarPositions } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeStackedBarPositions'; +import { type BarChartLayout } from '~/generated/graphql'; + +type ComputeBarPositionsByGroupModeParams = { + data: BarChartDatum[]; + indexBy: string; + keys: string[]; + enrichedKeysMap: Map; + innerWidth: number; + innerHeight: number; + layout: BarChartLayout; + groupMode: 'grouped' | 'stacked'; + valueDomain: { min: number; max: number }; + innerPadding: number; + shouldRoundFreeEndMap: Map | null; + includeZeroValues?: boolean; +}; + +export const computeBarPositionsByGroupMode = ({ + data, + indexBy, + keys, + enrichedKeysMap, + innerWidth, + innerHeight, + layout, + groupMode, + valueDomain, + innerPadding, + shouldRoundFreeEndMap, + includeZeroValues = false, +}: ComputeBarPositionsByGroupModeParams): BarPosition[] => { + const ctx = computeBarPositionContext({ + data, + keys, + innerWidth, + innerHeight, + layout, + valueDomain, + }); + + if (!ctx) { + return []; + } + + if (groupMode === 'stacked') { + return computeStackedBarPositions({ + ctx, + data, + indexBy, + keys, + enrichedKeysMap, + valueDomain, + shouldRoundFreeEndMap, + includeZeroValues, + }); + } + + return computeGroupedBarPositions({ + ctx, + data, + indexBy, + keys, + enrichedKeysMap, + innerPadding, + shouldRoundFreeEndMap, + includeZeroValues, + }); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBaselineBar.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBaselineBar.ts new file mode 100644 index 0000000000..14a5b6e293 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBaselineBar.ts @@ -0,0 +1,33 @@ +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; + +type ComputeBaselineBarParams = { + bar: BarPosition; + innerHeight: number; + zeroPixel: number; + isVertical: boolean; +}; + +export const computeBaselineBar = ({ + bar, + innerHeight, + zeroPixel, + isVertical, +}: ComputeBaselineBarParams): BarPosition => { + const baselineY = innerHeight - zeroPixel; + + if (isVertical) { + return { + ...bar, + y: baselineY, + height: 0, + value: 0, + }; + } + + return { + ...bar, + x: zeroPixel, + width: 0, + value: 0, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeGroupedBarPositions.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeGroupedBarPositions.ts new file mode 100644 index 0000000000..fd55c4ebbe --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeGroupedBarPositions.ts @@ -0,0 +1,52 @@ +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; +import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; +import { type BarPositionContext } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionContext'; +import { buildBars } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/buildBars'; +import { + computeGroupedBarLayout, + getGroupedBarDimensions, +} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getGroupedBarDimensions'; + +type ComputeGroupedBarPositionsParams = { + ctx: BarPositionContext; + data: BarChartDatum[]; + indexBy: string; + keys: string[]; + enrichedKeysMap: Map; + innerPadding: number; + shouldRoundFreeEndMap: Map | null; + includeZeroValues?: boolean; +}; + +export const computeGroupedBarPositions = ({ + ctx, + data, + indexBy, + keys, + enrichedKeysMap, + innerPadding, + shouldRoundFreeEndMap, + includeZeroValues = false, +}: ComputeGroupedBarPositionsParams): BarPosition[] => { + const groupedLayout = computeGroupedBarLayout(ctx, innerPadding); + return buildBars({ + ctx, + data, + indexBy, + keys, + enrichedKeysMap, + shouldRoundFreeEndMap, + includeZeroValues, + getDimensions: ({ ctx, categoryStart, keyIndex, value, stackState }) => ({ + dimensions: getGroupedBarDimensions({ + ctx, + layout: groupedLayout, + categoryStart, + keyIndex, + value, + }), + nextStackState: stackState, + }), + }); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeShouldRoundFreeEndMap.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeShouldRoundFreeEndMap.ts index 05f7cc0f60..fb6020bb7c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeShouldRoundFreeEndMap.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeShouldRoundFreeEndMap.ts @@ -1,25 +1,19 @@ -import { type BarDatum } from '@nivo/bar'; import { isNumber } from '@sniptt/guards'; -type ComputeShouldRoundFreeEndMapParams = { - data: BarDatum[]; - orderedKeys: string[]; - indexBy: string; - groupMode?: 'grouped' | 'stacked'; -}; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; export const computeShouldRoundFreeEndMap = ({ data, - orderedKeys, + keys, indexBy, groupMode, -}: ComputeShouldRoundFreeEndMapParams): Map | null => { - if ( - groupMode !== 'stacked' || - !orderedKeys?.length || - !data?.length || - !indexBy - ) { +}: { + data: BarChartDatum[]; + keys: string[]; + indexBy: string; + groupMode?: 'grouped' | 'stacked'; +}): Map | null => { + if (groupMode !== 'stacked' || !keys?.length || !data?.length || !indexBy) { return null; } @@ -28,8 +22,8 @@ export const computeShouldRoundFreeEndMap = ({ for (const dataPoint of data) { const indexValue = dataPoint[indexBy]; - for (let seriesIndex = 0; seriesIndex < orderedKeys.length; seriesIndex++) { - const key = orderedKeys[seriesIndex]; + for (let seriesIndex = 0; seriesIndex < keys.length; seriesIndex++) { + const key = keys[seriesIndex]; const value = dataPoint[key]; if (!isNumber(value) || value === 0) { @@ -38,7 +32,7 @@ export const computeShouldRoundFreeEndMap = ({ const isNegative = value < 0; - const keysAfterCurrent = orderedKeys.slice(seriesIndex + 1); + const keysAfterCurrent = keys.slice(seriesIndex + 1); const hasSameSignBarAfter = keysAfterCurrent.some((afterKey) => { const afterValue = dataPoint[afterKey]; return ( diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSliceTooltipPosition.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSliceTooltipPosition.ts new file mode 100644 index 0000000000..db3a244418 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSliceTooltipPosition.ts @@ -0,0 +1,45 @@ +import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import { findAnchorBarInSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/findAnchorBarInSlice'; + +type ComputeSliceTooltipPositionParams = { + slice: BarChartSlice; + margins: ChartMargins; + innerHeight: number; + isVertical: boolean; +}; + +type SliceTooltipPosition = { + offsetLeft: number; + offsetTop: number; +}; + +export const computeSliceTooltipPosition = ({ + slice, + margins, + innerHeight, + isVertical, +}: ComputeSliceTooltipPositionParams): SliceTooltipPosition => { + if (slice.bars.length === 0) { + return { + offsetLeft: isVertical ? slice.sliceCenter + margins.left : margins.left, + offsetTop: isVertical + ? innerHeight + margins.top + : slice.sliceCenter + margins.top, + }; + } + + const anchorBar = findAnchorBarInSlice({ + bars: slice.bars, + isVerticalLayout: isVertical, + }); + + return { + offsetLeft: isVertical + ? slice.sliceCenter + margins.left + : anchorBar.x + anchorBar.width + margins.left, + offsetTop: isVertical + ? anchorBar.y + margins.top + : slice.sliceCenter + margins.top, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSlicesFromBars.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSlicesFromBars.ts deleted file mode 100644 index a11b895baf..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSlicesFromBars.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; -import { type BarDatum, type ComputedBarDatum } from '@nivo/bar'; -import { isDefined } from 'twenty-shared/utils'; - -type ComputeSlicesFromBarsParams = { - bars: readonly ComputedBarDatum[]; - isVerticalLayout: boolean; -}; - -export const computeSlicesFromBars = ({ - bars, - isVerticalLayout, -}: ComputeSlicesFromBarsParams): BarChartSlice[] => { - if (bars.length === 0) { - return []; - } - - const groupedBarsByIndex = new Map[]>(); - - for (const bar of bars) { - const indexKey = String(bar.data.indexValue); - const existingBars = groupedBarsByIndex.get(indexKey); - if (isDefined(existingBars)) { - existingBars.push(bar); - } else { - groupedBarsByIndex.set(indexKey, [bar]); - } - } - - const computedSlices: BarChartSlice[] = []; - - for (const [indexValue, barsInGroup] of groupedBarsByIndex) { - const minPosition = Math.min( - ...barsInGroup.map((bar) => (isVerticalLayout ? bar.x : bar.y)), - ); - const maxPosition = Math.max( - ...barsInGroup.map((bar) => - isVerticalLayout ? bar.x + bar.width : bar.y + bar.height, - ), - ); - - computedSlices.push({ - indexValue, - bars: [...barsInGroup], - sliceLeft: minPosition, - sliceRight: maxPosition, - sliceCenter: (minPosition + maxPosition) / 2, - }); - } - - computedSlices.sort((sliceA, sliceB) => sliceA.sliceLeft - sliceB.sliceLeft); - - return computedSlices; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeStackedBarPositions.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeStackedBarPositions.ts new file mode 100644 index 0000000000..c72b4cdc66 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeStackedBarPositions.ts @@ -0,0 +1,60 @@ +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; +import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; +import { type BarPositionContext } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionContext'; +import { buildBars } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/buildBars'; +import { + computeStackedBarLayout, + getStackedBarDimensions, +} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getStackedBarDimensions'; + +type ComputeStackedBarPositionsParams = { + ctx: BarPositionContext; + data: BarChartDatum[]; + indexBy: string; + keys: string[]; + enrichedKeysMap: Map; + valueDomain: { min: number; max: number }; + shouldRoundFreeEndMap: Map | null; + includeZeroValues?: boolean; +}; + +export const computeStackedBarPositions = ({ + ctx, + data, + indexBy, + keys, + enrichedKeysMap, + valueDomain, + shouldRoundFreeEndMap, + includeZeroValues = false, +}: ComputeStackedBarPositionsParams): BarPosition[] => { + const stackedLayout = computeStackedBarLayout(ctx, valueDomain); + return buildBars({ + ctx, + data, + indexBy, + keys, + enrichedKeysMap, + shouldRoundFreeEndMap, + includeZeroValues, + getDimensions: ({ ctx, categoryStart, value, stackState }) => { + const { newPositiveStackPixel, newNegativeStackPixel, ...dimensions } = + getStackedBarDimensions({ + ctx, + layout: stackedLayout, + categoryStart, + value, + positiveStackPixel: stackState.positiveStackPixel, + negativeStackPixel: stackState.negativeStackPixel, + }); + return { + dimensions, + nextStackState: { + positiveStackPixel: newPositiveStackPixel, + negativeStackPixel: newNegativeStackPixel, + }, + }; + }, + }); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/drawRoundedRect.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/drawRoundedRect.ts new file mode 100644 index 0000000000..8a579613b0 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/drawRoundedRect.ts @@ -0,0 +1,103 @@ +type RoundedRectOptions = { + ctx: CanvasRenderingContext2D; + x: number; + y: number; + width: number; + height: number; + radius: number; + roundTop: boolean; + roundBottom: boolean; +}; + +export const drawRoundedRect = ({ + ctx, + x, + y, + width, + height, + radius, + roundTop, + roundBottom, +}: RoundedRectOptions): void => { + if (width <= 0 || height <= 0) { + return; + } + + const effectiveRadius = Math.min(radius, width / 2, height / 2); + + ctx.beginPath(); + + if (roundTop && roundBottom) { + ctx.roundRect(x, y, width, height, effectiveRadius); + } else if (roundTop) { + ctx.roundRect(x, y, width, height, [ + effectiveRadius, + effectiveRadius, + 0, + 0, + ]); + } else if (roundBottom) { + ctx.roundRect(x, y, width, height, [ + 0, + 0, + effectiveRadius, + effectiveRadius, + ]); + } else { + ctx.rect(x, y, width, height); + } + + ctx.fill(); +}; + +type HorizontalRoundedRectOptions = { + ctx: CanvasRenderingContext2D; + x: number; + y: number; + width: number; + height: number; + radius: number; + roundLeft: boolean; + roundRight: boolean; +}; + +export const drawHorizontalRoundedRect = ({ + ctx, + x, + y, + width, + height, + radius, + roundLeft, + roundRight, +}: HorizontalRoundedRectOptions): void => { + if (width <= 0 || height <= 0) { + return; + } + + const effectiveRadius = Math.min(radius, width / 2, height / 2); + + ctx.beginPath(); + + if (roundLeft && roundRight) { + ctx.roundRect(x, y, width, height, effectiveRadius); + } else if (roundRight) { + ctx.roundRect(x, y, width, height, [ + 0, + effectiveRadius, + effectiveRadius, + 0, + ]); + } else if (roundLeft) { + ctx.roundRect(x, y, width, height, [ + effectiveRadius, + 0, + 0, + effectiveRadius, + ]); + } else { + ctx.rect(x, y, width, height); + } + + ctx.fill(); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/findAnchorBarInSlice.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/findAnchorBarInSlice.ts index 060582fcd3..8bf04d8244 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/findAnchorBarInSlice.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/findAnchorBarInSlice.ts @@ -1,12 +1,20 @@ -import { type BarDatum, type ComputedBarDatum } from '@nivo/bar'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; -export const findAnchorBarInSlice = ( - bars: ComputedBarDatum[], - isVerticalLayout: boolean, -): ComputedBarDatum => { - return isVerticalLayout - ? bars.reduce((topBar, bar) => (bar.y < topBar.y ? bar : topBar)) - : bars.reduce((rightBar, bar) => - bar.x + bar.width > rightBar.x + rightBar.width ? bar : rightBar, - ); +export const findAnchorBarInSlice = ({ + bars, + isVerticalLayout, +}: { + bars: BarPosition[]; + isVerticalLayout: boolean; +}): BarPosition => { + if (bars.length === 0) { + throw new Error('Cannot find anchor bar in empty slice'); + } + + return bars.reduce((anchor, bar) => { + if (isVerticalLayout) { + return bar.y < anchor.y ? bar : anchor; + } + return bar.x + bar.width > anchor.x + anchor.width ? bar : anchor; + }); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/findSliceAtPosition.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/findSliceAtPosition.ts index 795886ecb1..f9a1c47e03 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/findSliceAtPosition.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/findSliceAtPosition.ts @@ -1,40 +1,21 @@ import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; -import { findAnchorBarInSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/findAnchorBarInSlice'; -import { type MouseEvent } from 'react'; -import { isDefined } from 'twenty-shared/utils'; - -type FindSliceAtPositionParams = { - event: MouseEvent; - slices: BarChartSlice[]; - marginLeft: number; - marginTop: number; - isVerticalLayout: boolean; -}; - -type SliceAtPositionResult = { - slice: BarChartSlice; - offsetLeft: number; - offsetTop: number; -}; export const findSliceAtPosition = ({ - event, + mouseX, + mouseY, slices, - marginLeft, - marginTop, isVerticalLayout, -}: FindSliceAtPositionParams): SliceAtPositionResult | null => { - const svgBoundingRectangle = - event.currentTarget.ownerSVGElement?.getBoundingClientRect(); - - if (!isDefined(svgBoundingRectangle) || slices.length === 0) { +}: { + mouseX: number; + mouseY: number; + slices: BarChartSlice[]; + isVerticalLayout: boolean; +}): BarChartSlice | null => { + if (slices.length === 0) { return null; } - const mousePositionX = event.clientX - svgBoundingRectangle.left - marginLeft; - const mousePositionY = event.clientY - svgBoundingRectangle.top - marginTop; - - const positionAlongAxis = isVerticalLayout ? mousePositionX : mousePositionY; + const positionAlongAxis = isVerticalLayout ? mouseX : mouseY; const nearestSlice = slices.reduce((nearest, slice) => { const currentDistance = Math.abs(slice.sliceCenter - positionAlongAxis); @@ -42,18 +23,5 @@ export const findSliceAtPosition = ({ return currentDistance < nearestDistance ? slice : nearest; }); - const anchorBar = findAnchorBarInSlice(nearestSlice.bars, isVerticalLayout); - - const offsetLeft = isVerticalLayout - ? nearestSlice.sliceCenter + marginLeft - : anchorBar.absX + anchorBar.width + marginLeft; - const offsetTop = isVerticalLayout - ? anchorBar.absY + marginTop - : nearestSlice.sliceCenter + marginTop; - - return { - slice: nearestSlice, - offsetLeft, - offsetTop, - }; + return nearestSlice; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs.ts index 832c0fedc8..85493e27cf 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs.ts @@ -76,9 +76,9 @@ export const getBarChartAxisConfigs = ({ legend: yAxisLabel, legendOffset: -margins.left + BAR_CHART_CONSTANTS.LEFT_AXIS_LEGEND_OFFSET_PADDING, - format: (value: number) => + format: (value: string | number) => truncateTickLabel( - formatGraphValue(value, formatOptions ?? {}), + formatGraphValue(Number(value), formatOptions ?? {}), maxLeftAxisTickLabelLength, ), }, @@ -101,9 +101,9 @@ export const getBarChartAxisConfigs = ({ 0, ), ), - format: (value: number) => + format: (value: string | number) => truncateTickLabel( - formatGraphValue(value, formatOptions ?? {}), + formatGraphValue(Number(value), formatOptions ?? {}), maxBottomAxisTickLabelLength, ), }, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor.ts deleted file mode 100644 index 1ef49fce36..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; -import { type BarDatum, type ComputedDatum } from '@nivo/bar'; -import { type ThemeType } from 'twenty-ui/theme'; - -export const getBarChartColor = ( - datum: ComputedDatum, - enrichedKeysMap: Map, - theme: ThemeType, -) => { - const enrichedKey = enrichedKeysMap.get(String(datum.id)); - if (!enrichedKey) { - return theme.border.color.light; - } - return enrichedKey.colorScheme.solid; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartInnerPadding.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartInnerPadding.ts index 2799af0b94..0770140ea5 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartInnerPadding.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartInnerPadding.ts @@ -1,4 +1,5 @@ import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { computeBandScale } from '@/page-layout/widgets/graph/chart-core/utils/computeBandScale'; import { BarChartLayout } from '~/generated/graphql'; type BarChartMargins = { @@ -40,9 +41,12 @@ export const getBarChartInnerPadding = ({ ? chartWidth - margins.left - margins.right : chartHeight - margins.top - margins.bottom; - const spacePerGroup = - (availableSpace / dataLength) * - (1 - BAR_CHART_CONSTANTS.OUTER_PADDING_RATIO); + const { bandwidth: spacePerGroup } = computeBandScale({ + axisLength: availableSpace, + count: dataLength, + padding: BAR_CHART_CONSTANTS.OUTER_PADDING_RATIO, + outerPaddingPx: BAR_CHART_CONSTANTS.OUTER_PADDING_PX, + }); const spacePerBar = spacePerGroup / keysLength; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartLayout.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartLayout.ts index 82aee1f05f..9788d9ecc6 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartLayout.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartLayout.ts @@ -1,5 +1,6 @@ import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; import { getBarChartAxisConfigs } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs'; import { getBarChartTickConfig, @@ -16,14 +17,13 @@ import { type GraphValueFormatOptions, } from '@/page-layout/widgets/graph/utils/graphFormatters'; import { resolveAxisFontSizes } from '@/page-layout/widgets/graph/utils/resolveAxisFontSizes'; -import { type BarDatum } from '@nivo/bar'; import { BarChartLayout } from '~/generated/graphql'; type GetBarChartLayoutParams = { axisTheme: ChartAxisTheme; chartWidth: number; chartHeight: number; - data: BarDatum[]; + data: BarChartDatum[]; indexBy: string; layout: BarChartLayout; xAxisLabel?: string; @@ -79,7 +79,12 @@ const resolveMarginInputs = ({ ? tickResult.tickValues.map((value) => formatGraphValue(value, formatOptions), ) - : tickConfiguration.categoryTickValues.map((value) => String(value)); + : tickConfiguration.categoryTickValues.map((value) => + truncateTickLabel( + String(value), + tickConfiguration.maxLeftAxisTickLabelLength, + ), + ); return { bottomTickLabels, leftTickLabels }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig.ts index b1628f990f..2c8631cd27 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig.ts @@ -1,10 +1,10 @@ import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; import { computeBarChartCategoryTickValues } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues'; import { computeBarChartValueTickCount } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartValueTickCount'; import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; import { computeMaxLabelLengthForMargin } from '@/page-layout/widgets/graph/utils/computeMaxLabelLengthForMargin'; import { getTickRotationConfig } from '@/page-layout/widgets/graph/utils/getTickRotationConfig'; -import { type BarDatum } from '@nivo/bar'; import { BarChartLayout } from '~/generated/graphql'; export type BarChartTickConfig = { @@ -26,7 +26,7 @@ export const getBarChartTickConfig = ({ }: { width: number; height: number; - data: BarDatum[]; + data: BarChartDatum[]; indexBy: string; axisFontSize: number; layout: BarChartLayout; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData.ts index 99a4e656d6..32ce4b1f01 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData.ts @@ -1,4 +1,5 @@ import { type GraphWidgetTooltipItem } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip'; +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; import { @@ -8,6 +9,7 @@ import { type GetBarChartTooltipDataParameters = { slice: BarChartSlice; + dataByIndexValue: Map; enrichedKeys: BarChartEnrichedKey[]; formatOptions: GraphValueFormatOptions; }; @@ -19,16 +21,18 @@ type BarChartTooltipData = { export const getBarChartTooltipData = ({ slice, + dataByIndexValue, enrichedKeys, formatOptions, }: GetBarChartTooltipDataParameters): BarChartTooltipData | null => { - if (slice.bars.length === 0) { + const dataRow = dataByIndexValue.get(slice.indexValue); + + if (!dataRow) { return null; } - const firstBar = slice.bars[0]; const tooltipItems = enrichedKeys.map((enrichedKey) => { - const seriesValue = Number(firstBar.data.data[enrichedKey.key] ?? 0); + const seriesValue = Number(dataRow[enrichedKey.key] ?? 0); return { key: enrichedKey.key, label: enrichedKey.label, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getGroupedBarDimensions.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getGroupedBarDimensions.ts new file mode 100644 index 0000000000..279ea7c49d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getGroupedBarDimensions.ts @@ -0,0 +1,77 @@ +import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { type BarPositionContext } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionContext'; + +type GetGroupedBarDimensionsParams = { + ctx: BarPositionContext; + layout: GroupedBarLayout; + categoryStart: number; + keyIndex: number; + value: number; +}; + +export type GroupedBarLayout = { + barThickness: number; + groupCenteringOffset: number; + barStride: number; +}; + +export const computeGroupedBarLayout = ( + ctx: BarPositionContext, + innerPadding: number, +): GroupedBarLayout => { + const { keysLength, categoryWidth } = ctx; + + const totalInnerPadding = innerPadding * (keysLength - 1); + const availableBarSpace = categoryWidth - totalInnerPadding; + const barThickness = Math.min( + Math.max( + availableBarSpace / keysLength, + BAR_CHART_CONSTANTS.MINIMUM_BAR_WIDTH, + ), + BAR_CHART_CONSTANTS.MAXIMUM_WIDTH, + ); + const actualTotalBarWidth = barThickness * keysLength + totalInnerPadding; + const groupCenteringOffset = (categoryWidth - actualTotalBarWidth) / 2; + const barStride = barThickness + innerPadding; + + return { barThickness, groupCenteringOffset, barStride }; +}; + +export const getGroupedBarDimensions = ({ + ctx, + layout, + categoryStart, + keyIndex, + value, +}: GetGroupedBarDimensionsParams): { + x: number; + y: number; + width: number; + height: number; +} => { + const { isVertical, valueAxisLength, valueToPixel, zeroPixel } = ctx; + const { barThickness, groupCenteringOffset, barStride } = layout; + + const valuePixel = valueToPixel(value); + const barStart = Math.min(zeroPixel, valuePixel); + const barLength = Math.abs(valuePixel - zeroPixel); + + const categoryPosition = + categoryStart + groupCenteringOffset + keyIndex * barStride; + + if (isVertical) { + return { + x: categoryPosition, + y: valueAxisLength - barStart - barLength, + width: barThickness, + height: barLength, + }; + } + + return { + x: barStart, + y: categoryPosition, + width: barLength, + height: barThickness, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getSliceHoverDataFromMouseEvent.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getSliceHoverDataFromMouseEvent.ts new file mode 100644 index 0000000000..6fa61ff5d3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getSliceHoverDataFromMouseEvent.ts @@ -0,0 +1,39 @@ +import { getPointerPosition } from '@/page-layout/widgets/graph/chart-core/utils/getPointerPosition'; +import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; +import { type BarChartSliceHoverData } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSliceHoverData'; +import { getSliceHoverDataFromPointerPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getSliceHoverDataFromPointerPosition'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import { type MouseEvent } from 'react'; + +type GetSliceHoverDataFromMouseEventParams = { + event: MouseEvent; + margins: ChartMargins; + innerWidth: number; + innerHeight: number; + slices: BarChartSlice[]; + isVerticalLayout: boolean; +}; + +export const getSliceHoverDataFromMouseEvent = ({ + event, + margins, + innerWidth, + innerHeight, + slices, + isVerticalLayout, +}: GetSliceHoverDataFromMouseEventParams): BarChartSliceHoverData | null => { + const { x: pointerPositionX, y: pointerPositionY } = getPointerPosition({ + event, + element: event.currentTarget, + }); + + return getSliceHoverDataFromPointerPosition({ + pointerPositionX, + pointerPositionY, + margins, + innerWidth, + innerHeight, + slices, + isVerticalLayout, + }); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getSliceHoverDataFromPointerPosition.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getSliceHoverDataFromPointerPosition.ts new file mode 100644 index 0000000000..2ad6a2c0ab --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getSliceHoverDataFromPointerPosition.ts @@ -0,0 +1,60 @@ +import { isPointInChartArea } from '@/page-layout/widgets/graph/chart-core/utils/isPointInChartArea'; +import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; +import { type BarChartSliceHoverData } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSliceHoverData'; +import { computeSliceTooltipPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSliceTooltipPosition'; +import { findSliceAtPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/findSliceAtPosition'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; + +type GetSliceHoverDataFromPointerPositionParams = { + pointerPositionX: number; + pointerPositionY: number; + margins: ChartMargins; + innerWidth: number; + innerHeight: number; + slices: BarChartSlice[]; + isVerticalLayout: boolean; +}; + +export const getSliceHoverDataFromPointerPosition = ({ + pointerPositionX, + pointerPositionY, + margins, + innerWidth, + innerHeight, + slices, + isVerticalLayout, +}: GetSliceHoverDataFromPointerPositionParams): BarChartSliceHoverData | null => { + const relativePointerPositionX = pointerPositionX - margins.left; + const relativePointerPositionY = pointerPositionY - margins.top; + + if ( + !isPointInChartArea({ + x: relativePointerPositionX, + y: relativePointerPositionY, + innerWidth, + innerHeight, + }) + ) { + return null; + } + + const slice = findSliceAtPosition({ + mouseX: relativePointerPositionX, + mouseY: relativePointerPositionY, + slices, + isVerticalLayout, + }); + + if (!slice) { + return null; + } + + const { offsetLeft, offsetTop } = computeSliceTooltipPosition({ + slice, + margins, + innerHeight, + isVertical: isVerticalLayout, + }); + + return { slice, offsetLeft, offsetTop }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getStackedBarDimensions.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getStackedBarDimensions.ts new file mode 100644 index 0000000000..13e726b14a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getStackedBarDimensions.ts @@ -0,0 +1,118 @@ +import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { computeValueScale } from '@/page-layout/widgets/graph/chart-core/utils/computeValueScale'; +import { type BarPositionContext } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarPositionContext'; + +type GetStackedBarDimensionsParams = { + ctx: BarPositionContext; + layout: StackedBarLayout; + categoryStart: number; + value: number; + positiveStackPixel: number; + negativeStackPixel: number; +}; + +type StackedBarDimensionsResult = { + x: number; + y: number; + width: number; + height: number; + newPositiveStackPixel: number; + newNegativeStackPixel: number; +}; + +export type StackedBarLayout = { + barThickness: number; + categoryBarCenteringOffset: number; + stackValueToPixel: (value: number) => number; + stackRange: number; +}; + +export const computeStackedBarLayout = ( + ctx: BarPositionContext, + valueDomain: { min: number; max: number }, +): StackedBarLayout => { + const { categoryWidth, valueAxisLength } = ctx; + + const barThickness = Math.min( + categoryWidth, + BAR_CHART_CONSTANTS.MAXIMUM_WIDTH, + ); + const categoryBarCenteringOffset = (categoryWidth - barThickness) / 2; + + const { valueToPixel: stackValueToPixel, range: stackRange } = + computeValueScale({ + domain: { min: 0, max: valueDomain.max - valueDomain.min }, + axisLength: valueAxisLength, + }); + + return { + barThickness, + categoryBarCenteringOffset, + stackValueToPixel, + stackRange, + }; +}; + +export const getStackedBarDimensions = ({ + ctx, + layout, + categoryStart, + value, + positiveStackPixel, + negativeStackPixel, +}: GetStackedBarDimensionsParams): StackedBarDimensionsResult => { + const { isVertical, valueAxisLength } = ctx; + const { + barThickness, + categoryBarCenteringOffset, + stackValueToPixel, + stackRange, + } = layout; + const categoryPosition = categoryStart + categoryBarCenteringOffset; + + const isNegative = value < 0; + const valuePixelDelta = + stackRange === 0 ? 0 : stackValueToPixel(Math.abs(value)); + + if (isVertical && isNegative) { + return { + x: categoryPosition, + y: valueAxisLength - negativeStackPixel, + width: barThickness, + height: valuePixelDelta, + newPositiveStackPixel: positiveStackPixel, + newNegativeStackPixel: negativeStackPixel - valuePixelDelta, + }; + } + + if (isVertical) { + return { + x: categoryPosition, + y: valueAxisLength - (positiveStackPixel + valuePixelDelta), + width: barThickness, + height: valuePixelDelta, + newPositiveStackPixel: positiveStackPixel + valuePixelDelta, + newNegativeStackPixel: negativeStackPixel, + }; + } + + if (isNegative) { + return { + x: negativeStackPixel - valuePixelDelta, + y: categoryPosition, + width: valuePixelDelta, + height: barThickness, + newPositiveStackPixel: positiveStackPixel, + newNegativeStackPixel: negativeStackPixel - valuePixelDelta, + }; + } + + return { + x: positiveStackPixel, + y: categoryPosition, + width: valuePixelDelta, + height: barThickness, + newPositiveStackPixel: positiveStackPixel + valuePixelDelta, + newNegativeStackPixel: negativeStackPixel, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/hasNegativeValuesInData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/hasNegativeValuesInData.ts new file mode 100644 index 0000000000..b6fdbfaa9f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/hasNegativeValuesInData.ts @@ -0,0 +1,17 @@ +import { type BarChartDatum } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDatum'; +import { isNumber } from '@sniptt/guards'; + +export const hasNegativeValuesInData = ( + data: BarChartDatum[], + keys: string[], +): boolean => { + for (const datum of data) { + for (const key of keys) { + const value = datum[key]; + if (isNumber(value) && value < 0) { + return true; + } + } + } + return false; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/interpolateBars.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/interpolateBars.ts new file mode 100644 index 0000000000..516e803004 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/interpolateBars.ts @@ -0,0 +1,55 @@ +import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; + +const getBarKey = (bar: BarPosition) => `${bar.indexValue}::${bar.seriesId}`; + +const buildBarMap = (bars: BarPosition[]) => + new Map(bars.map((bar) => [getBarKey(bar), bar])); + +const lerp = (from: number, to: number, t: number) => from + (to - from) * t; + +const easeOutCubic = (t: number) => + 1 - Math.pow(1 - t, BAR_CHART_CONSTANTS.ANIMATION_EASING_EXPONENT); + +export const interpolateBars = ( + sourceBars: BarPosition[], + targetBars: BarPosition[], + t: number, + toBaselineBar: (bar: BarPosition) => BarPosition, +): BarPosition[] => { + const sourceMap = buildBarMap(sourceBars); + const targetMap = buildBarMap(targetBars); + const allKeys = new Set([...sourceMap.keys(), ...targetMap.keys()]); + const eased = easeOutCubic(t); + + const result: BarPosition[] = []; + + for (const key of allKeys) { + const fromBar = sourceMap.get(key); + const toBar = targetMap.get(key); + + if (!fromBar && !toBar) { + continue; + } + + const startBar = fromBar ?? toBaselineBar(toBar as BarPosition); + const endBar = toBar ?? toBaselineBar(fromBar as BarPosition); + + result.push({ + ...endBar, + x: lerp(startBar.x, endBar.x, eased), + y: lerp(startBar.y, endBar.y, eased), + width: lerp(startBar.width, endBar.width, eased), + height: lerp(startBar.height, endBar.height, eased), + value: lerp(startBar.value, endBar.value, eased), + color: endBar.color ?? startBar.color, + seriesId: endBar.seriesId ?? startBar.seriesId, + indexValue: endBar.indexValue ?? startBar.indexValue, + shouldRoundFreeEnd: + endBar.shouldRoundFreeEnd ?? startBar.shouldRoundFreeEnd, + seriesIndex: endBar.seriesIndex ?? startBar.seriesIndex, + }); + } + + return result; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/renderBars.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/renderBars.ts new file mode 100644 index 0000000000..978c9a9fdf --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/renderBars.ts @@ -0,0 +1,62 @@ +import { LEGEND_HIGHLIGHT_DIMMED_OPACITY } from '@/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant'; +import { type BarPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarPosition'; +import { + drawHorizontalRoundedRect, + drawRoundedRect, +} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/drawRoundedRect'; + +export const renderBars = ({ + ctx, + bars, + borderRadius, + isVertical, + highlightedLegendId, +}: { + ctx: CanvasRenderingContext2D; + bars: BarPosition[]; + borderRadius: number; + isVertical: boolean; + highlightedLegendId: string | null; +}): void => { + for (const bar of bars) { + const isDimmed = + highlightedLegendId !== null && bar.seriesId !== highlightedLegendId; + + ctx.globalAlpha = isDimmed ? LEGEND_HIGHLIGHT_DIMMED_OPACITY : 1; + ctx.fillStyle = bar.color; + + const isNegative = bar.value < 0; + + if (isVertical) { + const roundTop = bar.shouldRoundFreeEnd && !isNegative; + const roundBottom = bar.shouldRoundFreeEnd && isNegative; + + drawRoundedRect({ + ctx, + x: bar.x, + y: bar.y, + width: bar.width, + height: bar.height, + radius: borderRadius, + roundTop, + roundBottom, + }); + } else { + const roundRight = bar.shouldRoundFreeEnd && !isNegative; + const roundLeft = bar.shouldRoundFreeEnd && isNegative; + + drawHorizontalRoundedRect({ + ctx, + x: bar.x, + y: bar.y, + width: bar.width, + height: bar.height, + radius: borderRadius, + roundLeft, + roundRight, + }); + } + } + + ctx.globalAlpha = 1; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/renderSliceHighlight.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/renderSliceHighlight.ts new file mode 100644 index 0000000000..a051ecd3c6 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/renderSliceHighlight.ts @@ -0,0 +1,39 @@ +import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; + +export const renderSliceHighlight = ({ + ctx, + slice, + innerWidth, + innerHeight, + isVertical, + highlightColor, +}: { + ctx: CanvasRenderingContext2D; + slice: BarChartSlice; + innerWidth: number; + innerHeight: number; + isVertical: boolean; + highlightColor: string; +}): void => { + const halfThickness = BAR_CHART_CONSTANTS.SLICE_HIGHLIGHT_THICKNESS / 2; + const center = slice.sliceCenter; + + ctx.fillStyle = highlightColor; + + if (isVertical) { + ctx.fillRect( + center - halfThickness, + 0, + BAR_CHART_CONSTANTS.SLICE_HIGHLIGHT_THICKNESS, + innerHeight, + ); + } else { + ctx.fillRect( + 0, + center - halfThickness, + innerWidth, + BAR_CHART_CONSTANTS.SLICE_HIGHLIGHT_THICKNESS, + ); + } +}; diff --git a/yarn.lock b/yarn.lock index 91452e29a1..b8289f7f4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12130,32 +12130,6 @@ __metadata: languageName: node linkType: hard -"@nivo/bar@npm:^0.99.0": - version: 0.99.0 - resolution: "@nivo/bar@npm:0.99.0" - dependencies: - "@nivo/annotations": "npm:0.99.0" - "@nivo/axes": "npm:0.99.0" - "@nivo/canvas": "npm:0.99.0" - "@nivo/colors": "npm:0.99.0" - "@nivo/core": "npm:0.99.0" - "@nivo/legends": "npm:0.99.0" - "@nivo/scales": "npm:0.99.0" - "@nivo/text": "npm:0.99.0" - "@nivo/theming": "npm:0.99.0" - "@nivo/tooltip": "npm:0.99.0" - "@react-spring/web": "npm:9.4.5 || ^9.7.2 || ^10.0" - "@types/d3-scale": "npm:^4.0.8" - "@types/d3-shape": "npm:^3.1.6" - d3-scale: "npm:^4.0.2" - d3-shape: "npm:^3.2.0" - lodash: "npm:^4.17.21" - peerDependencies: - react: ^16.14 || ^17.0 || ^18.0 || ^19.0 - checksum: 10c0/17860f18fed48b5c5d7a76e30b5be73481e810aa8bc417a5afc5c2350b1051ee7148bcd32f4444b6d8486839e9b9ffaa8c614b9a53d89196d4494703b0bd05c5 - languageName: node - linkType: hard - "@nivo/calendar@npm:^0.99.0": version: 0.99.0 resolution: "@nivo/calendar@npm:0.99.0" @@ -12178,13 +12152,6 @@ __metadata: languageName: node linkType: hard -"@nivo/canvas@npm:0.99.0": - version: 0.99.0 - resolution: "@nivo/canvas@npm:0.99.0" - checksum: 10c0/530f074b3368328b9bbadefaa64f5bdaafd210edb0c1446beed749ff4fa9ac805eae44418c88501ac137724db2c685e293da9d38067ea8e7fe9fd1aa500279d9 - languageName: node - linkType: hard - "@nivo/colors@npm:0.99.0": version: 0.99.0 resolution: "@nivo/colors@npm:0.99.0" @@ -57595,7 +57562,6 @@ __metadata: "@lingui/swc-plugin": "npm:^5.6.0" "@lingui/vite-plugin": "npm:^5.1.2" "@monaco-editor/react": "npm:^4.7.0" - "@nivo/bar": "npm:^0.99.0" "@nivo/core": "npm:^0.99.0" "@nivo/line": "npm:^0.99.0" "@nivo/pie": "npm:^0.99.0"