[DASHBOARDS] Rotate ticks on bar and line charts (#16528)

## Description

3 steps depending on the widget width. From bigger to tighter space:
- Fully shown horizontal text
- Rotated text
- Rotated text with skipped ticks to avoid overlapping

Also created common files for all constants for bar and pie charts.
Since a lot of them are shared, they can be inherited from a common
file.

## Video QA


https://github.com/user-attachments/assets/fd58d412-1a8b-4bd6-a420-4c03767e98d5
This commit is contained in:
Raphaël Bosi
2025-12-16 17:39:42 +01:00
committed by GitHub
parent 5a5bf112cf
commit 576019e465
97 changed files with 722 additions and 322 deletions
@@ -1,3 +1,4 @@
import { ROTATION_THRESHOLD_WIDTH } from '@/page-layout/widgets/graph/constants/RotationThresholdWidth';
import { computeChartCategoryTickValues } from '../computeChartCategoryTickValues';
describe('computeChartCategoryTickValues', () => {
@@ -89,4 +90,60 @@ describe('computeChartCategoryTickValues', () => {
expect(result).toEqual([]);
});
describe('with widthPerTick parameter', () => {
it('should return all values when widthPerTick is above rotation threshold', () => {
const values = ['A', 'B', 'C', 'D', 'E'];
const result = computeChartCategoryTickValues({
availableSize: 200,
minimumSizePerTick: 100,
values,
widthPerTick: ROTATION_THRESHOLD_WIDTH + 10,
});
expect(result).toEqual(['A', 'B', 'C', 'D', 'E']);
});
it('should return all values when rotated and widthPerTick exceeds minimumSizePerTick', () => {
const values = ['A', 'B', 'C', 'D', 'E'];
const result = computeChartCategoryTickValues({
availableSize: 200,
minimumSizePerTick: 20,
values,
widthPerTick: 30,
});
expect(result).toEqual(['A', 'B', 'C', 'D', 'E']);
});
it('should omit ticks when rotated and widthPerTick is below minimumSizePerTick', () => {
const values = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
const result = computeChartCategoryTickValues({
availableSize: 400,
minimumSizePerTick: 100,
values,
widthPerTick: 10,
});
expect(result.length).toBe(4);
expect(result).toEqual(['A', 'C', 'F', 'H']);
});
it('should behave like no widthPerTick when widthPerTick is undefined', () => {
const values = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
const resultWithoutWidthPerTick = computeChartCategoryTickValues({
availableSize: 400,
minimumSizePerTick: 100,
values,
});
const resultWithUndefined = computeChartCategoryTickValues({
availableSize: 400,
minimumSizePerTick: 100,
values,
widthPerTick: undefined,
});
expect(resultWithoutWidthPerTick).toEqual(resultWithUndefined);
});
});
});
@@ -0,0 +1,70 @@
import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants';
import { computeMaxLabelLengthForMargin } from '../computeMaxLabelLengthForMargin';
describe('computeMaxLabelLengthForMargin', () => {
it('should calculate label length based on margin size and font size', () => {
const result = computeMaxLabelLengthForMargin({
marginSize: 100,
axisFontSize: 11,
});
const expectedCharacterWidth =
11 * COMMON_CHART_CONSTANTS.HORIZONTAL_LABEL_CHARACTER_WIDTH_RATIO;
const availableWidth = 100 - COMMON_CHART_CONSTANTS.TICK_PADDING_ALLOWANCE;
const expectedLength = Math.floor(availableWidth / expectedCharacterWidth);
expect(result).toBe(expectedLength);
});
it('should return minimum length when margin is too small', () => {
const result = computeMaxLabelLengthForMargin({
marginSize: 20,
axisFontSize: 11,
});
const minimumLength =
COMMON_CHART_CONSTANTS.TICK_MINIMUM_NUMBER_OF_DISPLAYED_CHARACTERS;
expect(result).toBe(minimumLength);
});
it('should scale with font size', () => {
const smallFont = computeMaxLabelLengthForMargin({
marginSize: 100,
axisFontSize: 10,
});
const largeFont = computeMaxLabelLengthForMargin({
marginSize: 100,
axisFontSize: 14,
});
expect(smallFont).toBeGreaterThan(largeFont);
});
it('should scale with margin size', () => {
const smallMargin = computeMaxLabelLengthForMargin({
marginSize: 50,
axisFontSize: 11,
});
const largeMargin = computeMaxLabelLengthForMargin({
marginSize: 150,
axisFontSize: 11,
});
expect(largeMargin).toBeGreaterThan(smallMargin);
});
it('should never return less than the minimum displayable characters', () => {
const result = computeMaxLabelLengthForMargin({
marginSize: 0,
axisFontSize: 11,
});
const minimumLength =
COMMON_CHART_CONSTANTS.TICK_MINIMUM_NUMBER_OF_DISPLAYED_CHARACTERS;
expect(result).toBeGreaterThanOrEqual(minimumLength);
});
});
@@ -2,7 +2,7 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity';
import { CalendarStartDay } from 'twenty-shared';
import {
type FirstDayOfTheWeek,
@@ -4,7 +4,7 @@ import { type AggregateOperations } from '@/object-record/record-table/constants
import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions';
import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOptions';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { GRAPH_DEFAULT_AGGREGATE_VALUE } from '@/page-layout/widgets/graph/constants/GraphDefaultAggregateValue.constant';
import { GRAPH_DEFAULT_AGGREGATE_VALUE } from '@/page-layout/widgets/graph/constants/GraphDefaultAggregateValue';
import isEmpty from 'lodash.isempty';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@@ -1,18 +1,31 @@
import { ROTATION_THRESHOLD_WIDTH } from '@/page-layout/widgets/graph/constants/RotationThresholdWidth';
import { computeCategoryTickValues } from '@/page-layout/widgets/graph/utils/computeCategoryTickValues';
import { isDefined } from 'twenty-shared/utils';
export const computeChartCategoryTickValues = ({
availableSize,
minimumSizePerTick,
values,
widthPerTick,
}: {
availableSize: number;
minimumSizePerTick: number;
values: (string | number)[];
widthPerTick?: number;
}): (string | number)[] => {
if (availableSize <= 0 || values.length === 0) {
return [];
}
if (isDefined(widthPerTick)) {
const willRotate = widthPerTick < ROTATION_THRESHOLD_WIDTH;
const shouldOmitTicks = willRotate && widthPerTick < minimumSizePerTick;
if (!shouldOmitTicks) {
return values;
}
}
const numberOfTicks = Math.floor(availableSize / minimumSizePerTick);
const tickIndices = computeCategoryTickValues(numberOfTicks, values.length);
@@ -1,8 +1,6 @@
import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants';
import { isDefined } from 'twenty-shared/utils';
const POSITIVE_RANGE_PADDING_RATIO = 0.1;
const MINIMUM_POSITIVE_RANGE_PADDING = 1;
type ComputeEffectiveValueRangeParams = {
calculatedMinimum: number;
calculatedMaximum: number;
@@ -39,8 +37,9 @@ export const computeEffectiveValueRange = ({
? positiveRangePaddingTarget
: positiveRangePaddingTarget +
Math.max(
Math.abs(positiveRangePaddingTarget) * POSITIVE_RANGE_PADDING_RATIO,
MINIMUM_POSITIVE_RANGE_PADDING,
Math.abs(positiveRangePaddingTarget) *
COMMON_CHART_CONSTANTS.POSITIVE_RANGE_PADDING_RATIO,
COMMON_CHART_CONSTANTS.MINIMUM_POSITIVE_RANGE_PADDING,
);
let effectiveMinimumValue = baseMinimumValue;
@@ -49,7 +48,8 @@ export const computeEffectiveValueRange = ({
if (!isDefined(rangeMax) && !isDefined(rangeMin)) {
if (effectiveMinimumValue === effectiveMaximumValue) {
effectiveMaximumValue =
effectiveMinimumValue + MINIMUM_POSITIVE_RANGE_PADDING;
effectiveMinimumValue +
COMMON_CHART_CONSTANTS.MINIMUM_POSITIVE_RANGE_PADDING;
}
}
@@ -0,0 +1,21 @@
import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants';
export const computeMaxLabelLengthForMargin = ({
marginSize,
axisFontSize,
}: {
marginSize: number;
axisFontSize: number;
}): number => {
const characterWidth =
axisFontSize *
COMMON_CHART_CONSTANTS.HORIZONTAL_LABEL_CHARACTER_WIDTH_RATIO;
const availableWidth =
marginSize - COMMON_CHART_CONSTANTS.TICK_PADDING_ALLOWANCE;
const calculatedLength = Math.max(
COMMON_CHART_CONSTANTS.TICK_MINIMUM_NUMBER_OF_DISPLAYED_CHARACTERS,
Math.floor(availableWidth / characterWidth),
);
return calculatedLength;
};
@@ -0,0 +1 @@
export const ELLIPSIS_LENGTH = 3;
@@ -1,5 +1,5 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity';
import { formatDateByGranularity } from '@/page-layout/widgets/graph/utils/formatDateByGranularity';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
@@ -1,5 +1,5 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity';
import { GRAPH_DEFAULT_ORDER_BY } from '@/page-layout/widgets/graph/constants/GraphDefaultOrderBy';
import { getGroupByOrderBy } from '@/page-layout/widgets/graph/utils/getGroupByOrderBy';
import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind';
@@ -1,5 +1,5 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity';
import { getGroupByOrderBy } from '@/page-layout/widgets/graph/utils/getGroupByOrderBy';
import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind';
import {
@@ -2,7 +2,7 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity';
import { getRelationFieldOrderBy } from '@/page-layout/widgets/graph/utils/getRelationFieldOrderBy';
import {
type ObjectRecordGroupByDateGranularity,
@@ -1,5 +1,5 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity';
import {
type ObjectRecordGroupByDateGranularity,
type ObjectRecordOrderByForRelationField,
@@ -0,0 +1,57 @@
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;
export type TickRotationConfig = {
tickRotation: number;
maxLabelLength: number;
};
export const getTickRotationConfig = ({
widthPerTick,
axisFontSize,
}: {
widthPerTick: number;
axisFontSize: number;
}): TickRotationConfig => {
const shouldRotate =
widthPerTick <
COMMON_CHART_CONSTANTS.TICK_MINIMUM_NUMBER_OF_DISPLAYED_CHARACTERS *
COMMON_CHART_CONSTANTS.HORIZONTAL_LABEL_CHARACTER_WIDTH_RATIO *
axisFontSize +
TICK_MARGIN;
if (shouldRotate) {
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)),
),
);
return {
tickRotation: COMMON_CHART_CONSTANTS.TICK_ROTATION_ANGLE,
maxLabelLength: calculatedLength,
};
}
const characterWidth =
axisFontSize *
COMMON_CHART_CONSTANTS.HORIZONTAL_LABEL_CHARACTER_WIDTH_RATIO;
const calculatedLength = Math.max(
MIN_CALCULATED_LENGTH,
Math.floor(widthPerTick / characterWidth),
);
return {
tickRotation: COMMON_CHART_CONSTANTS.NO_ROTATION_ANGLE,
maxLabelLength: calculatedLength,
};
};
@@ -2,7 +2,7 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant';
import { LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMaximumNumberOfDataPoints.constant';
import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants';
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
@@ -44,7 +44,7 @@ export const transformOneDimensionalGroupByToLineChartData = ({
// TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600)
const limitedResults = rawResults.slice(
0,
LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS,
LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS,
);
const formattedValues = formatPrimaryDimensionValues({
@@ -112,7 +112,7 @@ export const transformOneDimensionalGroupByToLineChartData = ({
return {
series,
hasTooManyGroups:
rawResults.length > LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS,
rawResults.length > LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS,
formattedToRawLookup,
};
};
@@ -1,7 +1,7 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMaximumNumberOfDataPoints.constant';
import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants';
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
@@ -73,7 +73,10 @@ export const transformTwoDimensionalGroupByToLineChartData = ({
// TODO: Add a limit to the query instead of checking here (issue: twentyhq/core-team-issues#1600)
const isNewX = !xValueSet.has(xValue);
if (isNewX && xValueSet.size >= LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS) {
if (
isNewX &&
xValueSet.size >= LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS
) {
hasTooManyGroups = true;
return;
}