nitin
2026-01-22 21:08:02 +05:30
committed by GitHub
parent f8d1454c03
commit 1e9cd2f5c6
36 changed files with 1515 additions and 354 deletions
@@ -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;
@@ -0,0 +1,4 @@
export const TEXT_MARGIN_EXTRAS = {
tickPaddingExtra: 4,
bottomTickExtraNonRotated: 20,
} as const;
@@ -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;
@@ -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<BarDatum>) => (
<CustomBarItem
@@ -355,8 +342,8 @@ export const GraphWidgetBarChart = ({
markers={zeroMarker}
axisTop={null}
axisRight={null}
axisBottom={axisBottomConfig}
axisLeft={axisLeftConfig}
axisBottom={axisBottomConfiguration}
axisLeft={axisLeftConfiguration}
enableGridX={layout === BarChartLayout.HORIZONTAL && showGrid}
enableGridY={layout === BarChartLayout.VERTICAL && showGrid}
gridXValues={
@@ -1,7 +1,10 @@
import { parseFontSizeToPx } from '@/page-layout/widgets/graph/utils/parseFontSizeToPx';
import { useTheme } from '@emotion/react';
export const useBarChartTheme = () => {
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,
},
},
@@ -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);
});
});
});
@@ -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();
});
});
});
@@ -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;
};
@@ -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
@@ -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 ?? {}),
@@ -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<typeof getBarChartAxisConfigs>['axisLeft'];
valueTickValues: number[];
valueDomain: { min: number; max: number };
};
const resolveMarginInputs = ({
tickConfiguration,
tickResult,
layout,
formatOptions,
}: {
tickConfiguration: BarChartTickConfig;
tickResult: ReturnType<typeof computeValueTickValues>;
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,
};
};
@@ -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;
};
@@ -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,
});
@@ -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<LineSeries>['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 (
<StyledContainer id={id}>
<GraphWidgetChartContainer
@@ -303,10 +296,10 @@ export const GraphWidgetLineChart = ({
<ResponsiveLine
data={nivoData}
margin={{
top: COMMON_CHART_CONSTANTS.MARGIN_TOP,
right: COMMON_CHART_CONSTANTS.MARGIN_RIGHT,
bottom: marginBottom,
left: marginLeft,
top: margins.top,
right: margins.right,
bottom: margins.bottom,
left: margins.left,
}}
xScale={{ type: 'point' }}
yScale={{
@@ -325,8 +318,8 @@ export const GraphWidgetLineChart = ({
colors={colors}
axisTop={null}
axisRight={null}
axisBottom={axisBottomConfig}
axisLeft={axisLeftConfig}
axisBottom={axisBottomConfiguration}
axisLeft={axisLeftConfiguration}
enableGridX={showGrid}
enableGridY={showGrid}
gridYValues={valueTickValues}
@@ -1,7 +1,10 @@
import { parseFontSizeToPx } from '@/page-layout/widgets/graph/utils/parseFontSizeToPx';
import { useTheme } from '@emotion/react';
export const useLineChartTheme = () => {
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,
},
},
@@ -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);
});
});
@@ -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,
};
};
@@ -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 || {}),
};
};
@@ -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<typeof getLineChartAxisLeftConfig>;
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,
};
};
@@ -0,0 +1,4 @@
export type ChartAxisTheme = {
ticks: { text: { fontSize: number } };
legend: { text: { fontSize: number } };
};
@@ -0,0 +1,6 @@
export type ChartMargins = {
top: number;
right: number;
bottom: number;
left: number;
};
@@ -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);
});
});
@@ -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,
);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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,
});
@@ -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();
});
});
@@ -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,
);
};
@@ -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<TTickConfig, TValueTickResult> = {
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<TTickConfig, TValueTickResult> = {
margins: ChartMargins;
tickConfiguration: TTickConfig;
valueTickResult: TValueTickResult;
bottomLegendOffset?: number;
};
export const computeChartMargins = <TTickConfig, TValueTickResult>({
tickFontSize,
legendFontSize,
xAxisLabel,
yAxisLabel,
initialTickRotation,
computeTickConfig,
computeValueTickValues,
getTickRotation,
getBottomLegendOffset,
resolveMarginInputs,
}: ComputeChartMarginsParams<
TTickConfig,
TValueTickResult
>): ComputeChartMarginsResult<TTickConfig, TValueTickResult> => {
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 };
};
@@ -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);
@@ -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);
};
@@ -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,
};
};
@@ -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,
);
};
@@ -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 {
@@ -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;
};
@@ -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 };
};