[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:
@@ -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",
|
||||
|
||||
+6
-2
@@ -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,
|
||||
});
|
||||
+6
@@ -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;
|
||||
+36
@@ -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>
|
||||
);
|
||||
};
|
||||
+159
@@ -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>
|
||||
);
|
||||
};
|
||||
+51
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+43
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+28
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+11
@@ -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;
|
||||
};
|
||||
+41
@@ -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);
|
||||
});
|
||||
});
|
||||
+127
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+129
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+41
@@ -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);
|
||||
});
|
||||
});
|
||||
+16
@@ -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 });
|
||||
});
|
||||
});
|
||||
+43
@@ -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);
|
||||
});
|
||||
});
|
||||
+37
@@ -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 };
|
||||
};
|
||||
+46
@@ -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;
|
||||
};
|
||||
+41
@@ -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;
|
||||
};
|
||||
+48
@@ -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,
|
||||
});
|
||||
};
|
||||
+24
@@ -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 };
|
||||
};
|
||||
+127
@@ -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,
|
||||
};
|
||||
};
|
||||
+23
@@ -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,
|
||||
};
|
||||
};
|
||||
+13
@@ -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,
|
||||
};
|
||||
};
|
||||
+13
@@ -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;
|
||||
};
|
||||
+53
@@ -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([]);
|
||||
};
|
||||
+246
@@ -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>
|
||||
);
|
||||
};
|
||||
+61
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+267
@@ -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;
|
||||
};
|
||||
+45
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+94
@@ -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;
|
||||
};
|
||||
+148
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+7
-3
@@ -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,
|
||||
});
|
||||
+40
-22
@@ -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>
|
||||
);
|
||||
};
|
||||
-187
@@ -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>
|
||||
);
|
||||
};
|
||||
-170
@@ -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>
|
||||
);
|
||||
};
|
||||
+90
-247
@@ -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>
|
||||
);
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+160
@@ -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,
|
||||
};
|
||||
};
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
+25
-13
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+74
@@ -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,
|
||||
]);
|
||||
};
|
||||
+7
-10
@@ -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,
|
||||
});
|
||||
|
||||
-19
@@ -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;
|
||||
},
|
||||
});
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type BarChartDatum = Record<string, string | number>;
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice';
|
||||
|
||||
export type BarChartSliceHoverData = {
|
||||
slice: BarChartSlice;
|
||||
offsetLeft: number;
|
||||
offsetTop: number;
|
||||
};
|
||||
+12
@@ -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;
|
||||
};
|
||||
+5
-5
@@ -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 },
|
||||
|
||||
+5
-5
@@ -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 },
|
||||
|
||||
+264
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+27
-78
@@ -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);
|
||||
|
||||
|
||||
+31
-53
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+145
@@ -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);
|
||||
});
|
||||
});
|
||||
+133
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+54
@@ -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);
|
||||
});
|
||||
});
|
||||
+138
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+31
@@ -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);
|
||||
});
|
||||
});
|
||||
-161
@@ -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
-2
@@ -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 },
|
||||
|
||||
+62
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+117
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+84
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+163
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+97
@@ -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;
|
||||
};
|
||||
+2
-2
@@ -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[] = [];
|
||||
|
||||
+2
-2
@@ -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[] = [];
|
||||
|
||||
+82
@@ -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;
|
||||
};
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+4
-4
@@ -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,
|
||||
|
||||
+14
-6
@@ -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,
|
||||
};
|
||||
|
||||
+73
@@ -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),
|
||||
};
|
||||
};
|
||||
+70
@@ -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,
|
||||
});
|
||||
};
|
||||
+74
@@ -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,
|
||||
});
|
||||
};
|
||||
+33
@@ -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,
|
||||
};
|
||||
};
|
||||
+52
@@ -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,
|
||||
}),
|
||||
});
|
||||
};
|
||||
+12
-18
@@ -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 (
|
||||
|
||||
+45
@@ -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,
|
||||
};
|
||||
};
|
||||
-54
@@ -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;
|
||||
};
|
||||
+60
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
+103
@@ -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();
|
||||
};
|
||||
+18
-10
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
+11
-43
@@ -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;
|
||||
};
|
||||
|
||||
+4
-4
@@ -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,
|
||||
),
|
||||
},
|
||||
|
||||
-15
@@ -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;
|
||||
};
|
||||
+7
-3
@@ -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;
|
||||
|
||||
|
||||
+8
-3
@@ -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 };
|
||||
};
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+7
-3
@@ -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,
|
||||
|
||||
+77
@@ -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,
|
||||
};
|
||||
};
|
||||
+39
@@ -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,
|
||||
});
|
||||
};
|
||||
+60
@@ -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 };
|
||||
};
|
||||
+118
@@ -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,
|
||||
};
|
||||
};
|
||||
+17
@@ -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;
|
||||
};
|
||||
+55
@@ -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;
|
||||
};
|
||||
+62
@@ -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;
|
||||
};
|
||||
+39
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user