diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/CommonChartConstants.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/CommonChartConstants.ts index 495f62be8e..b2d306c8a1 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/CommonChartConstants.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/CommonChartConstants.ts @@ -1,24 +1,22 @@ export const COMMON_CHART_CONSTANTS = { MINIMUM_WIDTH_PER_TICK_ROTATED: 20, ROTATED_LABELS_EXTRA_BOTTOM_MARGIN: 20, - MARGIN_TOP: 20, - MARGIN_RIGHT: 20, - MARGIN_LEFT_WITHOUT_LABEL: 65, - MARGIN_LEFT_WITH_LABEL: 80, - MARGIN_BOTTOM_WITHOUT_LABEL: 50, - MARGIN_BOTTOM_WITH_LABEL: 65, TICK_PADDING: 5, TICK_SIZE: 0, AXIS_FONT_SIZE: 11, LEFT_AXIS_LEGEND_OFFSET_PADDING: 5, BOTTOM_AXIS_LEGEND_OFFSET: 55, TICK_MINIMUM_NUMBER_OF_DISPLAYED_CHARACTERS: 10, + ROTATED_TICK_MINIMUM_NUMBER_OF_DISPLAYED_CHARACTERS: 12, HORIZONTAL_LABEL_CHARACTER_WIDTH_RATIO: 0.6, ROTATED_LABEL_CHARACTER_WIDTH_RATIO: 0.5, + ROTATED_TICK_LABEL_WIDTH_ESTIMATE_RATIO: 0.5, + TICK_LABEL_LINE_HEIGHT_RATIO: 1.4, TICK_ROTATION_ANGLE: -45, TICK_PADDING_ALLOWANCE: 10, MIN_TICK_LABEL_LENGTH: 5, POSITIVE_RANGE_PADDING_RATIO: 0.1, MINIMUM_POSITIVE_RANGE_PADDING: 1, + LEGEND_OFFSET_MARGIN_BUFFER: 4, NO_ROTATION_ANGLE: 0, } as const; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/TextMarginExtras.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/TextMarginExtras.ts new file mode 100644 index 0000000000..e533276a65 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/TextMarginExtras.ts @@ -0,0 +1,4 @@ +export const TEXT_MARGIN_EXTRAS = { + tickPaddingExtra: 4, + bottomTickExtraNonRotated: 20, +} as const; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/TextMarginLimits.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/TextMarginLimits.ts new file mode 100644 index 0000000000..109dd4176f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/TextMarginLimits.ts @@ -0,0 +1,4 @@ +export const TEXT_MARGIN_LIMITS = { + min: { top: 10, right: 10, bottom: 30, left: 40 }, + max: { top: 30, right: 30, bottom: 140, left: 160 }, +} as const; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx index 9cebce6c7f..0322cfa4e6 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx @@ -14,13 +14,11 @@ import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graphW import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; import { calculateStackedBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange'; import { calculateValueRangeFromBarChartKeys } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys'; -import { getBarChartAxisConfigs } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs'; import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor'; import { getBarChartInnerPadding } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartInnerPadding'; -import { getBarChartTickConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig'; +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 { computeValueTickValues } from '@/page-layout/widgets/graph/utils/computeValueTickValues'; import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry'; import { formatGraphValue, @@ -165,24 +163,26 @@ export const GraphWidgetBarChart = ({ rangeMax, }); - const tickConfig = getBarChartTickConfig({ - width: chartWidth, - height: chartHeight, + const { + margins, + axisBottomConfiguration, + axisLeftConfiguration, + valueTickValues, + valueDomain, + } = getBarChartLayout({ + axisTheme: chartTheme.axis, + chartWidth, + chartHeight, data, indexBy, + layout, xAxisLabel, yAxisLabel, - axisFontSize: chartTheme.axis.ticks.text.fontSize, - layout, + formatOptions, + effectiveMinimumValue, + effectiveMaximumValue, }); - const { tickValues: valueTickValues, domain: valueDomain } = - computeValueTickValues({ - minimum: effectiveMinimumValue, - maximum: effectiveMaximumValue, - tickCount: tickConfig.numberOfValueTicks, - }); - const hasClickableItems = isDefined(onSliceClick); const hideTooltip = () => { @@ -222,19 +222,6 @@ export const GraphWidgetBarChart = ({ debouncedHideTooltip(); }; - const { - axisBottom: axisBottomConfig, - axisLeft: axisLeftConfig, - margins, - } = getBarChartAxisConfigs({ - layout, - xAxisLabel, - yAxisLabel, - formatOptions, - valueTickValues, - tickConfig, - }); - const BarItemWithContext = useMemo( () => (props: BarItemProps) => ( { const theme = useTheme(); + const tickFontSize = 11; + const legendFontSize = parseFontSizeToPx(theme.font.size.sm, tickFontSize); return { axis: { @@ -18,13 +21,13 @@ export const useBarChartTheme = () => { }, text: { fill: theme.font.color.secondary, - fontSize: 11, + fontSize: tickFontSize, }, }, legend: { text: { fill: theme.font.color.primary, - fontSize: theme.font.size.sm, + fontSize: legendFontSize, fontWeight: theme.font.weight.medium, }, }, @@ -38,7 +41,7 @@ export const useBarChartTheme = () => { }, labels: { text: { - fontSize: 11, + fontSize: tickFontSize, fontWeight: theme.font.weight.medium, }, }, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateWidthPerTick.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateWidthPerTick.test.ts deleted file mode 100644 index 18f86dbeda..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateWidthPerTick.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { calculateWidthPerTick } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateWidthPerTick'; -import { BarChartLayout } from '~/generated/graphql'; - -describe('calculateWidthPerTick', () => { - describe('vertical layout', () => { - it('should calculate width per tick based on categoryTickCount for vertical layout', () => { - const result = calculateWidthPerTick({ - layout: BarChartLayout.VERTICAL, - availableWidth: 500, - categoryTickCount: 10, - valueTickCount: 5, - }); - - expect(result).toBe(50); - }); - - it('should return 0 when categoryTickCount is 0 for vertical layout', () => { - const result = calculateWidthPerTick({ - layout: BarChartLayout.VERTICAL, - availableWidth: 500, - categoryTickCount: 0, - valueTickCount: 5, - }); - - expect(result).toBe(0); - }); - - it('should handle small available width', () => { - const result = calculateWidthPerTick({ - layout: BarChartLayout.VERTICAL, - availableWidth: 100, - categoryTickCount: 20, - valueTickCount: 5, - }); - - expect(result).toBe(5); - }); - }); - - describe('horizontal layout', () => { - it('should calculate width per tick based on valueTickCount for horizontal layout', () => { - const result = calculateWidthPerTick({ - layout: BarChartLayout.HORIZONTAL, - availableWidth: 600, - categoryTickCount: 10, - valueTickCount: 6, - }); - - expect(result).toBe(100); - }); - - it('should return 0 when valueTickCount is 0 for horizontal layout', () => { - const result = calculateWidthPerTick({ - layout: BarChartLayout.HORIZONTAL, - availableWidth: 500, - categoryTickCount: 10, - valueTickCount: 0, - }); - - expect(result).toBe(0); - }); - - it('should handle decimal results', () => { - const result = calculateWidthPerTick({ - layout: BarChartLayout.HORIZONTAL, - availableWidth: 100, - categoryTickCount: 10, - valueTickCount: 3, - }); - - expect(result).toBeCloseTo(33.33, 1); - }); - }); - - describe('edge cases', () => { - it('should handle availableWidth of 0', () => { - const result = calculateWidthPerTick({ - layout: BarChartLayout.VERTICAL, - availableWidth: 0, - categoryTickCount: 10, - valueTickCount: 5, - }); - - expect(result).toBe(0); - }); - - it('should handle single tick', () => { - const result = calculateWidthPerTick({ - layout: BarChartLayout.VERTICAL, - availableWidth: 500, - categoryTickCount: 1, - valueTickCount: 5, - }); - - expect(result).toBe(500); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartLayout.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartLayout.test.ts new file mode 100644 index 0000000000..091c99ad38 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartLayout.test.ts @@ -0,0 +1,179 @@ +import { TEXT_MARGIN_LIMITS } from '@/page-layout/widgets/graph/constants/TextMarginLimits'; +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 { BarChartLayout } from '~/generated/graphql'; + +describe('getBarChartLayout', () => { + const defaultAxisTheme: ChartAxisTheme = { + ticks: { text: { fontSize: 11 } }, + legend: { text: { fontSize: 11 } }, + }; + + const defaultFormatOptions: GraphValueFormatOptions = { + displayType: 'number', + }; + + const defaultData: BarDatum[] = [ + { category: 'A', value: 10 }, + { category: 'B', value: 20 }, + { category: 'C', value: 30 }, + ]; + + const baseParams = { + axisTheme: defaultAxisTheme, + chartWidth: 500, + chartHeight: 300, + data: defaultData, + indexBy: 'category', + xAxisLabel: 'Categories', + yAxisLabel: 'Values', + formatOptions: defaultFormatOptions, + effectiveMinimumValue: 0, + effectiveMaximumValue: 30, + }; + + describe('vertical layout', () => { + it('produces correct axis configurations', () => { + const result = getBarChartLayout({ + ...baseParams, + layout: BarChartLayout.VERTICAL, + }); + + expect(result.axisBottomConfiguration).toBeDefined(); + expect(result.axisLeftConfiguration).toBeDefined(); + expect(result.valueTickValues).toBeDefined(); + expect(result.valueDomain).toBeDefined(); + }); + + it('returns margins within TEXT_MARGIN_LIMITS bounds', () => { + const result = getBarChartLayout({ + ...baseParams, + layout: BarChartLayout.VERTICAL, + }); + + expect(result.margins.top).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.top, + ); + expect(result.margins.top).toBeLessThanOrEqual( + TEXT_MARGIN_LIMITS.max.top, + ); + expect(result.margins.right).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.right, + ); + expect(result.margins.right).toBeLessThanOrEqual( + TEXT_MARGIN_LIMITS.max.right, + ); + expect(result.margins.bottom).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.bottom, + ); + expect(result.margins.bottom).toBeLessThanOrEqual( + TEXT_MARGIN_LIMITS.max.bottom, + ); + expect(result.margins.left).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.left, + ); + expect(result.margins.left).toBeLessThanOrEqual( + TEXT_MARGIN_LIMITS.max.left, + ); + }); + }); + + describe('horizontal layout', () => { + it('produces correct axis configurations', () => { + const result = getBarChartLayout({ + ...baseParams, + layout: BarChartLayout.HORIZONTAL, + }); + + expect(result.axisBottomConfiguration).toBeDefined(); + expect(result.axisLeftConfiguration).toBeDefined(); + expect(result.valueTickValues).toBeDefined(); + expect(result.valueDomain).toBeDefined(); + }); + + it('returns margins within TEXT_MARGIN_LIMITS bounds', () => { + const result = getBarChartLayout({ + ...baseParams, + layout: BarChartLayout.HORIZONTAL, + }); + + expect(result.margins.top).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.top, + ); + expect(result.margins.top).toBeLessThanOrEqual( + TEXT_MARGIN_LIMITS.max.top, + ); + expect(result.margins.right).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.right, + ); + expect(result.margins.right).toBeLessThanOrEqual( + TEXT_MARGIN_LIMITS.max.right, + ); + expect(result.margins.bottom).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.bottom, + ); + expect(result.margins.bottom).toBeLessThanOrEqual( + TEXT_MARGIN_LIMITS.max.bottom, + ); + expect(result.margins.left).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.left, + ); + expect(result.margins.left).toBeLessThanOrEqual( + TEXT_MARGIN_LIMITS.max.left, + ); + }); + }); + + describe('edge cases', () => { + it('handles empty data array', () => { + const result = getBarChartLayout({ + ...baseParams, + layout: BarChartLayout.VERTICAL, + data: [], + }); + + expect(result.margins).toBeDefined(); + expect(result.axisBottomConfiguration).toBeDefined(); + expect(result.axisLeftConfiguration).toBeDefined(); + }); + + it('handles large value ranges with negative min and positive max', () => { + const result = getBarChartLayout({ + ...baseParams, + layout: BarChartLayout.VERTICAL, + effectiveMinimumValue: -1000, + effectiveMaximumValue: 5000, + }); + + expect(result.valueDomain.min).toBeLessThanOrEqual(-1000); + expect(result.valueDomain.max).toBeGreaterThanOrEqual(5000); + expect(result.valueTickValues.length).toBeGreaterThan(0); + }); + + it('handles single data point', () => { + const result = getBarChartLayout({ + ...baseParams, + layout: BarChartLayout.VERTICAL, + data: [{ category: 'Single', value: 50 }], + }); + + expect(result.margins).toBeDefined(); + expect(result.valueTickValues.length).toBeGreaterThan(0); + }); + + it('handles missing axis labels', () => { + const result = getBarChartLayout({ + ...baseParams, + layout: BarChartLayout.VERTICAL, + xAxisLabel: undefined, + yAxisLabel: undefined, + }); + + expect(result.margins).toBeDefined(); + expect(result.axisBottomConfiguration).toBeDefined(); + expect(result.axisLeftConfiguration).toBeDefined(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateWidthPerTick.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateWidthPerTick.ts deleted file mode 100644 index fc6f20dff7..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateWidthPerTick.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { BarChartLayout } from '~/generated/graphql'; - -export const calculateWidthPerTick = ({ - layout, - availableWidth, - categoryTickCount, - valueTickCount, -}: { - layout: BarChartLayout; - availableWidth: number; - categoryTickCount: number; - valueTickCount: number; -}): number => { - if (layout === BarChartLayout.VERTICAL) { - return categoryTickCount > 0 ? availableWidth / categoryTickCount : 0; - } - - return valueTickCount > 0 ? availableWidth / valueTickCount : 0; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts index edc1a8b94a..76c95a39fc 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts @@ -1,7 +1,7 @@ import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; import { computeMinHeightPerTick } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeMinHeightPerTick'; -import { getBarChartMargins } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins'; 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'; @@ -10,8 +10,7 @@ export const computeBarChartCategoryTickValues = ({ axisFontSize, data, indexBy, - xAxisLabel, - yAxisLabel, + margins, layout, }: { axisSize: number; @@ -19,8 +18,7 @@ export const computeBarChartCategoryTickValues = ({ data: BarDatum[]; indexBy: string; layout: BarChartLayout; - xAxisLabel?: string; - yAxisLabel?: string; + margins: ChartMargins; }): (string | number)[] => { if (axisSize === 0 || data.length === 0) { return []; @@ -28,8 +26,6 @@ export const computeBarChartCategoryTickValues = ({ const values = data.map((item) => item[indexBy] as string | number); - const margins = getBarChartMargins({ xAxisLabel, yAxisLabel }); - const totalMargins = layout === BarChartLayout.VERTICAL ? margins.left + margins.right diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs.ts index 3513eb0d5b..832c0fedc8 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs.ts @@ -1,4 +1,3 @@ -import { getBarChartMargins } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins'; import { truncateTickLabel } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/truncateTickLabel'; import { formatGraphValue, @@ -8,6 +7,7 @@ import { 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 BarChartTickConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; import { BarChartLayout } from '~/generated/graphql'; type GetBarChartAxisConfigsProps = { @@ -16,7 +16,8 @@ type GetBarChartAxisConfigsProps = { yAxisLabel?: string; formatOptions?: GraphValueFormatOptions; valueTickValues?: number[]; - tickConfig: BarChartTickConfig; + tickConfiguration: BarChartTickConfig; + margins: ChartMargins; }; export const getBarChartAxisConfigs = ({ @@ -25,7 +26,8 @@ export const getBarChartAxisConfigs = ({ yAxisLabel, formatOptions, valueTickValues, - tickConfig, + tickConfiguration, + margins, }: GetBarChartAxisConfigsProps) => { const { categoryTickValues, @@ -33,25 +35,24 @@ export const getBarChartAxisConfigs = ({ maxBottomAxisTickLabelLength, maxLeftAxisTickLabelLength, bottomAxisTickRotation, - } = tickConfig; + } = tickConfiguration; const resolvedValueTickValues = valueTickValues && valueTickValues.length > 0 ? valueTickValues : numberOfValueTicks; - const baseMargins = getBarChartMargins({ xAxisLabel, yAxisLabel }); - const hasRotation = bottomAxisTickRotation !== 0; - const margins = - layout === BarChartLayout.VERTICAL && hasRotation - ? { - ...baseMargins, - bottom: - baseMargins.bottom + - COMMON_CHART_CONSTANTS.ROTATED_LABELS_EXTRA_BOTTOM_MARGIN, - } - : baseMargins; + const baseBottomLegendOffset = + BAR_CHART_CONSTANTS.BOTTOM_AXIS_LEGEND_OFFSET + + (hasRotation ? BAR_CHART_CONSTANTS.ROTATED_LABELS_EXTRA_BOTTOM_MARGIN : 0); + const bottomLegendOffset = Math.min( + baseBottomLegendOffset, + Math.max( + margins.bottom - COMMON_CHART_CONSTANTS.LEGEND_OFFSET_MARGIN_BUFFER, + 0, + ), + ); if (layout === BarChartLayout.VERTICAL) { return { @@ -62,11 +63,7 @@ export const getBarChartAxisConfigs = ({ tickValues: categoryTickValues, tickRotation: bottomAxisTickRotation, legend: xAxisLabel, - legendOffset: - BAR_CHART_CONSTANTS.BOTTOM_AXIS_LEGEND_OFFSET + - (hasRotation - ? BAR_CHART_CONSTANTS.ROTATED_LABELS_EXTRA_BOTTOM_MARGIN - : 0), + legendOffset: bottomLegendOffset, format: (value: string | number) => truncateTickLabel(String(value), maxBottomAxisTickLabelLength), }, @@ -97,7 +94,13 @@ export const getBarChartAxisConfigs = ({ tickRotation: BAR_CHART_CONSTANTS.NO_ROTATION_ANGLE, tickValues: resolvedValueTickValues, legend: xAxisLabel, - legendOffset: BAR_CHART_CONSTANTS.BOTTOM_AXIS_LEGEND_OFFSET, + legendOffset: Math.min( + BAR_CHART_CONSTANTS.BOTTOM_AXIS_LEGEND_OFFSET, + Math.max( + margins.bottom - COMMON_CHART_CONSTANTS.LEGEND_OFFSET_MARGIN_BUFFER, + 0, + ), + ), format: (value: number) => truncateTickLabel( formatGraphValue(value, formatOptions ?? {}), diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartLayout.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartLayout.ts new file mode 100644 index 0000000000..82aee1f05f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartLayout.ts @@ -0,0 +1,196 @@ +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; +import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { getBarChartAxisConfigs } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs'; +import { + getBarChartTickConfig, + type BarChartTickConfig, +} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig'; +import { truncateTickLabel } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/truncateTickLabel'; +import { type ChartAxisTheme } from '@/page-layout/widgets/graph/types/ChartAxisTheme'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import { computeBottomLegendOffsetFromText } from '@/page-layout/widgets/graph/utils/computeBottomLegendOffsetFromText'; +import { computeChartMargins } from '@/page-layout/widgets/graph/utils/computeChartMargins'; +import { computeValueTickValues } from '@/page-layout/widgets/graph/utils/computeValueTickValues'; +import { + formatGraphValue, + 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[]; + indexBy: string; + layout: BarChartLayout; + xAxisLabel?: string; + yAxisLabel?: string; + formatOptions: GraphValueFormatOptions; + effectiveMinimumValue: number; + effectiveMaximumValue: number; +}; + +type BarChartLayoutResult = { + margins: ChartMargins; + axisBottomConfiguration: ReturnType< + typeof getBarChartAxisConfigs + >['axisBottom']; + axisLeftConfiguration: ReturnType['axisLeft']; + valueTickValues: number[]; + valueDomain: { min: number; max: number }; +}; + +const resolveMarginInputs = ({ + tickConfiguration, + tickResult, + layout, + formatOptions, +}: { + tickConfiguration: BarChartTickConfig; + tickResult: ReturnType; + layout: BarChartLayout; + formatOptions: GraphValueFormatOptions; +}) => { + const formatCategoryTick = (value: string | number, maxLength: number) => + truncateTickLabel(String(value), maxLength); + const formatValueTick = (value: number, maxLength: number) => + truncateTickLabel(formatGraphValue(value, formatOptions), maxLength); + + const bottomTickLabels = + layout === BarChartLayout.VERTICAL + ? tickConfiguration.categoryTickValues.map((value) => + formatCategoryTick( + value, + tickConfiguration.maxBottomAxisTickLabelLength, + ), + ) + : tickResult.tickValues.map((value) => + formatValueTick( + value, + tickConfiguration.maxBottomAxisTickLabelLength, + ), + ); + + const leftTickLabels = + layout === BarChartLayout.VERTICAL + ? tickResult.tickValues.map((value) => + formatGraphValue(value, formatOptions), + ) + : tickConfiguration.categoryTickValues.map((value) => String(value)); + + return { bottomTickLabels, leftTickLabels }; +}; + +const resolveAxisBottomConfiguration = ({ + axisBottomConfigurationBase, + margins, + bottomLegendOffset, +}: { + axisBottomConfigurationBase: ReturnType< + typeof getBarChartAxisConfigs + >['axisBottom']; + margins: ChartMargins; + bottomLegendOffset?: number; +}) => { + const resolvedLegendOffset = + bottomLegendOffset ?? axisBottomConfigurationBase.legendOffset; + + return { + ...axisBottomConfigurationBase, + legendOffset: Math.min( + resolvedLegendOffset, + Math.max( + margins.bottom - COMMON_CHART_CONSTANTS.LEGEND_OFFSET_MARGIN_BUFFER, + 0, + ), + ), + }; +}; + +export const getBarChartLayout = ({ + axisTheme, + chartWidth, + chartHeight, + data, + indexBy, + layout, + xAxisLabel, + yAxisLabel, + formatOptions, + effectiveMinimumValue, + effectiveMaximumValue, +}: GetBarChartLayoutParams): BarChartLayoutResult => { + const { tickFontSize, legendFontSize } = resolveAxisFontSizes(axisTheme); + + const { margins, tickConfiguration, valueTickResult, bottomLegendOffset } = + computeChartMargins({ + tickFontSize, + legendFontSize, + xAxisLabel, + yAxisLabel, + initialTickRotation: BAR_CHART_CONSTANTS.NO_ROTATION_ANGLE, + computeTickConfig: (currentMargins) => + getBarChartTickConfig({ + width: chartWidth, + height: chartHeight, + data, + indexBy, + axisFontSize: tickFontSize, + layout, + margins: currentMargins, + }), + computeValueTickValues: (currentTickConfiguration) => + computeValueTickValues({ + minimum: effectiveMinimumValue, + maximum: effectiveMaximumValue, + tickCount: currentTickConfiguration.numberOfValueTicks, + }), + getTickRotation: (currentTickConfiguration) => + currentTickConfiguration.bottomAxisTickRotation, + getBottomLegendOffset: (parameters) => + computeBottomLegendOffsetFromText({ + tickLabels: parameters.marginInputs.bottomTickLabels, + tickFontSize, + tickRotation: parameters.tickConfiguration.bottomAxisTickRotation, + }), + resolveMarginInputs: (currentTickConfiguration, tickResult) => + resolveMarginInputs({ + tickConfiguration: currentTickConfiguration, + tickResult, + layout, + formatOptions, + }), + }); + + const { tickValues: valueTickValues, domain: valueDomain } = valueTickResult; + + const { + axisBottom: axisBottomConfigurationBase, + axisLeft: axisLeftConfiguration, + } = getBarChartAxisConfigs({ + layout, + xAxisLabel, + yAxisLabel, + formatOptions, + valueTickValues, + tickConfiguration, + margins, + }); + + const axisBottomConfiguration = resolveAxisBottomConfiguration({ + axisBottomConfigurationBase, + margins, + bottomLegendOffset, + }); + + return { + margins, + axisBottomConfiguration, + axisLeftConfiguration, + valueTickValues, + valueDomain, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins.ts deleted file mode 100644 index 6764bfbf33..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; -import { isDefined } from 'twenty-shared/utils'; - -const BAR_CHART_MARGINS = { - top: COMMON_CHART_CONSTANTS.MARGIN_TOP, - right: COMMON_CHART_CONSTANTS.MARGIN_RIGHT, - bottom: COMMON_CHART_CONSTANTS.MARGIN_BOTTOM_WITHOUT_LABEL, - left: COMMON_CHART_CONSTANTS.MARGIN_LEFT_WITHOUT_LABEL, -} as const; - -const BAR_CHART_MARGINS_WITH_BOTH_LABELS = { - top: COMMON_CHART_CONSTANTS.MARGIN_TOP, - right: COMMON_CHART_CONSTANTS.MARGIN_RIGHT, - bottom: COMMON_CHART_CONSTANTS.MARGIN_BOTTOM_WITH_LABEL, - left: COMMON_CHART_CONSTANTS.MARGIN_LEFT_WITH_LABEL, -} as const; - -const BAR_CHART_MARGINS_WITH_X_LABEL = { - top: COMMON_CHART_CONSTANTS.MARGIN_TOP, - right: COMMON_CHART_CONSTANTS.MARGIN_RIGHT, - bottom: COMMON_CHART_CONSTANTS.MARGIN_BOTTOM_WITH_LABEL, - left: COMMON_CHART_CONSTANTS.MARGIN_LEFT_WITHOUT_LABEL, -} as const; - -const BAR_CHART_MARGINS_WITH_Y_LABEL = { - top: COMMON_CHART_CONSTANTS.MARGIN_TOP, - right: COMMON_CHART_CONSTANTS.MARGIN_RIGHT, - bottom: COMMON_CHART_CONSTANTS.MARGIN_BOTTOM_WITHOUT_LABEL, - left: COMMON_CHART_CONSTANTS.MARGIN_LEFT_WITH_LABEL, -} as const; - -export const getBarChartMargins = ({ - xAxisLabel, - yAxisLabel, -}: { - xAxisLabel?: string; - yAxisLabel?: string; -}) => { - if (isDefined(xAxisLabel) && isDefined(yAxisLabel)) { - return BAR_CHART_MARGINS_WITH_BOTH_LABELS; - } - - if (isDefined(xAxisLabel)) { - return BAR_CHART_MARGINS_WITH_X_LABEL; - } - - if (isDefined(yAxisLabel)) { - return BAR_CHART_MARGINS_WITH_Y_LABEL; - } - - return BAR_CHART_MARGINS; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig.ts index 003cdf597c..b1628f990f 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig.ts @@ -1,8 +1,7 @@ import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; -import { calculateWidthPerTick } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateWidthPerTick'; import { computeBarChartCategoryTickValues } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues'; import { computeBarChartValueTickCount } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartValueTickCount'; -import { getBarChartMargins } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins'; +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'; @@ -21,19 +20,17 @@ export const getBarChartTickConfig = ({ height, data, indexBy, - xAxisLabel, - yAxisLabel, axisFontSize, layout, + margins, }: { width: number; height: number; data: BarDatum[]; indexBy: string; - xAxisLabel?: string; - yAxisLabel?: string; axisFontSize: number; layout: BarChartLayout; + margins: ChartMargins; }): BarChartTickConfig => { const clampValueTickCount = (tickCount: number) => Math.min( @@ -46,13 +43,10 @@ export const getBarChartTickConfig = ({ axisFontSize, data, indexBy, - xAxisLabel, - yAxisLabel, + margins, layout, }); - const margins = getBarChartMargins({ xAxisLabel, yAxisLabel }); - const availableWidth = width - (margins.left + margins.right); const availableHeight = height - (margins.top + margins.bottom); @@ -65,23 +59,17 @@ export const getBarChartTickConfig = ({ }), ); - const widthPerTick = calculateWidthPerTick({ - layout, - availableWidth, - categoryTickCount: categoryTickValues.length, - valueTickCount: numberOfValueTicks, - }); - - const actualDataPointCount = data.length; - const widthPerDataPoint = - layout === BarChartLayout.VERTICAL && - actualDataPointCount > 0 && - availableWidth > 0 - ? availableWidth / actualDataPointCount - : widthPerTick; + const bottomTickCount = + layout === BarChartLayout.VERTICAL + ? categoryTickValues.length + : numberOfValueTicks; + const widthPerBottomTick = + bottomTickCount > 0 && availableWidth > 0 + ? availableWidth / bottomTickCount + : 0; const tickRotationConfig = getTickRotationConfig({ - widthPerTick: widthPerDataPoint, + widthPerTick: widthPerBottomTick, axisFontSize, }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx index 9fcc922ad8..728a7e8612 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx @@ -1,7 +1,6 @@ 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 { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; import { CustomCrosshairLayer, type SliceHoverData, @@ -10,18 +9,15 @@ import { CustomLinesLayer } from '@/page-layout/widgets/graph/graphWidgetLineCha import { CustomPointLabelsLayer } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/CustomPointLabelsLayer'; import { CustomStackedAreasLayer } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/CustomStackedAreasLayer'; import { GraphLineChartTooltip } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphLineChartTooltip'; -import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants'; import { useLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartData'; import { useLineChartTheme } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartTheme'; import { graphWidgetLineCrosshairXComponentState } from '@/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineCrosshairXComponentState'; import { graphWidgetLineTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineTooltipComponentState'; import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor'; import { calculateValueRangeFromLineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries'; -import { getLineChartAxisBottomConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig'; -import { getLineChartAxisLeftConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisLeftConfig'; +import { getLineChartLayout } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartLayout'; import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; import { computeEffectiveValueRange } from '@/page-layout/widgets/graph/utils/computeEffectiveValueRange'; -import { computeValueTickValues } from '@/page-layout/widgets/graph/utils/computeValueTickValues'; import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry'; import { formatGraphValue, @@ -164,6 +160,23 @@ export const GraphWidgetLineChart = ({ debouncedHideTooltip(); }; + const { + margins, + axisBottomConfiguration, + axisLeftConfiguration, + valueTickValues, + valueDomain, + } = getLineChartLayout({ + axisTheme: chartTheme.axis, + chartWidth, + data, + xAxisLabel, + yAxisLabel, + formatOptions, + effectiveMinimumValue, + effectiveMaximumValue, + }); + const handleSliceEnter = (sliceData: SliceHoverData) => { const slice: SliceTooltipProps['slice'] = { id: String(sliceData.nearestSlice.xValue ?? ''), @@ -176,8 +189,8 @@ export const GraphWidgetLineChart = ({ points: sliceData.nearestSlice.points, }; - const offsetLeft = sliceData.nearestSlice.x + marginLeft; - const offsetTop = sliceData.mouseY + COMMON_CHART_CONSTANTS.MARGIN_TOP; + const offsetLeft = sliceData.nearestSlice.x + margins.left; + const offsetTop = sliceData.mouseY + margins.top; debouncedHideTooltip.cancel(); setCrosshairX(sliceData.sliceX); @@ -217,8 +230,8 @@ export const GraphWidgetLineChart = ({ points={layerProps.points} innerHeight={layerProps.innerHeight} innerWidth={layerProps.innerWidth} - marginLeft={marginLeft} - marginTop={COMMON_CHART_CONSTANTS.MARGIN_TOP} + marginLeft={margins.left} + marginTop={margins.top} onSliceHover={handleSliceEnter} onSliceClick={ isDefined(onSliceClick) @@ -269,26 +282,6 @@ export const GraphWidgetLineChart = ({ /> ); - const marginLeft = isDefined(yAxisLabel) - ? COMMON_CHART_CONSTANTS.MARGIN_LEFT_WITH_LABEL - : COMMON_CHART_CONSTANTS.MARGIN_LEFT_WITHOUT_LABEL; - - const { config: axisBottomConfig, marginBottom } = - getLineChartAxisBottomConfig(xAxisLabel, chartWidth, data, marginLeft); - - const { tickValues: valueTickValues, domain: valueDomain } = - computeValueTickValues({ - minimum: effectiveMinimumValue, - maximum: effectiveMaximumValue, - tickCount: LINE_CHART_CONSTANTS.DEFAULT_TICK_COUNT, - }); - - const axisLeftConfig = getLineChartAxisLeftConfig( - yAxisLabel, - formatOptions, - valueTickValues, - ); - return ( { const theme = useTheme(); + const tickFontSize = 12; + const legendFontSize = parseFontSizeToPx(theme.font.size.sm, tickFontSize); return { axis: { @@ -18,13 +21,13 @@ export const useLineChartTheme = () => { }, text: { fill: theme.font.color.secondary, - fontSize: 12, + fontSize: tickFontSize, }, }, legend: { text: { fill: theme.font.color.primary, - fontSize: theme.font.size.sm, + fontSize: legendFontSize, fontWeight: theme.font.weight.medium, }, }, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/getLineChartLayout.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/getLineChartLayout.test.ts new file mode 100644 index 0000000000..96d308ce0c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/getLineChartLayout.test.ts @@ -0,0 +1,167 @@ +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; +import { TEXT_MARGIN_LIMITS } from '@/page-layout/widgets/graph/constants/TextMarginLimits'; +import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor'; +import { getLineChartLayout } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartLayout'; +import { type ChartAxisTheme } from '@/page-layout/widgets/graph/types/ChartAxisTheme'; +import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters'; + +describe('getLineChartLayout', () => { + const defaultAxisTheme: ChartAxisTheme = { + ticks: { text: { fontSize: 11 } }, + legend: { text: { fontSize: 11 } }, + }; + + const defaultFormatOptions: GraphValueFormatOptions = { + displayType: 'number', + }; + + const defaultData: LineChartSeriesWithColor[] = [ + { + id: 'series1', + label: 'Series 1', + data: [ + { x: '2024-01', y: 10 }, + { x: '2024-02', y: 20 }, + { x: '2024-03', y: 30 }, + ], + }, + ]; + + const baseParams = { + axisTheme: defaultAxisTheme, + chartWidth: 500, + data: defaultData, + xAxisLabel: 'Date', + yAxisLabel: 'Value', + formatOptions: defaultFormatOptions, + effectiveMinimumValue: 0, + effectiveMaximumValue: 30, + }; + + it('produces correct axis configurations', () => { + const result = getLineChartLayout(baseParams); + + expect(result.axisBottomConfiguration).toBeDefined(); + expect(result.axisLeftConfiguration).toBeDefined(); + expect(result.valueTickValues).toBeDefined(); + expect(result.valueDomain).toBeDefined(); + }); + + it('returns margins within TEXT_MARGIN_LIMITS bounds', () => { + const result = getLineChartLayout(baseParams); + + expect(result.margins.top).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.top, + ); + expect(result.margins.top).toBeLessThanOrEqual(TEXT_MARGIN_LIMITS.max.top); + expect(result.margins.right).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.right, + ); + expect(result.margins.right).toBeLessThanOrEqual( + TEXT_MARGIN_LIMITS.max.right, + ); + expect(result.margins.bottom).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.bottom, + ); + expect(result.margins.bottom).toBeLessThanOrEqual( + TEXT_MARGIN_LIMITS.max.bottom, + ); + expect(result.margins.left).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.left, + ); + expect(result.margins.left).toBeLessThanOrEqual( + TEXT_MARGIN_LIMITS.max.left, + ); + }); + + it('handles empty data array', () => { + const result = getLineChartLayout({ + ...baseParams, + data: [], + }); + + expect(result.margins).toBeDefined(); + expect(result.axisBottomConfiguration).toBeDefined(); + expect(result.axisLeftConfiguration).toBeDefined(); + }); + + it('handles single data point series', () => { + const result = getLineChartLayout({ + ...baseParams, + data: [ + { + id: 'series1', + label: 'Series 1', + data: [{ x: '2024-01', y: 50 }], + }, + ], + }); + + expect(result.margins).toBeDefined(); + expect(result.valueTickValues.length).toBeGreaterThan(0); + }); + + it('axisLeftConfiguration.legendOffset is correct relative to margins.left', () => { + const result = getLineChartLayout(baseParams); + + const legendOffset = result.axisLeftConfiguration.legendOffset; + + if (legendOffset !== undefined) { + expect(Math.abs(legendOffset)).toBeLessThanOrEqual( + result.margins.left - + COMMON_CHART_CONSTANTS.LEFT_AXIS_LEGEND_OFFSET_PADDING, + ); + } + }); + + it('handles multiple series', () => { + const result = getLineChartLayout({ + ...baseParams, + data: [ + { + id: 'series1', + label: 'Series 1', + data: [ + { x: '2024-01', y: 10 }, + { x: '2024-02', y: 20 }, + ], + }, + { + id: 'series2', + label: 'Series 2', + data: [ + { x: '2024-01', y: 15 }, + { x: '2024-02', y: 25 }, + ], + }, + ], + }); + + expect(result.margins).toBeDefined(); + expect(result.axisBottomConfiguration).toBeDefined(); + }); + + it('handles missing axis labels', () => { + const result = getLineChartLayout({ + ...baseParams, + xAxisLabel: undefined, + yAxisLabel: undefined, + }); + + expect(result.margins).toBeDefined(); + expect(result.axisBottomConfiguration).toBeDefined(); + expect(result.axisLeftConfiguration).toBeDefined(); + }); + + it('handles large value ranges with negative and positive values', () => { + const result = getLineChartLayout({ + ...baseParams, + effectiveMinimumValue: -500, + effectiveMaximumValue: 1000, + }); + + expect(result.valueDomain.min).toBeLessThanOrEqual(-500); + expect(result.valueDomain.max).toBeGreaterThanOrEqual(1000); + expect(result.valueTickValues.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig.ts index 35d4a40bb4..610f1225b4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig.ts @@ -4,7 +4,6 @@ import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graph import { computeLineChartCategoryTickValues } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineChartCategoryTickValues'; import { getTickRotationConfig } from '@/page-layout/widgets/graph/utils/getTickRotationConfig'; import { isNonEmptyArray } from '@sniptt/guards'; -import { isDefined } from 'twenty-shared/utils'; export type LineChartAxisBottomResult = { config: { @@ -17,55 +16,50 @@ export type LineChartAxisBottomResult = { legendOffset: number; format: (value: string | number) => string; }; - marginBottom: number; }; -export const getLineChartAxisBottomConfig = ( - xAxisLabel?: string, - width?: number, - data?: LineChartSeriesWithColor[], - marginLeft?: number, -): LineChartAxisBottomResult => { - const effectiveMarginLeft = - marginLeft ?? COMMON_CHART_CONSTANTS.MARGIN_LEFT_WITH_LABEL; +type LineChartAxisBottomConfigParams = { + xAxisLabel?: string; + width: number; + data: LineChartSeriesWithColor[]; + marginLeft: number; + marginRight: number; + axisFontSize: number; +}; +export const getLineChartAxisBottomConfig = ({ + xAxisLabel, + width, + data, + marginLeft, + marginRight, + axisFontSize, +}: LineChartAxisBottomConfigParams): LineChartAxisBottomResult => { const tickValues = - width && data + width > 0 && data.length > 0 ? computeLineChartCategoryTickValues({ width, data, - marginLeft: effectiveMarginLeft, - marginRight: COMMON_CHART_CONSTANTS.MARGIN_RIGHT, + marginLeft, + marginRight, }) : undefined; - const availableWidth = width - ? width - (effectiveMarginLeft + COMMON_CHART_CONSTANTS.MARGIN_RIGHT) - : 0; + const availableWidth = width - (marginLeft + marginRight); const actualDataPointCount = isNonEmptyArray(data?.[0]?.data) ? data[0].data.length : 1; - const widthPerDataPoint = - actualDataPointCount > 0 && availableWidth > 0 - ? availableWidth / actualDataPointCount - : 0; + const tickCount = tickValues?.length ?? actualDataPointCount; + const widthPerTick = + tickCount > 0 && availableWidth > 0 ? availableWidth / tickCount : 0; const { tickRotation, maxLabelLength } = getTickRotationConfig({ - widthPerTick: widthPerDataPoint, - axisFontSize: COMMON_CHART_CONSTANTS.AXIS_FONT_SIZE, + widthPerTick, + axisFontSize, }); - const hasRotation = tickRotation !== 0; - const baseMarginBottom = isDefined(xAxisLabel) - ? COMMON_CHART_CONSTANTS.MARGIN_BOTTOM_WITH_LABEL - : COMMON_CHART_CONSTANTS.MARGIN_BOTTOM_WITHOUT_LABEL; - const marginBottom = hasRotation - ? baseMarginBottom + - COMMON_CHART_CONSTANTS.ROTATED_LABELS_EXTRA_BOTTOM_MARGIN - : baseMarginBottom; - return { config: { tickSize: COMMON_CHART_CONSTANTS.TICK_SIZE, @@ -76,12 +70,11 @@ export const getLineChartAxisBottomConfig = ( legendPosition: 'middle' as const, legendOffset: COMMON_CHART_CONSTANTS.BOTTOM_AXIS_LEGEND_OFFSET + - (hasRotation + (tickRotation !== 0 ? COMMON_CHART_CONSTANTS.ROTATED_LABELS_EXTRA_BOTTOM_MARGIN : 0), format: (value: string | number) => truncateTickLabel(String(value), maxLabelLength), }, - marginBottom, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisLeftConfig.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisLeftConfig.ts index f34a902440..82df598058 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisLeftConfig.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisLeftConfig.ts @@ -3,20 +3,20 @@ import { formatGraphValue, type GraphValueFormatOptions, } from '@/page-layout/widgets/graph/utils/graphFormatters'; -import { isDefined } from 'twenty-shared/utils'; -export const getLineChartAxisLeftConfig = ( - yAxisLabel?: string, - formatOptions?: GraphValueFormatOptions, - tickValues?: number[], - marginLeft?: number, -) => { - const effectiveMarginLeft = - marginLeft ?? - (isDefined(yAxisLabel) - ? COMMON_CHART_CONSTANTS.MARGIN_LEFT_WITH_LABEL - : COMMON_CHART_CONSTANTS.MARGIN_LEFT_WITHOUT_LABEL); +type LineChartAxisLeftConfigParams = { + yAxisLabel?: string; + formatOptions?: GraphValueFormatOptions; + tickValues?: number[]; + marginLeft: number; +}; +export const getLineChartAxisLeftConfig = ({ + yAxisLabel, + formatOptions, + tickValues, + marginLeft, +}: LineChartAxisLeftConfigParams) => { return { tickSize: COMMON_CHART_CONSTANTS.TICK_SIZE, tickPadding: COMMON_CHART_CONSTANTS.TICK_PADDING, @@ -25,8 +25,7 @@ export const getLineChartAxisLeftConfig = ( legend: yAxisLabel, legendPosition: 'middle' as const, legendOffset: - -effectiveMarginLeft + - COMMON_CHART_CONSTANTS.LEFT_AXIS_LEGEND_OFFSET_PADDING, + -marginLeft + COMMON_CHART_CONSTANTS.LEFT_AXIS_LEGEND_OFFSET_PADDING, format: (value: number) => formatGraphValue(value, formatOptions || {}), }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartLayout.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartLayout.ts new file mode 100644 index 0000000000..96d63228a9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartLayout.ts @@ -0,0 +1,142 @@ +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; +import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants'; +import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor'; +import { getLineChartAxisBottomConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig'; +import { getLineChartAxisLeftConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisLeftConfig'; +import { type ChartAxisTheme } from '@/page-layout/widgets/graph/types/ChartAxisTheme'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import { computeBottomLegendOffsetFromText } from '@/page-layout/widgets/graph/utils/computeBottomLegendOffsetFromText'; +import { computeChartMargins } from '@/page-layout/widgets/graph/utils/computeChartMargins'; +import { computeValueTickValues } from '@/page-layout/widgets/graph/utils/computeValueTickValues'; +import { + formatGraphValue, + type GraphValueFormatOptions, +} from '@/page-layout/widgets/graph/utils/graphFormatters'; +import { resolveAxisFontSizes } from '@/page-layout/widgets/graph/utils/resolveAxisFontSizes'; + +type GetLineChartLayoutParams = { + axisTheme: ChartAxisTheme; + chartWidth: number; + data: LineChartSeriesWithColor[]; + xAxisLabel?: string; + yAxisLabel?: string; + formatOptions: GraphValueFormatOptions; + effectiveMinimumValue: number; + effectiveMaximumValue: number; +}; + +type LineChartLayout = { + margins: ChartMargins; + axisBottomConfiguration: ReturnType< + typeof getLineChartAxisBottomConfig + >['config']; + axisLeftConfiguration: ReturnType; + valueTickValues: number[]; + valueDomain: { min: number; max: number }; +}; + +const resolveAxisBottomConfiguration = ({ + axisBottomConfigurationResult, + margins, + bottomLegendOffset, +}: { + axisBottomConfigurationResult: ReturnType< + typeof getLineChartAxisBottomConfig + >; + margins: ChartMargins; + bottomLegendOffset?: number; +}) => { + const resolvedLegendOffset = + bottomLegendOffset ?? axisBottomConfigurationResult.config.legendOffset; + + return { + ...axisBottomConfigurationResult.config, + legendOffset: Math.min( + resolvedLegendOffset, + Math.max( + margins.bottom - COMMON_CHART_CONSTANTS.LEGEND_OFFSET_MARGIN_BUFFER, + 0, + ), + ), + }; +}; + +export const getLineChartLayout = ({ + axisTheme, + chartWidth, + data, + xAxisLabel, + yAxisLabel, + formatOptions, + effectiveMinimumValue, + effectiveMaximumValue, +}: GetLineChartLayoutParams): LineChartLayout => { + const { tickFontSize, legendFontSize } = resolveAxisFontSizes(axisTheme); + + const { + margins, + tickConfiguration: axisBottomConfigurationResult, + valueTickResult, + bottomLegendOffset, + } = computeChartMargins({ + tickFontSize, + legendFontSize, + xAxisLabel, + yAxisLabel, + initialTickRotation: LINE_CHART_CONSTANTS.NO_ROTATION_ANGLE, + computeTickConfig: (currentMargins) => + getLineChartAxisBottomConfig({ + xAxisLabel, + width: chartWidth, + data, + marginLeft: currentMargins.left, + marginRight: currentMargins.right, + axisFontSize: tickFontSize, + }), + computeValueTickValues: () => + computeValueTickValues({ + minimum: effectiveMinimumValue, + maximum: effectiveMaximumValue, + tickCount: LINE_CHART_CONSTANTS.DEFAULT_TICK_COUNT, + }), + getTickRotation: (tickConfiguration) => + tickConfiguration.config.tickRotation, + getBottomLegendOffset: (parameters) => + computeBottomLegendOffsetFromText({ + tickLabels: parameters.marginInputs.bottomTickLabels, + tickFontSize, + tickRotation: parameters.tickConfiguration.config.tickRotation, + }), + resolveMarginInputs: (tickConfiguration, tickResult) => ({ + bottomTickLabels: (tickConfiguration.config.tickValues ?? []).map( + (value) => tickConfiguration.config.format(value), + ), + leftTickLabels: tickResult.tickValues.map((value) => + formatGraphValue(value, formatOptions), + ), + }), + }); + + const axisBottomConfiguration = resolveAxisBottomConfiguration({ + axisBottomConfigurationResult, + margins, + bottomLegendOffset, + }); + + const { tickValues: valueTickValues, domain: valueDomain } = valueTickResult; + + const axisLeftConfiguration = getLineChartAxisLeftConfig({ + yAxisLabel, + formatOptions, + tickValues: valueTickValues, + marginLeft: margins.left, + }); + + return { + margins, + axisBottomConfiguration, + axisLeftConfiguration, + valueTickValues, + valueDomain, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/ChartAxisTheme.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/ChartAxisTheme.ts new file mode 100644 index 0000000000..d7eff714a7 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/ChartAxisTheme.ts @@ -0,0 +1,4 @@ +export type ChartAxisTheme = { + ticks: { text: { fontSize: number } }; + legend: { text: { fontSize: number } }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/ChartMargins.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/ChartMargins.ts new file mode 100644 index 0000000000..ecab2f5861 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/ChartMargins.ts @@ -0,0 +1,6 @@ +export type ChartMargins = { + top: number; + right: number; + bottom: number; + left: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/computeBottomLegendOffsetFromText.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/computeBottomLegendOffsetFromText.test.ts new file mode 100644 index 0000000000..80edff81ac --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/computeBottomLegendOffsetFromText.test.ts @@ -0,0 +1,60 @@ +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; +import { TEXT_MARGIN_EXTRAS } from '@/page-layout/widgets/graph/constants/TextMarginExtras'; +import { computeBottomLegendOffsetFromText } from '@/page-layout/widgets/graph/utils/computeBottomLegendOffsetFromText'; + +describe('computeBottomLegendOffsetFromText', () => { + it('uses tick font size when no tick labels are provided', () => { + const tickFontSize = 12; + const result = computeBottomLegendOffsetFromText({ + tickLabels: undefined, + tickFontSize, + tickRotation: COMMON_CHART_CONSTANTS.NO_ROTATION_ANGLE, + }); + + expect(result).toBe( + Math.ceil( + tickFontSize + + COMMON_CHART_CONSTANTS.TICK_PADDING + + TEXT_MARGIN_EXTRAS.tickPaddingExtra + + TEXT_MARGIN_EXTRAS.bottomTickExtraNonRotated, + ), + ); + }); + + it('returns larger offsets for rotated labels', () => { + const tickFontSize = 12; + const tickLabels = ['LongLabelValue']; + + const rotated = computeBottomLegendOffsetFromText({ + tickLabels, + tickFontSize, + tickRotation: COMMON_CHART_CONSTANTS.TICK_ROTATION_ANGLE, + }); + + const nonRotated = computeBottomLegendOffsetFromText({ + tickLabels, + tickFontSize, + tickRotation: COMMON_CHART_CONSTANTS.NO_ROTATION_ANGLE, + }); + + expect(rotated).toBeGreaterThan(nonRotated); + }); + + it('scales rotated offsets with label length', () => { + const tickFontSize = 12; + const tickRotation = COMMON_CHART_CONSTANTS.TICK_ROTATION_ANGLE; + + const short = computeBottomLegendOffsetFromText({ + tickLabels: ['AB'], + tickFontSize, + tickRotation, + }); + const long = computeBottomLegendOffsetFromText({ + tickLabels: ['AB'.repeat(12)], + tickFontSize, + tickRotation, + }); + + expect(long).toBeGreaterThan(short); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/computeChartMargins.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/computeChartMargins.test.ts new file mode 100644 index 0000000000..27b95451de --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/computeChartMargins.test.ts @@ -0,0 +1,125 @@ +import { TEXT_MARGIN_LIMITS } from '@/page-layout/widgets/graph/constants/TextMarginLimits'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import { computeChartMargins } from '@/page-layout/widgets/graph/utils/computeChartMargins'; + +describe('computeChartMargins', () => { + const defaultTickConfig = { + categoryTickValues: ['A', 'B', 'C'], + numberOfValueTicks: 5, + bottomAxisTickRotation: 0, + maxBottomAxisTickLabelLength: 10, + }; + + const defaultValueTickResult = { + tickValues: [0, 25, 50, 75, 100], + domain: { min: 0, max: 100 }, + }; + + const baseParams = { + tickFontSize: 11, + legendFontSize: 11, + initialTickRotation: 0, + computeTickConfig: () => defaultTickConfig, + computeValueTickValues: () => defaultValueTickResult, + getTickRotation: () => 0, + resolveMarginInputs: () => ({ + bottomTickLabels: ['A', 'B', 'C'], + leftTickLabels: ['0', '25', '50', '75', '100'], + }), + }; + + it('computes margins with simple callbacks', () => { + const result = computeChartMargins(baseParams); + + expect(result.margins).toBeDefined(); + expect(result.margins.top).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.top, + ); + expect(result.margins.right).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.right, + ); + expect(result.margins.bottom).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.bottom, + ); + expect(result.margins.left).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.left, + ); + }); + + it('returns tick configuration', () => { + const result = computeChartMargins(baseParams); + + expect(result.tickConfiguration).toEqual(defaultTickConfig); + }); + + it('returns value tick result', () => { + const result = computeChartMargins(baseParams); + + expect(result.valueTickResult).toEqual(defaultValueTickResult); + }); + + it('margins stabilize and do not oscillate between iterations', () => { + const callHistory: ChartMargins[] = []; + + const result = computeChartMargins({ + ...baseParams, + computeTickConfig: (margins) => { + callHistory.push({ ...margins }); + return defaultTickConfig; + }, + }); + + expect(result.margins).toBeDefined(); + expect(callHistory.length).toBeGreaterThan(0); + }); + + it('works with getBottomLegendOffset callback', () => { + const result = computeChartMargins({ + ...baseParams, + getBottomLegendOffset: () => 40, + }); + + expect(result.bottomLegendOffset).toBe(40); + expect(result.margins).toBeDefined(); + }); + + it('works without getBottomLegendOffset callback', () => { + const result = computeChartMargins(baseParams); + + expect(result.bottomLegendOffset).toBeUndefined(); + expect(result.margins).toBeDefined(); + }); + + it('handles empty tick label arrays', () => { + const result = computeChartMargins({ + ...baseParams, + resolveMarginInputs: () => ({ + bottomTickLabels: [], + leftTickLabels: [], + }), + }); + + expect(result.margins).toBeDefined(); + expect(result.margins.bottom).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.bottom, + ); + expect(result.margins.left).toBeGreaterThanOrEqual( + TEXT_MARGIN_LIMITS.min.left, + ); + }); + + it('respects margin limits', () => { + const result = computeChartMargins(baseParams); + + expect(result.margins.top).toBeLessThanOrEqual(TEXT_MARGIN_LIMITS.max.top); + expect(result.margins.right).toBeLessThanOrEqual( + TEXT_MARGIN_LIMITS.max.right, + ); + expect(result.margins.bottom).toBeLessThanOrEqual( + TEXT_MARGIN_LIMITS.max.bottom, + ); + expect(result.margins.left).toBeLessThanOrEqual( + TEXT_MARGIN_LIMITS.max.left, + ); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getChartMarginsFromText.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getChartMarginsFromText.test.ts new file mode 100644 index 0000000000..1d982944c5 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getChartMarginsFromText.test.ts @@ -0,0 +1,46 @@ +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; +import { TEXT_MARGIN_EXTRAS } from '@/page-layout/widgets/graph/constants/TextMarginExtras'; +import { TEXT_MARGIN_LIMITS } from '@/page-layout/widgets/graph/constants/TextMarginLimits'; +import { getChartMarginsFromText } from '@/page-layout/widgets/graph/utils/getChartMarginsFromText'; + +describe('getChartMarginsFromText', () => { + it('clamps margins when labels are very large', () => { + const longLabel = 'X'.repeat(200); + + const result = getChartMarginsFromText({ + tickFontSize: 12, + legendFontSize: 12, + bottomTickLabels: [longLabel], + leftTickLabels: [longLabel], + xAxisLabel: 'x', + yAxisLabel: 'y', + tickRotation: COMMON_CHART_CONSTANTS.NO_ROTATION_ANGLE, + bottomLegendOffset: 1000, + }); + + expect(result.bottom).toBe(TEXT_MARGIN_LIMITS.max.bottom); + expect(result.left).toBe(TEXT_MARGIN_LIMITS.max.left); + expect(result.top).toBe(18); + expect(result.right).toBe(18); + }); + + it('uses bottom legend offset when it exceeds tick and label blocks', () => { + const bottomLegendOffset = 100; + const result = getChartMarginsFromText({ + tickFontSize: 10, + legendFontSize: 10, + bottomTickLabels: ['a'], + leftTickLabels: ['b'], + xAxisLabel: 'x', + yAxisLabel: 'y', + tickRotation: COMMON_CHART_CONSTANTS.NO_ROTATION_ANGLE, + bottomLegendOffset, + }); + + const expectedBottom = Math.ceil( + bottomLegendOffset + 10 + TEXT_MARGIN_EXTRAS.tickPaddingExtra, + ); + + expect(result.bottom).toBe(expectedBottom); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getMaxLabelLength.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getMaxLabelLength.test.ts new file mode 100644 index 0000000000..f7f1164a66 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getMaxLabelLength.test.ts @@ -0,0 +1,34 @@ +import { getMaxLabelLength } from '@/page-layout/widgets/graph/utils/getMaxLabelLength'; + +describe('getMaxLabelLength', () => { + it('returns 0 for undefined input', () => { + expect(getMaxLabelLength(undefined)).toBe(0); + }); + + it('returns 0 for empty array', () => { + expect(getMaxLabelLength([])).toBe(0); + }); + + it('returns max length from array of labels', () => { + expect(getMaxLabelLength(['a', 'bb', 'ccc'])).toBe(3); + }); + + it('returns correct length when max is not last', () => { + expect(getMaxLabelLength(['longest', 'short', 'med'])).toBe(7); + }); + + it('handles single item array', () => { + expect(getMaxLabelLength(['single'])).toBe(6); + }); + + it('handles null/undefined items in array', () => { + expect( + getMaxLabelLength(['valid', null as unknown as string, 'test']), + ).toBe(5); + expect(getMaxLabelLength([undefined as unknown as string, 'abc'])).toBe(3); + }); + + it('handles empty strings in array', () => { + expect(getMaxLabelLength(['', 'abc', ''])).toBe(3); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getTickRotationConfig.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getTickRotationConfig.test.ts index 831e779035..5cbf012770 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getTickRotationConfig.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getTickRotationConfig.test.ts @@ -82,12 +82,12 @@ describe('getTickRotationConfig', () => { it('should handle different font sizes', () => { const smallFontResult: TickRotationConfig = getTickRotationConfig({ - widthPerTick: 50, + widthPerTick: 200, axisFontSize: 8, }); const largeFontResult: TickRotationConfig = getTickRotationConfig({ - widthPerTick: 50, + widthPerTick: 200, axisFontSize: 16, }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/parseFontSizeToPx.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/parseFontSizeToPx.test.ts new file mode 100644 index 0000000000..6f17ab4d3b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/parseFontSizeToPx.test.ts @@ -0,0 +1,46 @@ +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; +import { parseFontSizeToPx } from '@/page-layout/widgets/graph/utils/parseFontSizeToPx'; + +describe('parseFontSizeToPx', () => { + it('returns numeric font sizes as-is when valid', () => { + expect(parseFontSizeToPx(12, 10)).toBe(12); + }); + + it('falls back on invalid numbers', () => { + expect(parseFontSizeToPx(Number.NaN, 10)).toBe(10); + expect(parseFontSizeToPx(-1, 10)).toBe(10); + }); + + it('parses px strings', () => { + expect(parseFontSizeToPx('14px', 10)).toBe(14); + expect(parseFontSizeToPx('14', 10)).toBe(14); + }); + + it('parses rem/em using root font size', () => { + const computedStyleSpy = jest + .spyOn(window, 'getComputedStyle') + .mockReturnValue({ fontSize: '20px' } as CSSStyleDeclaration); + + expect(parseFontSizeToPx('1.5rem', 10)).toBe(30); + expect(parseFontSizeToPx('2em', 10)).toBe(40); + + computedStyleSpy.mockRestore(); + }); + + it('falls back for invalid strings', () => { + expect(parseFontSizeToPx('calc(1rem + 2px)', 11)).toBe(11); + expect(parseFontSizeToPx('bad', 11)).toBe(11); + }); + + it('falls back when root font size is invalid', () => { + const computedStyleSpy = jest + .spyOn(window, 'getComputedStyle') + .mockReturnValue({ fontSize: 'oops' } as CSSStyleDeclaration); + + expect(parseFontSizeToPx('1rem', 10)).toBe( + COMMON_CHART_CONSTANTS.AXIS_FONT_SIZE, + ); + + computedStyleSpy.mockRestore(); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeBottomLegendOffsetFromText.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeBottomLegendOffsetFromText.ts new file mode 100644 index 0000000000..e3c56b4972 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeBottomLegendOffsetFromText.ts @@ -0,0 +1,47 @@ +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; +import { TEXT_MARGIN_EXTRAS } from '@/page-layout/widgets/graph/constants/TextMarginExtras'; +import { estimateLineHeight } from '@/page-layout/widgets/graph/utils/estimateLineHeight'; +import { estimateRotatedHeight } from '@/page-layout/widgets/graph/utils/estimateRotatedHeight'; +import { getMaxLabelLength } from '@/page-layout/widgets/graph/utils/getMaxLabelLength'; + +export const computeBottomLegendOffsetFromText = ({ + tickLabels, + tickFontSize, + tickRotation, +}: { + tickLabels?: string[]; + tickFontSize: number; + tickRotation: number; +}) => { + if (!tickLabels || tickLabels.length === 0) { + const extraSpacing = + tickRotation === 0 ? TEXT_MARGIN_EXTRAS.bottomTickExtraNonRotated : 0; + return Math.ceil( + tickFontSize + + COMMON_CHART_CONSTANTS.TICK_PADDING + + TEXT_MARGIN_EXTRAS.tickPaddingExtra + + extraSpacing, + ); + } + + const effectiveMaxLabelLength = getMaxLabelLength(tickLabels); + const bottomTickHeight = + tickRotation !== 0 + ? estimateRotatedHeight( + effectiveMaxLabelLength, + tickFontSize, + tickRotation, + ) + : estimateLineHeight(tickFontSize); + const effectiveTickHeight = + bottomTickHeight > 0 ? bottomTickHeight : tickFontSize; + const extraSpacing = + tickRotation === 0 ? TEXT_MARGIN_EXTRAS.bottomTickExtraNonRotated : 0; + + return Math.ceil( + effectiveTickHeight + + COMMON_CHART_CONSTANTS.TICK_PADDING + + TEXT_MARGIN_EXTRAS.tickPaddingExtra + + extraSpacing, + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeChartMargins.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeChartMargins.ts new file mode 100644 index 0000000000..83055c4cd2 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeChartMargins.ts @@ -0,0 +1,108 @@ +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import { getChartMarginsFromText } from '@/page-layout/widgets/graph/utils/getChartMarginsFromText'; + +type ChartMarginInputs = { + bottomTickLabels?: string[]; + leftTickLabels?: string[]; +}; + +type ComputeChartMarginsParams = { + tickFontSize: number; + legendFontSize?: number; + xAxisLabel?: string; + yAxisLabel?: string; + initialTickRotation: number; + computeTickConfig: (margins: ChartMargins) => TTickConfig; + computeValueTickValues: (tickConfiguration: TTickConfig) => TValueTickResult; + getTickRotation: (tickConfiguration: TTickConfig) => number; + getBottomLegendOffset?: (params: { + tickConfiguration: TTickConfig; + marginInputs: ChartMarginInputs; + }) => number; + resolveMarginInputs: ( + tickConfiguration: TTickConfig, + valueTickResult: TValueTickResult, + ) => ChartMarginInputs; +}; + +type ComputeChartMarginsResult = { + margins: ChartMargins; + tickConfiguration: TTickConfig; + valueTickResult: TValueTickResult; + bottomLegendOffset?: number; +}; + +export const computeChartMargins = ({ + tickFontSize, + legendFontSize, + xAxisLabel, + yAxisLabel, + initialTickRotation, + computeTickConfig, + computeValueTickValues, + getTickRotation, + getBottomLegendOffset, + resolveMarginInputs, +}: ComputeChartMarginsParams< + TTickConfig, + TValueTickResult +>): ComputeChartMarginsResult => { + const provisionalMargins = getChartMarginsFromText({ + tickFontSize, + legendFontSize, + xAxisLabel, + yAxisLabel, + tickRotation: initialTickRotation, + }); + + const provisionalTickConfiguration = computeTickConfig(provisionalMargins); + const provisionalValueTickResult = computeValueTickValues( + provisionalTickConfiguration, + ); + const provisionalMarginInputs = resolveMarginInputs( + provisionalTickConfiguration, + provisionalValueTickResult, + ); + + const computedBottomLegendOffset = getBottomLegendOffset + ? getBottomLegendOffset({ + tickConfiguration: provisionalTickConfiguration, + marginInputs: provisionalMarginInputs, + }) + : undefined; + + const computedMargins = getChartMarginsFromText({ + tickFontSize, + legendFontSize, + bottomTickLabels: provisionalMarginInputs.bottomTickLabels, + leftTickLabels: provisionalMarginInputs.leftTickLabels, + xAxisLabel, + yAxisLabel, + tickRotation: getTickRotation(provisionalTickConfiguration), + bottomLegendOffset: computedBottomLegendOffset, + }); + + const tickConfiguration = computeTickConfig(computedMargins); + const valueTickResult = computeValueTickValues(tickConfiguration); + const marginInputs = resolveMarginInputs(tickConfiguration, valueTickResult); + + const bottomLegendOffset = getBottomLegendOffset + ? getBottomLegendOffset({ + tickConfiguration, + marginInputs, + }) + : undefined; + + const margins = getChartMarginsFromText({ + tickFontSize, + legendFontSize, + bottomTickLabels: marginInputs.bottomTickLabels, + leftTickLabels: marginInputs.leftTickLabels, + xAxisLabel, + yAxisLabel, + tickRotation: getTickRotation(tickConfiguration), + bottomLegendOffset, + }); + + return { margins, tickConfiguration, valueTickResult, bottomLegendOffset }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/estimateLineHeight.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/estimateLineHeight.ts new file mode 100644 index 0000000000..ebc227bca4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/estimateLineHeight.ts @@ -0,0 +1,4 @@ +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; + +export const estimateLineHeight = (fontSize: number) => + Math.ceil(fontSize * COMMON_CHART_CONSTANTS.TICK_LABEL_LINE_HEIGHT_RATIO); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/estimateRotatedHeight.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/estimateRotatedHeight.ts new file mode 100644 index 0000000000..c0d791660a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/estimateRotatedHeight.ts @@ -0,0 +1,28 @@ +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; +import { estimateLineHeight } from '@/page-layout/widgets/graph/utils/estimateLineHeight'; + +export const estimateRotatedHeight = ( + maxLength: number, + fontSize: number, + rotationDegrees: number, +) => { + if (rotationDegrees === 0 || maxLength <= 0) { + return 0; + } + + const labelWidth = + maxLength * + fontSize * + COMMON_CHART_CONSTANTS.ROTATED_TICK_LABEL_WIDTH_ESTIMATE_RATIO; + if (labelWidth <= 0) { + return 0; + } + + const labelHeight = estimateLineHeight(fontSize); + const rotationRadians = (Math.abs(rotationDegrees) * Math.PI) / 180; + const projectedHeight = + Math.abs(labelWidth * Math.sin(rotationRadians)) + + Math.abs(labelHeight * Math.cos(rotationRadians)); + + return Math.max(labelHeight, projectedHeight); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getChartMarginsFromText.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getChartMarginsFromText.ts new file mode 100644 index 0000000000..fa5fe6d75a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getChartMarginsFromText.ts @@ -0,0 +1,111 @@ +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; +import { TEXT_MARGIN_EXTRAS } from '@/page-layout/widgets/graph/constants/TextMarginExtras'; +import { TEXT_MARGIN_LIMITS } from '@/page-layout/widgets/graph/constants/TextMarginLimits'; +import { type ChartMargins } from '@/page-layout/widgets/graph/types/ChartMargins'; +import { estimateLineHeight } from '@/page-layout/widgets/graph/utils/estimateLineHeight'; +import { estimateRotatedHeight } from '@/page-layout/widgets/graph/utils/estimateRotatedHeight'; +import { getMaxLabelLength } from '@/page-layout/widgets/graph/utils/getMaxLabelLength'; +import { isNumber } from '@sniptt/guards'; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + +const estimateLabelWidth = (maxLength: number, fontSize: number) => + Math.ceil( + maxLength * + fontSize * + COMMON_CHART_CONSTANTS.HORIZONTAL_LABEL_CHARACTER_WIDTH_RATIO, + ); + +export const getChartMarginsFromText = ({ + tickFontSize, + legendFontSize, + bottomTickLabels, + leftTickLabels, + xAxisLabel, + yAxisLabel, + tickRotation, + bottomLegendOffset, +}: { + tickFontSize: number; + legendFontSize?: number; + bottomTickLabels?: string[]; + leftTickLabels?: string[]; + xAxisLabel?: string; + yAxisLabel?: string; + tickRotation: number; + bottomLegendOffset?: number; +}): ChartMargins => { + const normalizedLegendFontSize = legendFontSize ?? tickFontSize; + + const bottomMaxLabelLength = getMaxLabelLength(bottomTickLabels); + const leftMaxLabelLength = getMaxLabelLength(leftTickLabels); + + const tickPaddingExtra = xAxisLabel ? TEXT_MARGIN_EXTRAS.tickPaddingExtra : 0; + const bottomTickHeight = + bottomMaxLabelLength > 0 + ? tickRotation !== 0 + ? estimateRotatedHeight( + bottomMaxLabelLength, + tickFontSize, + tickRotation, + ) + : estimateLineHeight(tickFontSize) + : 0; + const bottomTicksBlock = + bottomTickHeight > 0 + ? bottomTickHeight + + COMMON_CHART_CONSTANTS.TICK_PADDING + + tickPaddingExtra + : 0; + const bottomLabelBlock = xAxisLabel + ? normalizedLegendFontSize + tickPaddingExtra + : 0; + const bottomFromTicksAndLabel = bottomTicksBlock + bottomLabelBlock; + const bottomFromLegendOffset = + xAxisLabel && isNumber(bottomLegendOffset) + ? bottomLegendOffset + normalizedLegendFontSize + tickPaddingExtra + : 0; + + const bottom = clamp( + Math.ceil(Math.max(bottomFromTicksAndLabel, bottomFromLegendOffset)), + TEXT_MARGIN_LIMITS.min.bottom, + TEXT_MARGIN_LIMITS.max.bottom, + ); + + const leftTickWidth = + leftMaxLabelLength > 0 + ? estimateLabelWidth(leftMaxLabelLength, tickFontSize) + : 0; + const leftTicksBlock = + leftTickWidth > 0 + ? leftTickWidth + + COMMON_CHART_CONSTANTS.TICK_PADDING + + TEXT_MARGIN_EXTRAS.tickPaddingExtra + : 0; + const leftLabelBlock = yAxisLabel + ? normalizedLegendFontSize + TEXT_MARGIN_EXTRAS.tickPaddingExtra + : 0; + const left = clamp( + Math.ceil(leftTicksBlock + leftLabelBlock), + TEXT_MARGIN_LIMITS.min.left, + TEXT_MARGIN_LIMITS.max.left, + ); + + const topRightBase = Math.ceil(tickFontSize * 1.5); + + return { + top: clamp( + topRightBase, + TEXT_MARGIN_LIMITS.min.top, + TEXT_MARGIN_LIMITS.max.top, + ), + right: clamp( + topRightBase, + TEXT_MARGIN_LIMITS.min.right, + TEXT_MARGIN_LIMITS.max.right, + ), + bottom, + left, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getMaxLabelLength.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getMaxLabelLength.ts new file mode 100644 index 0000000000..ee0a990864 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getMaxLabelLength.ts @@ -0,0 +1,10 @@ +export const getMaxLabelLength = (labels?: string[]) => { + if (!labels || labels.length === 0) { + return 0; + } + + return labels.reduce( + (maxLength, label) => Math.max(maxLength, label?.length ?? 0), + 0, + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getTickRotationConfig.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getTickRotationConfig.ts index 98b474a01b..fa28e5bdca 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getTickRotationConfig.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getTickRotationConfig.ts @@ -1,7 +1,5 @@ import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; -const TICK_ROTATION_ANGLE_RAD = - (Math.abs(COMMON_CHART_CONSTANTS.TICK_ROTATION_ANGLE) * Math.PI) / 180; const MIN_CALCULATED_LENGTH = 1; const TICK_MARGIN = 1; @@ -28,11 +26,8 @@ export const getTickRotationConfig = ({ const characterWidth = axisFontSize * COMMON_CHART_CONSTANTS.ROTATED_LABEL_CHARACTER_WIDTH_RATIO; const calculatedLength = Math.max( - MIN_CALCULATED_LENGTH, - Math.floor( - COMMON_CHART_CONSTANTS.MARGIN_BOTTOM_WITHOUT_LABEL / - (characterWidth * Math.sin(TICK_ROTATION_ANGLE_RAD)), - ), + COMMON_CHART_CONSTANTS.ROTATED_TICK_MINIMUM_NUMBER_OF_DISPLAYED_CHARACTERS, + Math.floor(widthPerTick / characterWidth), ); return { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/parseFontSizeToPx.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/parseFontSizeToPx.ts new file mode 100644 index 0000000000..ae064cd8a4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/parseFontSizeToPx.ts @@ -0,0 +1,41 @@ +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; +import { isNumber, isString } from '@sniptt/guards'; + +const getRootFontSize = () => { + if (typeof document === 'undefined') { + return COMMON_CHART_CONSTANTS.AXIS_FONT_SIZE; + } + + const rootFontSize = getComputedStyle(document.documentElement).fontSize; + const parsed = Number.parseFloat(rootFontSize); + + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : COMMON_CHART_CONSTANTS.AXIS_FONT_SIZE; +}; + +export const parseFontSizeToPx = ( + fontSize: number | string | undefined, + fallback: number, +) => { + if (isNumber(fontSize)) { + return Number.isFinite(fontSize) && fontSize > 0 ? fontSize : fallback; + } + + if (isString(fontSize)) { + const trimmed = fontSize.trim(); + const parsed = Number.parseFloat(trimmed); + + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + + if (trimmed.endsWith('rem') || trimmed.endsWith('em')) { + return parsed * getRootFontSize(); + } + + return parsed; + } + + return fallback; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/resolveAxisFontSizes.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/resolveAxisFontSizes.ts new file mode 100644 index 0000000000..1f497f2dc2 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/resolveAxisFontSizes.ts @@ -0,0 +1,10 @@ +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; +import { type ChartAxisTheme } from '@/page-layout/widgets/graph/types/ChartAxisTheme'; + +export const resolveAxisFontSizes = (axisTheme: ChartAxisTheme) => { + const tickFontSize = + axisTheme?.ticks?.text?.fontSize ?? COMMON_CHART_CONSTANTS.AXIS_FONT_SIZE; + const legendFontSize = axisTheme?.legend?.text?.fontSize ?? tickFontSize; + + return { tickFontSize, legendFontSize }; +};