Skip to content

Commit 29928fd

Browse files
authored
Merge pull request #88718 from software-mansion-labs/impr/chart-label-alignment-spacing
Improve chart label alignment and spacing
2 parents a4898c6 + a133e3c commit 29928fd

11 files changed

Lines changed: 87 additions & 255 deletions

File tree

src/components/Charts/BarChart/BarChartContent.tsx

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer';
1010
import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels';
1111
import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels';
1212
import {AXIS_LABEL_GAP, CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, GLYPH_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants';
13-
import type {ComputeGeometryFn, HitTestArgs} from '@components/Charts/hooks';
13+
import type {HitTestArgs} from '@components/Charts/hooks';
1414
import {
1515
useChartFontManager,
1616
useChartInteractions,
@@ -22,7 +22,7 @@ import {
2222
useYAxisLabelWidth,
2323
} from '@components/Charts/hooks';
2424
import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types';
25-
import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getAdditionalOffset, getChartColor, rotatedLabelYOffset} from '@components/Charts/utils';
25+
import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils';
2626
import useTheme from '@hooks/useTheme';
2727
import useThemeStyles from '@hooks/useThemeStyles';
2828
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
@@ -36,29 +36,6 @@ const BAR_INNER_PADDING = 0.3;
3636
*/
3737
const BASE_DOMAIN_PADDING = {top: 32, bottom: 1, left: 0, right: 0};
3838

39-
/**
40-
* Bar chart geometry for label hit-testing.
41-
* Labels are center-anchored: the 45° parallelogram's upper-right corner is offset
42-
* by (halfLabelWidth * sinA) right and up, so the box straddles the tick symmetrically.
43-
*/
44-
const computeBarLabelGeometry: ComputeGeometryFn = ({ascent, descent, sinA, angleRad, labelWidths, padding}) => {
45-
const maxLabelWidth = labelWidths.length > 0 ? Math.max(...labelWidths) : 0;
46-
const centeredUpwardOffset = angleRad > 0 ? (maxLabelWidth / 2) * sinA : 0;
47-
const halfLabelSins = labelWidths.map((w) => (w / 2) * sinA - variables.iconSizeExtraSmall / 3);
48-
const halfWidths = labelWidths.map((w) => w / 2);
49-
const additionalOffset = getAdditionalOffset(angleRad);
50-
return {
51-
labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset - additionalOffset,
52-
iconSin: variables.iconSizeExtraSmall * sinA,
53-
labelSins: labelWidths.map((w) => w * sinA),
54-
halfWidths,
55-
cornerAnchorDX: halfLabelSins,
56-
cornerAnchorDY: halfLabelSins.map((v) => -v),
57-
yMin90Offsets: halfWidths.map((hw) => -hw + padding),
58-
yMax90Offsets: halfWidths.map((hw) => hw + padding),
59-
};
60-
};
61-
6239
type BarChartProps = CartesianChartProps & {
6340
/** Callback when a bar is pressed */
6441
onBarPress?: (dataPoint: ChartDataPoint, index: number) => void;
@@ -141,14 +118,12 @@ function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left'
141118
labelRotation,
142119
labelSkipInterval,
143120
chartBottom,
144-
computeGeometry: computeBarLabelGeometry,
145121
});
146122

