[Dashboards] Replace nivo bar chart with custom canvas bar chart (#17441)

before - 


https://github.com/user-attachments/assets/01d1ce73-1732-4516-bde0-d43c1bbcb734

after - 
I got rid of line chart in the after clip -- because now its the line
chart thats more laggy :) -- but now could be easily migrated away from
nivo


https://github.com/user-attachments/assets/430f4697-68cd-47be-b63e-f8df34a2ee0e

stress test - 

before - 



https://github.com/user-attachments/assets/c3d3e05c-943e-48dc-9429-a2ecf41cd4fe



after - 




https://github.com/user-attachments/assets/98b35a43-f918-4a46-9b66-3bc8deabfdb8

---------

Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
This commit is contained in:
nitin
2026-02-02 13:12:20 +05:30
committed by GitHub
parent 1976ad58c4
commit fb97c40cad
98 changed files with 4971 additions and 1186 deletions
-1
View File
@@ -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",
@@ -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<HTMLDivElement>(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}
</StyledCommandMenu>
@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isSidePanelAnimatingState = atom({
key: 'command-menu/isSidePanelAnimatingState',
default: false,
});
@@ -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;
@@ -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 (
<text
x={x}
y={y}
textAnchor="middle"
transform={
isDefined(rotation) ? `rotate(${rotation}, ${x}, ${y})` : undefined
}
fill={theme.font.color.primary}
fontSize={fontSize}
fontWeight={theme.font.weight.medium}
>
{label}
</text>
);
};
@@ -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 (
<StyledSvgOverlay width={chartWidth} height={chartHeight}>
<g transform={`translate(${margins.left}, ${margins.top})`}>
<line
x1={0}
y1={innerHeight + STROKE_ALIGNMENT_OFFSET}
x2={innerWidth}
y2={innerHeight + STROKE_ALIGNMENT_OFFSET}
stroke={theme.border.color.light}
strokeWidth={1}
/>
<line
x1={0}
y1={0}
x2={0}
y2={innerHeight}
stroke={theme.border.color.light}
strokeWidth={1}
/>
{shouldRenderZeroLine && (
<ZeroLine
isVertical={isVertical}
zeroPosition={zeroPosition}
innerWidth={innerWidth}
innerHeight={innerHeight}
/>
)}
<g transform={`translate(0, ${innerHeight})`}>
<BottomAxisTicks
bottomTickValues={bottomTickValues}
getBottomTickPosition={getBottomTickPosition}
formatBottomTick={formatBottomTick}
hasRotation={hasRotation}
bottomAxisTickRotation={bottomAxisTickRotation}
tickPadding={axisConfig.tickPadding}
tickFontSize={tickFontSize}
/>
{xAxisLabel && (
<AxisLabel
label={xAxisLabel}
x={innerWidth / 2}
y={bottomLegendOffset}
fontSize={legendFontSize}
/>
)}
</g>
<g>
<LeftAxisTicks
leftTickValues={leftTickValues}
getLeftTickPosition={getLeftTickPosition}
formatLeftTick={formatLeftTick}
tickPadding={axisConfig.tickPadding}
tickFontSize={tickFontSize}
/>
{yAxisLabel && (
<AxisLabel
label={yAxisLabel}
x={leftLegendOffset}
y={innerHeight / 2}
fontSize={legendFontSize}
rotation={-90}
/>
)}
</g>
</g>
</StyledSvgOverlay>
);
};
@@ -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 (
<g key={`bottom-tick-${index}`} transform={`translate(${x}, 0)`}>
<text
x={0}
y={tickPadding + tickFontSize}
textAnchor={hasRotation ? 'end' : 'middle'}
transform={
hasRotation
? `rotate(${bottomAxisTickRotation}, 0, ${tickPadding + tickFontSize / 2})`
: undefined
}
fill={theme.font.color.secondary}
fontSize={tickFontSize}
>
{label}
</text>
</g>
);
})}
</>
);
};
@@ -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 (
<g key={`left-tick-${index}`} transform={`translate(0, ${y})`}>
<text
x={-tickPadding}
y={0}
textAnchor="end"
dominantBaseline="middle"
fill={theme.font.color.secondary}
fontSize={tickFontSize}
>
{label}
</text>
</g>
);
})}
</>
);
};
@@ -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 (
<line
x1={isVertical ? 0 : zeroPosition}
y1={isVertical ? zeroPosition : 0}
x2={isVertical ? innerWidth : zeroPosition}
y2={isVertical ? zeroPosition : innerHeight}
stroke={theme.border.color.medium}
strokeWidth={1}
/>
);
};
@@ -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;
};
@@ -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);
});
});
@@ -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);
});
});
});
@@ -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);
});
});
});
@@ -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);
});
});
@@ -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 });
});
});
@@ -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);
});
});
@@ -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 };
};
@@ -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<string, number>;
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;
};
@@ -0,0 +1,41 @@
type CategoryScale = {
offset: number;
step: number;
bandwidth: number;
};
type ComputeCategoryTickCenterPositionParams = {
value: string | number;
index: number;
categoryValues: (string | number)[];
categoryIndexMap: Map<string, number>;
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;
};
@@ -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<string, number>;
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,
});
};
@@ -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 };
};
@@ -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<string, number>(
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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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;
};
@@ -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([]);
};
@@ -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<string, BarChartEnrichedKey>;
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<StyledCanvasContainer
isClickable={isDefined(onSliceClick)}
onMouseMove={handleMouseMove}
onMouseLeave={onSliceLeave}
onClick={handleClick}
>
<BarChartLayers
allowDataTransitions={allowDataTransitions}
axisBottomTickRotation={axisBottomConfiguration.tickRotation}
axisConfig={axisLayerConfig}
bars={bars}
categoryValues={categoryValues}
categoryTickValues={resolvedCategoryTickValues}
chartHeight={chartHeight}
chartWidth={chartWidth}
formatBottomTick={formatBottomTick}
formatLeftTick={formatLeftTick}
formatValue={formatValue}
groupMode={groupMode}
hasNegativeValues={hasNegativeValues}
hasNoData={hasNoData}
highlightedLegendId={highlightedLegendId}
hoveredSlice={hoveredSlice}
innerHeight={innerHeight}
innerWidth={innerWidth}
labelBars={labelBars}
layout={layout}
margins={margins}
omitNullValues={omitNullValues}
offset={theme.spacingMultiplicator * 2}
showGrid={showGrid}
showValues={showTotalsValues}
valueDomain={valueDomain}
valueTickValues={valueTickValues}
xAxisLabel={axisConfig?.xAxisLabel}
yAxisLabel={axisConfig?.yAxisLabel}
/>
</StyledCanvasContainer>
);
};
@@ -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<HTMLCanvasElement>(null);
return (
<>
<StyledBaseCanvas ref={canvasRef} />
<BarChartBaseLayerEffect
bars={bars}
chartHeight={chartHeight}
chartWidth={chartWidth}
layout={layout}
margins={margins}
valueDomain={valueDomain}
valueTickValues={valueTickValues}
showGrid={showGrid}
highlightedLegendId={highlightedLegendId}
allowDataTransitions={allowDataTransitions}
canvasRef={canvasRef}
/>
</>
);
};
@@ -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<HTMLCanvasElement>;
};
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<number>(
() =>
(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<AnimationState>(() => ({
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;
};
@@ -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<HTMLCanvasElement>(null);
return (
<>
<StyledHoverCanvas ref={canvasRef} />
<BarChartHoverLayerEffect
hoveredSlice={hoveredSlice}
chartHeight={chartHeight}
chartWidth={chartWidth}
layout={layout}
margins={margins}
canvasRef={canvasRef}
/>
</>
);
};
@@ -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<HTMLCanvasElement>;
};
export const BarChartHoverLayerEffect = ({
hoveredSlice,
chartWidth,
chartHeight,
margins,
layout,
canvasRef,
}: BarChartHoverLayerEffectProps) => {
const theme = useTheme();
const [dpr] = useState<number>(
() =>
(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;
};
@@ -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 (
<>
<BarChartHoverLayer
hoveredSlice={hoveredSlice}
chartHeight={chartHeight}
chartWidth={chartWidth}
layout={layout}
margins={margins}
/>
<BarChartBaseLayer
bars={bars}
highlightedLegendId={highlightedLegendId}
showGrid={showGrid}
valueDomain={valueDomain}
valueTickValues={valueTickValues}
allowDataTransitions={allowDataTransitions}
chartHeight={chartHeight}
chartWidth={chartWidth}
layout={layout}
margins={margins}
/>
<AxisLayer
bottomAxisTickRotation={axisBottomTickRotation}
categoryValues={categoryValues}
categoryTickValues={categoryTickValues}
chartHeight={chartHeight}
chartWidth={chartWidth}
formatBottomTick={formatBottomTick}
formatLeftTick={formatLeftTick}
hasNegativeValues={hasNegativeValues}
isVertical={layout === BarChartLayout.VERTICAL}
margins={margins}
axisConfig={axisConfig}
valueDomain={valueDomain}
valueTickValues={valueTickValues}
xAxisLabel={xAxisLabel}
yAxisLabel={yAxisLabel}
/>
<BarChartTotalsLayer
bars={labelBars}
chartHeight={chartHeight}
chartWidth={chartWidth}
formatValue={formatValue}
groupMode={groupMode}
layout={layout}
margins={margins}
offset={offset}
omitNullValues={omitNullValues}
showValues={showValues}
/>
{hasNoData && (
<StyledNoDataOverlay>
<g transform={`translate(${margins.left}, ${margins.top})`}>
<NoDataLayer
hasNoData={hasNoData}
innerHeight={innerHeight}
innerWidth={innerWidth}
/>
</g>
</StyledNoDataOverlay>
)}
</>
);
};
@@ -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<HTMLDivElement>;
dataByIndexValue: Map<string, BarChartDatum>;
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,
});
@@ -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<BarCustomLayerProps<BarDatum>, '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<BarCustomLayerProps<BarDatum>, '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,
);
<StyledSvgLabels width={chartWidth} height={chartHeight}>
<g transform={`translate(${margins.left}, ${margins.top})`}>
{barChartLabelsToRender.map((barChartLabel) => {
const graphLabel = convertToGraphLabelData(
barChartLabel,
isVerticalLayout,
);
return (
<GraphDataLabel
key={graphLabel.key}
label={graphLabel}
formatValue={formatValue}
offset={offset}
isVerticalLayout={isVerticalLayout}
/>
);
})}
</>
return (
<GraphDataLabel
key={graphLabel.key}
label={graphLabel}
formatValue={formatValue}
offset={offset}
isVerticalLayout={isVerticalLayout}
/>
);
})}
</g>
</StyledSvgLabels>
);
};
@@ -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<D extends BarDatum> = BarItemProps<D> & {
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 = <D extends BarDatum>({
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<D>) => {
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 (
<animated.g transform={transform}>
<animated.g transform={barInterpolations.centeringTransform}>
{clipInterpolations && (
<defs>
<clipPath id={clipPathId}>
<animated.rect
x={clipPathX}
y={clipPathY}
rx={clipInterpolations.clipBorderRadiusX}
ry={clipInterpolations.clipBorderRadiusY}
width={clipInterpolations.clipRectWidth}
height={clipInterpolations.clipRectHeight}
/>
</clipPath>
</defs>
)}
<StyledBarRect
$isInteractive={isInteractive}
$isDimmed={isDimmed}
$isSliceHovered={isSliceHovered}
clipPath={clipInterpolations ? `url(#${clipPathId})` : undefined}
width={barInterpolations.finalBarWidth}
height={barInterpolations.finalBarHeight}
fill={color}
strokeWidth={borderWidth}
stroke={borderColor}
focusable={isFocusable}
tabIndex={isFocusable ? 0 : undefined}
aria-label={ariaLabel ? ariaLabel(barData) : undefined}
aria-labelledby={ariaLabelledBy ? ariaLabelledBy(barData) : undefined}
aria-describedby={
ariaDescribedBy ? ariaDescribedBy(barData) : undefined
}
aria-disabled={ariaDisabled ? ariaDisabled(barData) : undefined}
aria-hidden={ariaHidden ? ariaHidden(barData) : undefined}
data-testid={`bar.item.${barData.id}.${barData.index}`}
/>
</animated.g>
</animated.g>
);
};
@@ -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<BarDatum>[];
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<SVGRectElement>) => {
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<SVGRectElement>) => {
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 (
<g>
<AnimatePresence>
{isDefined(hoveredSlice) && (
<motion.g
key="highlight"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: theme.animation.duration.fast,
ease: 'easeInOut',
}}
transform={`translate(${highlightPosition.x}, ${highlightPosition.y})`}
>
<rect
width={highlightPosition.width}
height={highlightPosition.height}
fill={theme.background.transparent.medium}
style={{ pointerEvents: 'none' }}
/>
</motion.g>
)}
</AnimatePresence>
<rect
x={0}
y={0}
width={innerWidth}
height={innerHeight}
fill="transparent"
style={{ cursor: isDefined(onSliceClick) ? 'pointer' : 'default' }}
onMouseEnter={handleMouseMove}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
/>
</g>
);
};
@@ -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<BarDatum>;
type SliceHoverLayerWrapperProps = BarCustomLayerProps<BarDatum>;
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<string, string>;
showGrid?: boolean;
showLegend?: boolean;
showValues?: boolean;
xAxisLabel?: string;
yAxisLabel?: string;
id: string;
layout?: BarChartLayout;
groupMode?: 'grouped' | 'stacked';
colorMode: GraphColorMode;
seriesLabels?: Record<string, string>;
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<string, number>(
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<BarDatum>) => {
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 (
<CustomBarItem
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
shouldRoundFreeEnd={shouldRoundFreeEnd}
seriesIndex={seriesIndex}
layout={layout}
chartId={id}
/>
);
},
[shouldRoundFreeEndMap, keyToIndexMap, layout, id],
);
const TotalsLayer = ({
bars,
}: {
bars: readonly ComputedBarDatum<BarDatum>[];
}) => {
if (hasNoData) {
return null;
}
return (
<CustomTotalsLayer
bars={bars}
formatValue={(value) => formatGraphValue(value, formatOptions)}
offset={theme.spacingMultiplicator * 2}
layout={layout}
groupMode={groupMode}
omitNullValues={omitNullValues}
showValues={showValues}
/>
);
};
const NoDataLayerWrapper = (layerProps: NoDataLayerWrapperProps) => (
<NoDataLayer
innerWidth={layerProps.innerWidth}
innerHeight={layerProps.innerHeight}
hasNoData={hasNoData}
/>
);
const SliceHoverLayerWrapper = (layerProps: SliceHoverLayerWrapperProps) =>
hasNoData ? null : (
<CustomSliceHoverLayer
bars={layerProps.bars}
innerWidth={layerProps.innerWidth}
innerHeight={layerProps.innerHeight}
marginLeft={margins.left}
marginTop={margins.top}
layout={layout}
onSliceHover={handleSliceHover}
onSliceClick={onSliceClick}
onSliceLeave={handleSliceLeave}
/>
);
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 (
<StyledContainer id={id}>
<GraphWidgetChartContainer
ref={containerRef}
$isClickable={hasClickableItems}
$cursorSelector="svg g[transform] rect[fill]"
$cursorSelector="canvas"
>
<NodeDimensionEffect
elementRef={containerRef}
onDimensionChange={({ width, height }) => {
setChartWidth(width);
onDimensionChange={({ height, width }) => {
setChartHeight(height);
setChartWidth(width);
}}
/>
<ResponsiveBar
barComponent={MemoizedBarItem}
data={data}
keys={orderedKeys}
indexBy={indexBy}
margin={margins}
padding={BAR_CHART_CONSTANTS.OUTER_PADDING_RATIO}
groupMode={groupMode}
layout={
layout === BarChartLayout.VERTICAL ? 'vertical' : 'horizontal'
}
valueScale={{
type: 'linear',
min: valueDomain.min,
max: valueDomain.max,
clamp: true,
}}
indexScale={{ type: 'band', round: true }}
colors={(datum) => 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 && (
<BarChart
allowDataTransitions={allowDataTransitions}
axisConfig={{
showGrid,
xAxisLabel,
yAxisLabel,
}}
chartHeight={chartHeight}
chartWidth={chartWidth}
data={data}
dataLabelsConfig={{
omitNullValues,
show: showValues,
}}
effectiveValueRange={{
maximum: effectiveMaximumValue,
minimum: effectiveMinimumValue,
}}
enrichedKeysMap={enrichedKeysMap}
formatOptions={formatOptions}
groupMode={groupMode}
hasNoData={hasNoData}
hoveredSliceIndexValue={hoveredSliceIndexValue}
indexBy={indexBy}
keys={orderedKeys}
layout={layout}
onSliceClick={onSliceClick}
onSliceHover={handleSliceHover}
onSliceLeave={handleSliceLeave}
/>
)}
</GraphWidgetChartContainer>
<GraphBarChartTooltip
<BarChartTooltip
containerRef={containerRef}
dataByIndexValue={dataByIndexValue}
enrichedKeys={enrichedKeys}
formatOptions={formatOptions}
onSliceClick={onSliceClick}
onMouseEnter={handleTooltipMouseEnter}
onMouseLeave={handleTooltipMouseLeave}
onSliceClick={onSliceClick}
/>
<GraphWidgetLegend
show={showLegend && data.length > 0 && keys.length > 0}
items={legendItems}
show={showLegend && data.length > 0 && keys.length > 0}
/>
</StyledContainer>
);
@@ -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,
@@ -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<typeof getBarChartLayout>['margins'];
resolvedCategoryTickValues: (string | number)[];
formatBottomTick: (value: string | number) => string;
formatLeftTick: (value: string | number) => string;
valueDomain: ReturnType<typeof getBarChartLayout>['valueDomain'];
valueTickValues: ReturnType<typeof getBarChartLayout>['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,
};
};
@@ -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 {
@@ -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<string, RawDimensionValue>;
@@ -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,
};
};
@@ -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<string, BarChartEnrichedKey>;
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,
]);
};
@@ -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<BarChartSliceHoverData | null>({
key: 'graphWidgetBarTooltipComponentState',
defaultValue: null,
componentInstanceContext: WidgetComponentInstanceContext,
});
@@ -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<boolean, string>({
key: 'graphWidgetIsSliceHoveredComponentFamilySelector',
componentInstanceContext: WidgetComponentInstanceContext,
get:
({ instanceId, familyKey }: { instanceId: string; familyKey: string }) =>
({ get }) => {
const hoveredSliceIndex = get(
graphWidgetHoveredSliceIndexComponentState.atomFamily({ instanceId }),
);
return isDefined(hoveredSliceIndex) && hoveredSliceIndex === familyKey;
},
});
@@ -0,0 +1 @@
export type BarChartDatum = Record<string, string | number>;
@@ -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<BarDatum>[];
bars: BarPosition[];
sliceLeft: number;
sliceRight: number;
sliceCenter: number;
@@ -0,0 +1,7 @@
import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice';
export type BarChartSliceHoverData = {
slice: BarChartSlice;
offsetLeft: number;
offsetTop: number;
};
@@ -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;
};
@@ -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 },
@@ -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 },
@@ -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');
});
});
});
@@ -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> = {}): 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<BarDatum> =>
({
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<BarDatum>;
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);
@@ -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<BarDatum> =>
({
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<BarDatum>;
): 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<BarDatum>[] = [
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<BarDatum>[] = [
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<BarDatum>[] = [
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<BarDatum>[] = [
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<BarDatum>[] = [
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<BarDatum>[] = [
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<BarDatum>[] = [
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<BarDatum>[] = [
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<BarDatum>[] = [
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<BarDatum>[] = [
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<BarDatum>[] = [
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);
});
});
@@ -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<string, BarChartEnrichedKey>([
[
'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);
});
});
@@ -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> = {}): 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);
});
});
});
@@ -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> = {},
): 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);
});
});
@@ -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> = {},
): 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);
});
});
});
@@ -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> = {},
): 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);
});
});
@@ -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<string, BarChartEnrichedKey>([
[
'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<BarDatum> = {
id: 'sales',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const result = getBarChartColor(datum, mockEnrichedKeysMap, mockTheme);
expect(result).toBe('#solidBlue');
});
it('should return different colors for different keys', () => {
const salesDatum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const revenueDatum: ComputedDatum<BarDatum> = {
id: 'revenue',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
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<BarDatum> = {
id: 'unknown',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const result = getBarChartColor(datum, mockEnrichedKeysMap, mockTheme);
expect(result).toBe('#fallback');
});
it('should return fallback color when enrichedKeysMap is empty', () => {
const datum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const result = getBarChartColor(
datum,
new Map<string, BarChartEnrichedKey>(),
mockTheme,
);
expect(result).toBe('#fallback');
});
it('should return same color for same key regardless of indexValue', () => {
const januaryDatum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const februaryDatum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'February',
} as unknown as ComputedDatum<BarDatum>;
const januaryColor = getBarChartColor(
januaryDatum,
mockEnrichedKeysMap,
mockTheme,
);
const februaryColor = getBarChartColor(
februaryDatum,
mockEnrichedKeysMap,
mockTheme,
);
expect(januaryColor).toBe('#solidBlue');
expect(februaryColor).toBe('#solidBlue');
});
});
@@ -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 },
@@ -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> = {},
): 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,
});
});
});
@@ -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> = {},
): 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,
});
});
});
@@ -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);
});
});
});
@@ -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> = {}): 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([]);
});
});
});
@@ -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<string, BarChartEnrichedKey>;
shouldRoundFreeEndMap: Map<string, boolean> | 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;
};
@@ -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[] = [];
@@ -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[] = [];
@@ -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<string, BarPosition[]>();
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;
};
@@ -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;
@@ -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<BarDatum>[],
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,
@@ -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<BarDatum>[],
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<BarDatum>[];
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,
};
@@ -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),
};
};
@@ -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<string, BarChartEnrichedKey>;
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,
});
};
@@ -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<string, BarChartEnrichedKey>;
innerWidth: number;
innerHeight: number;
layout: BarChartLayout;
groupMode: 'grouped' | 'stacked';
valueDomain: { min: number; max: number };
innerPadding: number;
shouldRoundFreeEndMap: Map<string, boolean> | 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,
});
};
@@ -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,
};
};
@@ -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<string, BarChartEnrichedKey>;
innerPadding: number;
shouldRoundFreeEndMap: Map<string, boolean> | 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,
}),
});
};
@@ -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<string, boolean> | null => {
if (
groupMode !== 'stacked' ||
!orderedKeys?.length ||
!data?.length ||
!indexBy
) {
}: {
data: BarChartDatum[];
keys: string[];
indexBy: string;
groupMode?: 'grouped' | 'stacked';
}): Map<string, boolean> | 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 (
@@ -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,
};
};
@@ -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<BarDatum>[];
isVerticalLayout: boolean;
};
export const computeSlicesFromBars = ({
bars,
isVerticalLayout,
}: ComputeSlicesFromBarsParams): BarChartSlice[] => {
if (bars.length === 0) {
return [];
}
const groupedBarsByIndex = new Map<string, ComputedBarDatum<BarDatum>[]>();
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;
};
@@ -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<string, BarChartEnrichedKey>;
valueDomain: { min: number; max: number };
shouldRoundFreeEndMap: Map<string, boolean> | 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,
},
};
},
});
};
@@ -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();
};
@@ -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<BarDatum>[],
isVerticalLayout: boolean,
): ComputedBarDatum<BarDatum> => {
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;
});
};
@@ -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<SVGRectElement>;
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;
};
@@ -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,
),
},
@@ -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<BarDatum>,
enrichedKeysMap: Map<string, BarChartEnrichedKey>,
theme: ThemeType,
) => {
const enrichedKey = enrichedKeysMap.get(String(datum.id));
if (!enrichedKey) {
return theme.border.color.light;
}
return enrichedKey.colorScheme.solid;
};
@@ -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;
@@ -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 };
};
@@ -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;
@@ -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<string, BarChartDatum>;
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,
@@ -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,
};
};
@@ -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<HTMLDivElement>;
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,
});
};
@@ -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 };
};
@@ -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,
};
};
@@ -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;
};
@@ -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;
};
@@ -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;
};
@@ -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,
);
}
};
-34
View File
@@ -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"