147123
const handleChartBoundsChange = (bounds: ChartBounds) => {
148124
const domainWidth = bounds.right - bounds.left;
149125
const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * domainWidth) / data.length;
150126
barWidth.set(calculatedBarWidth);
151-
chartBottom.set(bounds.bottom);
152127
yZero.set(0);
153128
setBarAreaWidth(domainWidth);
154129
setBoundsLeft(bounds.left);
@@ -216,6 +191,10 @@ function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left'
216191
if (!fontMgr || xAxisLabelHeight === undefined) {
217192
return null;
218193
}
194+
195+
const chartBoundsBottom = args.yScale(Math.min(...args.yTicks));
196+
chartBottom.set(chartBoundsBottom);
197+
219198
return (
220199
<>
221200
<ChartXAxisLabels
@@ -231,8 +210,7 @@ function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left'
231210
fontMgr={fontMgr}
232211
labelColor={theme.textSupporting}
233212
xScale={args.xScale}
234-
chartBoundsBottom={args.chartBounds.bottom}
235-
centerRotatedLabels
213+
chartBoundsBottom={chartBoundsBottom}
236214
/>
237215
<ChartYAxisLabels
238216
yTicks={args.yTicks}
@@ -258,7 +236,7 @@ function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left'
258236
fontMgr,
259237
variables.iconSizeExtraSmall,
260238
);
261-
const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom + variables.iconSizeExtraSmall, left: yAxisLabelWidth + GLYPH_PADDING};
239+
const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom, left: yAxisLabelWidth + GLYPH_PADDING};
262240

263241
if (isLoading || !fontMgr) {
264242
const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'BarChartContent', isLoading, isFontLoading: !fontMgr};

src/components/Charts/LineChart/LineChartContent.tsx

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels';
1212
import LeftFrameLine from '@components/Charts/components/LeftFrameLine';
1313
import ScatterPoints from '@components/Charts/components/ScatterPoints';
1414
import {AXIS_LABEL_GAP, CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, GLYPH_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants';
15-
import type {ComputeGeometryFn, HitTestArgs} from '@components/Charts/hooks';
15+
import type {HitTestArgs} from '@components/Charts/hooks';
1616
import {
1717
useChartFontManager,
1818
useChartInteractions,
@@ -24,7 +24,7 @@ import {
2424
useYAxisLabelWidth,
2525
} from '@components/Charts/hooks';
2626
import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types';
27-
import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getAdditionalOffset, rotatedLabelYOffset} from '@components/Charts/utils';
27+
import {calculateMinDomainPadding, DEFAULT_CHART_COLOR} from '@components/Charts/utils';
2828
import useTheme from '@hooks/useTheme';
2929
import useThemeStyles from '@hooks/useThemeStyles';
3030
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
@@ -42,26 +42,6 @@ const MIN_SAFE_PADDING = DOT_RADIUS + DOT_HOVER_EXTRA_RADIUS;
4242
/** Base domain padding applied to all sides */
4343
const BASE_DOMAIN_PADDING = {top: 16, bottom: 16, left: 0, right: 0};
4444

45-
/**
46-
* Line chart geometry for label hit-testing.
47-
* Labels are start-anchored at the tick: the 45° parallelogram's upper-right corner is
48-
* offset by (iconSize/3 * sinA) left and down, placing the box just below the axis line.
49-
*/
50-
const computeLineLabelGeometry: ComputeGeometryFn = ({ascent, descent, sinA, angleRad, labelWidths, padding}) => {
51-
const iconThirdSin = (variables.iconSizeExtraSmall / 3) * sinA;
52-
const additionalOffset = getAdditionalOffset(angleRad);
53-
return {
54-
labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - additionalOffset,
55-
iconSin: variables.iconSizeExtraSmall * sinA,
56-
labelSins: labelWidths.map((w) => w * sinA),
57-
halfWidths: labelWidths.map((w) => w / 2),
58-
cornerAnchorDX: labelWidths.map(() => -iconThirdSin),
59-
cornerAnchorDY: labelWidths.map(() => iconThirdSin),
60-
yMin90Offsets: labelWidths.map(() => padding),
61-
yMax90Offsets: labelWidths.map((w) => w + padding),
62-
};
63-
};
64-
6545
type LineChartProps = CartesianChartProps & {
6646
/** Callback when a data point is pressed */
6747
onPointPress?: (dataPoint: ChartDataPoint, index: number) => void;
@@ -139,7 +119,6 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left
139119
labelAreaWidth: plotAreaWidth,
140120
firstTickLeftSpace: boundsLeft + domainPadding.left * paddingScale,
141121
lastTickRightSpace: chartWidth > 0 ? chartWidth - boundsRight + domainPadding.right * paddingScale : 0,
142-
allowTightDiagonalPacking: true,
143122
measurements,
144123
});
145124

@@ -158,14 +137,12 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left
158137
labelRotation,
159138
labelSkipInterval,
160139
chartBottom,
161-
computeGeometry: computeLineLabelGeometry,
162140
});
163141

164142
const handleChartBoundsChange = (bounds: ChartBounds) => {
165143
setPlotAreaWidth(bounds.right - bounds.left);
166144
setBoundsLeft(bounds.left);
167145
setBoundsRight(bounds.right);
168-
chartBottom.set(bounds.bottom);
169146
};
170147

171148
const checkIsOverDot = (args: HitTestArgs) => {
@@ -197,6 +174,8 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left
197174
}));
198175

199176
const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => {
177+
const chartBoundsBottom = args.yScale(Math.min(...args.yTicks));
178+
chartBottom.set(chartBoundsBottom);
200179
return (
201180
<>
202181
<LeftFrameLine
@@ -224,7 +203,7 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left
224203
fontMgr={fontMgr}
225204
labelColor={theme.textSupporting}
226205
xScale={args.xScale}
227-
chartBoundsBottom={args.chartBounds.bottom}
206+
chartBoundsBottom={chartBoundsBottom}
228207
/>
229208
)}
230209
{!!fontMgr && (
@@ -253,7 +232,7 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left
253232
fontMgr,
254233
variables.iconSizeExtraSmall,
255234
);
256-
const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom + variables.iconSizeExtraSmall, left: yAxisLabelWidth + GLYPH_PADDING};
235+
const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom, left: yAxisLabelWidth + GLYPH_PADDING};
257236

258237
if (isLoading || !fontMgr) {
259238
const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'LineChartContent', isLoading, isFontLoading: !fontMgr};

src/components/Charts/components/ChartXAxisLabels.tsx

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {AXIS_LABEL_GAP, GLYPH_PADDING, MAX_X_AXIS_LABEL_WIDTH} from '@components
55
import {useChartParagraphs} from '@components/Charts/hooks';
66
import type {LabelRotation} from '@components/Charts/types';
77
import {getFontLineMetrics, rotatedLabelCenterCorrection, rotatedLabelYOffset, truncateLabel} from '@components/Charts/utils';
8-
import variables from '@styles/variables';
98

109
type ChartXAxisLabelsProps = {
1110
/** Original (non-truncated) label strings from the data. */
@@ -46,9 +45,6 @@ type ChartXAxisLabelsProps = {
4645

4746
/** Y-pixel coordinate of the bottom edge of the chart plot area. */
4847
chartBoundsBottom: number;
49-
50-
/** When true, rotated labels are centered on the tick. When false, they are right-aligned (end of text at tick). */
51-
centerRotatedLabels?: boolean;
5248
};
5349

5450
function ChartXAxisLabels({
@@ -65,7 +61,6 @@ function ChartXAxisLabels({
6561
labelColor,
6662
xScale,
6763
chartBoundsBottom,
68-
centerRotatedLabels = false,
6964
}: ChartXAxisLabelsProps) {
7065
const angleRad = (Math.abs(labelRotation) * Math.PI) / 180;
7166
const truncatedLabels = (() => {
@@ -83,14 +78,11 @@ function ChartXAxisLabels({
8378

8479
const paragraphs = useChartParagraphs(truncatedLabels, fontMgr, fontSize, labelColor, MAX_X_AXIS_LABEL_WIDTH);
8580

86-
const renderedWidths = truncatedLabels.map((_, i) => paragraphs?.at(i)?.width ?? 0);
87-
8881
// Derive ascent/descent from the first available paragraph's line metrics.
8982
const {ascent, descent} = getFontLineMetrics(fontMgr, fontSize);
9083

9184
const correction = rotatedLabelCenterCorrection(ascent, descent, angleRad);
92-
const centeredUpwardOffset = centerRotatedLabels && angleRad > 0 ? (Math.max(...renderedWidths) / 2) * Math.sin(angleRad) : 0;
93-
const labelY = chartBoundsBottom + AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset;
85+
const labelY = chartBoundsBottom + AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad);
9486

9587
return truncatedLabels.map((label, i) => {
9688
if (i % labelSkipInterval !== 0 || label.length === 0) {
@@ -111,13 +103,13 @@ function ChartXAxisLabels({
111103
key={`x-label-${label}-${tickX}`}
112104
paragraph={paraData.para}
113105
x={tickX - renderWidth / 2}
114-
y={labelY - variables.iconSizeExtraSmall}
106+
y={labelY - ascent}
115107
width={renderWidth + GLYPH_PADDING}
116108
/>
117109
);
118110
}
119111

120-
const textX = centerRotatedLabels ? tickX - renderWidth / 2 : tickX - renderWidth;
112+
const textX = tickX - renderWidth;
121113
const origin = vec(tickX, labelY);
122114

123115
return (

src/components/Charts/hooks/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,3 @@ export {default as useChartLabelFormats} from './useChartLabelFormats';
99
export {default as useDynamicYDomain} from './useDynamicYDomain';
1010
export {useTooltipData} from './useTooltipData';
1111
export {default as useLabelHitTesting} from './useLabelHitTesting';
12-
export type {ComputeGeometryFn, ComputeGeometryInput} from './useLabelHitTesting';

src/components/Charts/hooks/useChartLabelLayout.ts

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,6 @@ type LabelLayoutConfig = {
2626
/** Pixels from last tick to right edge of canvas. Defaults to Infinity (no constraint). */
2727
lastTickRightSpace?: number;
2828

29-
/** When true, allows tighter label packing at 45° by accounting for vertical offset between right-aligned labels. */
30-
allowTightDiagonalPacking?: boolean;
31-
3229
/** Measurements of the label text. */
3330
measurements: ReturnType<typeof useChartLabelMeasurements>;
3431
};
@@ -45,16 +42,7 @@ const EMPTY_LAYOUT = {
4542
ellipsisWidth: 0,
4643
};
4744

48-
function useChartLabelLayout({
49-
data,
50-
fontMgr,
51-
tickSpacing,
52-
labelAreaWidth,
53-
firstTickLeftSpace = Infinity,
54-
lastTickRightSpace = Infinity,
55-
allowTightDiagonalPacking = false,
56-
measurements,
57-
}: LabelLayoutConfig) {
45+
function useChartLabelLayout({data, fontMgr, tickSpacing, labelAreaWidth, firstTickLeftSpace = Infinity, lastTickRightSpace = Infinity, measurements}: LabelLayoutConfig) {
5846
// Phase 1: font/data measurements — stable across geometry-only changes (resize).
5947

6048
// Phase 2: layout decisions + label truncation.
@@ -83,18 +71,16 @@ function useChartLabelLayout({
8371
rotation: LABEL_ROTATIONS.HORIZONTAL,
8472
firstTickLeftSpace: effectiveFirstTickLeftSpace,
8573
lastTickRightSpace: effectiveLastTickRightSpace,
86-
rightAligned: false,
8774
});
8875

8976
if (hFitsInTicks && hEdgeFits) {
9077
rotation = LABEL_ROTATIONS.HORIZONTAL;
9178
} else {
92-
const diagonalOverlap = allowTightDiagonalPacking ? lineHeight * SIN_45 : 0;
93-
const minDiagWidth = minTruncatedWidth * SIN_45 - diagonalOverlap;
79+
const minDiagWidth = minTruncatedWidth * SIN_45 - lineHeight * SIN_45;
9480
const dFitsInTicks = minDiagWidth + LABEL_PADDING <= tickSpacing;
9581

96-
const firstEdgeMax = edgeMaxLabelWidth(effectiveFirstTickLeftSpace, lineHeight, LABEL_ROTATIONS.DIAGONAL, allowTightDiagonalPacking, 'first');
97-
const lastEdgeMax = edgeMaxLabelWidth(effectiveLastTickRightSpace, lineHeight, LABEL_ROTATIONS.DIAGONAL, allowTightDiagonalPacking, 'last');
82+
const firstEdgeMax = edgeMaxLabelWidth(effectiveFirstTickLeftSpace, lineHeight, LABEL_ROTATIONS.DIAGONAL, 'first');
83+
const lastEdgeMax = edgeMaxLabelWidth(effectiveLastTickRightSpace, lineHeight, LABEL_ROTATIONS.DIAGONAL, 'last');
9884
const dEdgeFits = firstEdgeMax >= firstMinTrunc && lastEdgeMax >= lastMinTrunc;
9985

10086
if (dFitsInTicks && dEdgeFits) {
@@ -103,17 +89,16 @@ function useChartLabelLayout({
10389
}
10490

10591
// Compute per-label max-width constraints (used by ChartXAxisLabels for truncation).
106-
const truncDiagonalOverlap = allowTightDiagonalPacking ? lineHeight : 0;
107-
const tickMaxWidth = rotation === LABEL_ROTATIONS.DIAGONAL ? (tickSpacing - LABEL_PADDING) / SIN_45 + truncDiagonalOverlap : Infinity;
92+
const tickMaxWidth = rotation === LABEL_ROTATIONS.DIAGONAL ? (tickSpacing - LABEL_PADDING) / SIN_45 + lineHeight : Infinity;
10893

10994
const labelMaxWidths = data.map((_, index) => {
11095
let maxWidth = tickMaxWidth;
11196
if (index === 0) {
112-
const edgeMax = edgeMaxLabelWidth(effectiveFirstTickLeftSpace, lineHeight, rotation, allowTightDiagonalPacking, 'first');
97+
const edgeMax = edgeMaxLabelWidth(effectiveFirstTickLeftSpace, lineHeight, rotation, 'first');
11398
maxWidth = Math.min(maxWidth, edgeMax);
11499
}
115100
if (index === data.length - 1) {
116-
const edgeMax = edgeMaxLabelWidth(effectiveLastTickRightSpace, lineHeight, rotation, allowTightDiagonalPacking, 'last');
101+
const edgeMax = edgeMaxLabelWidth(effectiveLastTickRightSpace, lineHeight, rotation, 'last');
117102
maxWidth = Math.min(maxWidth, edgeMax);
118103
}
119104
return maxWidth;
@@ -122,15 +107,15 @@ function useChartLabelLayout({
122107
// Approximate truncated widths for hit-testing: exact for non-truncated labels,
123108
// at most ellipsisWidth px over for truncated ones — acceptable for bounding boxes.
124109
const truncatedLabelWidths = labelMaxWidths.map((maxW, i) => Math.min(labelWidths.at(i) ?? 0, maxW));
125-
const finalMaxWidth = Math.max(...truncatedLabelWidths);
126110

127111
let skipInterval = 1;
128112
if (rotation === LABEL_ROTATIONS.VERTICAL) {
129-
const verticalWidth = effectiveWidth(finalMaxWidth, lineHeight, rotation);
130-
const visibleCount = maxVisibleCount(labelAreaWidth, verticalWidth);
113+
const visibleCount = maxVisibleCount(labelAreaWidth, lineHeight);
131114
skipInterval = visibleCount >= data.length ? 1 : Math.ceil(data.length / Math.max(1, visibleCount));
132115
}
133116

117+
const finalMaxWidth = Math.max(...truncatedLabelWidths.filter((_, i) => i % skipInterval === 0));
118+
134119
const lastIndex = data.length - 1;
135120

136121
return {

0 commit comments

Comments
 (0)