Skip to content

Commit a871d06

Browse files
chihsuanclaudeCopilot
authored
Charts: Add labelOverflow ellipsis option for bar chart axis labels (#46656)
* Charts: Add labelOverflow ellipsis option for bar chart axis labels When bar charts are narrow, long categorical axis labels overlap and become unreadable. This adds a `labelOverflow: 'ellipsis'` option to axis configuration that truncates labels to fit the available bandwidth, with full text shown on hover via tooltip. Co-Authored-By: Claude Opus 4.5 <[email protected]> * Update changelog * Address Copilot PR feedback for TruncatedTickComponent - Fix textAlign mapping to handle 'middle' textAnchor (maps to 'center') - Fix xOffset calculation based on actual text alignment - Add JSDoc for MINI_TICK_LABEL_LENGTH constant - Only set title attribute when formattedValue has content (avoid empty tooltips) - Add aria-label for screen reader accessibility - Improve type safety by explicitly mapping compatible SVG text props to CSS Co-Authored-By: Claude Opus 4.5 <[email protected]> * Refactor bar chart axis options to improve labelOverflow handling - Extracted labelOverflow from axis options for both x and y axes. - Updated axis configuration to utilize extracted options for better readability and maintainability. - Ensured compatibility with the existing labelOverflow ellipsis feature. This change enhances the clarity of the axis configuration and prepares for future enhancements. * Enhance TruncatedTickComponent and add tests for label overflow handling - Renamed constant MINI_TICK_LABEL_LENGTH to MIN_TICK_LABEL_WIDTH for clarity. - Updated logic to use the new constant for determining maximum tick label width. - Introduced a comprehensive test suite for label overflow ellipsis functionality, ensuring proper rendering and accessibility features for long labels in bar charts. This update improves the readability of the code and ensures that long labels are handled gracefully in various chart orientations. * Document MIN_TICK_LABEL_WIDTH overlap behavior and mitigation strategies Add documentation explaining the trade-off when bandwidth is less than the minimum label width (20px): labels may overlap on very dense charts. Include mitigation strategies (numTicks, tickFormat, chart sizing). Co-Authored-By: Claude Opus 4.5 <[email protected]> * Add minor update to changelog for bar chart labelOverflow ellipsis option Corrected punctuation in the changelog entry for the labelOverflow ellipsis feature, ensuring clarity in the description of the functionality that truncates long axis labels for bar charts. * Refactor bar chart exports to streamline component usage Removed the export of TruncatedTickComponent from the bar chart index file, simplifying the module's interface and focusing on the essential exports for better maintainability. * Improve TruncatedTickComponent positioning for better label alignment - Adjusted the transform property to align HTML <div> elements within <foreignObject> to match SVG <text> vertical alignment. - Updated the position style to 'fixed' to enhance compatibility across browsers, particularly Safari. - Removed redundant transform logic to streamline the component's rendering. These changes enhance the visual consistency of tick labels in bar charts, ensuring they are displayed correctly across different environments. * Add doc comment * Update projects/js-packages/charts/src/charts/bar-chart/private/truncated-tick-component.tsx Co-authored-by: Copilot <[email protected]> * Update projects/js-packages/charts/src/charts/bar-chart/test/bar-chart.test.tsx Co-authored-by: Copilot <[email protected]> * Update projects/js-packages/charts/src/charts/bar-chart/stories/index.stories.tsx Co-authored-by: Copilot <[email protected]> * Update projects/js-packages/charts/src/charts/bar-chart/stories/index.stories.tsx Co-authored-by: Copilot <[email protected]> * Update projects/js-packages/charts/src/charts/bar-chart/private/truncated-tick-component.tsx Co-authored-by: Copilot <[email protected]> * Update projects/js-packages/charts/src/charts/bar-chart/private/truncated-tick-component.tsx Co-authored-by: Copilot <[email protected]> * Memoize tick components and conditionally apply Safari workaround - Pre-create TruncatedXTickComponent and TruncatedYTickComponent at module level to prevent component recreation on every render - Apply Safari position:fixed workaround only on Safari browsers using existing isSafari() utility - Update exports and imports to use memoized component instances Co-Authored-By: Claude Opus 4.5 <[email protected]> * Fix lint * Update projects/js-packages/charts/src/charts/bar-chart/private/truncated-tick-component.tsx Co-authored-by: Copilot <[email protected]> * Update projects/js-packages/charts/src/types.ts Co-authored-by: Copilot <[email protected]> * Update projects/js-packages/charts/src/charts/bar-chart/private/truncated-tick-component.tsx Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Claude Opus 4.5 <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent d1858ab commit a871d06

File tree

7 files changed

+384
-2
lines changed

7 files changed

+384
-2
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Add labelOverflow ellipsis option to truncate long axis labels for bar chart.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { useBarChartOptions } from './use-bar-chart-options';
2+
export { TruncatedXTickComponent, TruncatedYTickComponent } from './truncated-tick-component';
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { DataContext } from '@visx/xychart';
2+
import { useContext } from 'react';
3+
import { isSafari } from '../../../utils';
4+
import type { AxisScale, TickRendererProps } from '@visx/axis';
5+
import type { FC, CSSProperties } from 'react';
6+
7+
/**
8+
* Get the bandwidth of a scale
9+
*
10+
* @param scale - The scale to get the bandwidth of
11+
* @return The bandwidth of the scale
12+
*/
13+
const getScaleBandwidth = < Scale extends AxisScale >( scale?: Scale ) => {
14+
return scale && 'bandwidth' in scale ? scale.bandwidth() ?? 0 : 0;
15+
};
16+
interface TruncatedTickComponentProps extends TickRendererProps {
17+
/** Which axis this tick belongs to */
18+
axis: 'x' | 'y';
19+
}
20+
21+
/**
22+
* Minimum width in pixels for tick labels when scale bandwidth is very small.
23+
* Prevents labels from collapsing to unreadable widths on dense charts.
24+
*
25+
* Trade-off: When bandwidth is less than this minimum (e.g., many bars in a narrow chart),
26+
* adjacent labels may overlap since each label uses this minimum width regardless of
27+
* available space. This prioritizes label readability over preventing overlap.
28+
*
29+
* For very dense charts where overlap occurs, consider:
30+
* - Using `numTicks` option to reduce the number of displayed labels
31+
* - Using `tickFormat` to abbreviate label text
32+
* - Increasing chart width or reducing data points
33+
*/
34+
const MIN_TICK_LABEL_WIDTH = 20;
35+
36+
/**
37+
* A tick component that renders labels with text truncation (ellipsis) when they exceed
38+
* the available bandwidth. Shows the full text on hover via native title attribute.
39+
*
40+
* Uses foreignObject to embed HTML within SVG, enabling CSS text-overflow: ellipsis.
41+
* Inherits text styles from tickLabelProps passed by visx Axis component.
42+
*
43+
* Note: A minimum label width (MIN_TICK_LABEL_WIDTH) is enforced to keep labels readable.
44+
* On very dense charts where bandwidth < 20px, this may cause label overlap.
45+
* See MIN_TICK_LABEL_WIDTH documentation for mitigation strategies.
46+
*
47+
* @param props - The props for the truncated tick component
48+
* @param props.x - The x position of the tick
49+
* @param props.y - The y position of the tick
50+
* @param props.formattedValue - The formatted value of the tick
51+
* @param props.axis - The axis this tick belongs to
52+
* @param props.textAnchor - The text anchor of the tick
53+
* @param props.fill - The fill color of the tick
54+
* @param props.dy - The dy offset of the tick
55+
*
56+
* @return The truncated tick component
57+
*/
58+
export const TruncatedTickComponent: FC< TruncatedTickComponentProps > = ( {
59+
x,
60+
y,
61+
formattedValue,
62+
axis,
63+
textAnchor,
64+
fill,
65+
dy,
66+
...textProps
67+
} ) => {
68+
// Get max width of the tick label
69+
const { xScale, yScale } = useContext( DataContext ) || {};
70+
const scale = axis === 'x' ? xScale : yScale;
71+
const bandwidth = getScaleBandwidth( scale );
72+
const maxWidth = Math.max( bandwidth, MIN_TICK_LABEL_WIDTH );
73+
74+
// Map SVG textAnchor to CSS textAlign
75+
let textAlign: 'left' | 'right' | 'center' = 'center';
76+
if ( textAnchor === 'start' ) {
77+
textAlign = 'left';
78+
} else if ( textAnchor === 'end' ) {
79+
textAlign = 'right';
80+
} else if ( textAnchor === 'middle' ) {
81+
textAlign = 'center';
82+
}
83+
84+
// Calculate x offset based on text alignment
85+
let xOffset = 0;
86+
if ( textAlign === 'center' ) {
87+
xOffset = -maxWidth / 2;
88+
} else if ( textAlign === 'right' ) {
89+
xOffset = -maxWidth;
90+
}
91+
92+
// Extract compatible style properties from SVG text props
93+
const { fontSize, fontFamily, fontWeight, fontStyle, letterSpacing, opacity } = textProps as {
94+
fontSize?: CSSProperties[ 'fontSize' ];
95+
fontFamily?: CSSProperties[ 'fontFamily' ];
96+
fontWeight?: CSSProperties[ 'fontWeight' ];
97+
fontStyle?: CSSProperties[ 'fontStyle' ];
98+
letterSpacing?: CSSProperties[ 'letterSpacing' ];
99+
opacity?: CSSProperties[ 'opacity' ];
100+
};
101+
102+
const textStyles: CSSProperties = {
103+
/**
104+
* SVG <text> elements are vertically aligned to the baseline by default, but HTML <div> elements inside <foreignObject>
105+
* are positioned relative to the top-left corner. To visually align the tick label like SVG text,
106+
* we shift the div up by 100% of its height and adjust by twice the SVG dy value (from visx) to approximate original placement.
107+
*/
108+
transform: `translateY(calc(-100% + ${ dy ?? '0' } * 2))`,
109+
// Safari doesn't work well with foreignObject positioning. Use position: fixed as a workaround.
110+
...( isSafari() ? { position: 'fixed' as const } : {} ),
111+
// Apply compatible SVG text styles
112+
fontSize,
113+
fontFamily,
114+
fontWeight,
115+
fontStyle,
116+
letterSpacing,
117+
opacity,
118+
// Convert svg text styles to CSS styles for the div
119+
color: fill ?? 'inherit',
120+
textAlign,
121+
// Ensure text is truncated with ellipsis, remains on one line, and shows the full value in a tooltip on hover.
122+
// The surrounding div uses CSS to handle overflow, and the 'title' attribute is set for accessibility.
123+
width: maxWidth,
124+
overflow: 'hidden',
125+
textOverflow: 'ellipsis',
126+
whiteSpace: 'nowrap',
127+
cursor: 'default',
128+
pointerEvents: 'auto',
129+
};
130+
131+
return (
132+
<foreignObject x={ x + xOffset } y={ y } width={ maxWidth } height={ 0 } overflow="visible">
133+
<div style={ textStyles } title={ formattedValue }>
134+
{ formattedValue }
135+
</div>
136+
</foreignObject>
137+
);
138+
};
139+
140+
/**
141+
* Factory function to create a truncated tick component for a specific axis.
142+
* Returns a component that can be passed to visx's tickComponent prop.
143+
*
144+
* @param axis - The axis this tick component is for ('x' or 'y')
145+
* @return A tick component function compatible with visx's TickRendererProps
146+
*/
147+
const createTruncatedTickComponent = ( axis: 'x' | 'y' ) => ( props: TickRendererProps ) => {
148+
return <TruncatedTickComponent { ...props } axis={ axis } />;
149+
};
150+
151+
/**
152+
* Pre-created tick components for x and y axes.
153+
* These functions are created once at module initialization and reused,
154+
* avoiding repeated factory calls when configuring axes.
155+
*/
156+
export const TruncatedXTickComponent = createTruncatedTickComponent( 'x' );
157+
export const TruncatedYTickComponent = createTruncatedTickComponent( 'y' );

projects/js-packages/charts/src/charts/bar-chart/private/use-bar-chart-options.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { formatNumberCompact } from '@automattic/number-formatters';
22
import { useMemo } from 'react';
3+
import { TruncatedXTickComponent, TruncatedYTickComponent } from './truncated-tick-component';
34
import type { EnhancedDataPoint } from '../../../hooks/use-zero-value-display';
45
import type { DataPointDate, BaseChartProps, SeriesData } from '../../../types';
56
import type { TickFormatter } from '@visx/axis';
@@ -102,6 +103,9 @@ export function useBarChartOptions(
102103
? options.axis?.y?.tickFormat
103104
: options.axis?.x?.tickFormat;
104105

106+
const { labelOverflow: xLabelOverflow, ...xAxisOptions } = options.axis?.x || {};
107+
const { labelOverflow: yLabelOverflow, ...yAxisOptions } = options.axis?.y || {};
108+
105109
return {
106110
gridVisibility,
107111
xScale,
@@ -115,13 +119,15 @@ export function useBarChartOptions(
115119
orientation: 'bottom' as const,
116120
numTicks: 4,
117121
tickFormat: xTickFormat,
118-
...( options.axis?.x || {} ),
122+
...( xLabelOverflow === 'ellipsis' ? { tickComponent: TruncatedXTickComponent } : {} ),
123+
...xAxisOptions,
119124
},
120125
y: {
121126
orientation: 'left' as const,
122127
numTicks: 4,
123128
tickFormat: yTickFormat,
124-
...( options.axis?.y || {} ),
129+
...( yLabelOverflow === 'ellipsis' ? { tickComponent: TruncatedYTickComponent } : {} ),
130+
...yAxisOptions,
125131
},
126132
},
127133
barGroup: {

projects/js-packages/charts/src/charts/bar-chart/stories/index.stories.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,3 +419,64 @@ export const ZeroValueComparison: StoryObj< typeof BarChart > = {
419419
},
420420
},
421421
};
422+
423+
// Data with long categorical labels to demonstrate overlapping issue
424+
const longLabelData = [
425+
{
426+
group: 'sales',
427+
label: 'Sales by Channel',
428+
data: [
429+
{ label: 'Organic Search Traffic', value: 12500 },
430+
{ label: 'Paid Advertising Campaign', value: 8750 },
431+
{ label: 'Social Media Marketing', value: 6250 },
432+
{ label: 'Email Newsletter Subscribers', value: 4375 },
433+
{ label: 'Direct Website Visitors', value: 3125 },
434+
{ label: 'Affiliate Partner Referrals', value: 2500 },
435+
],
436+
},
437+
];
438+
439+
export const LabelOverflowEllipsis: StoryObj< typeof BarChart > = {
440+
render: () => (
441+
<div style={ { display: 'grid', gap: '40px' } }>
442+
<div>
443+
<h3>Without labelOverflow (Default - Labels Overlap)</h3>
444+
<p style={ { marginBottom: '20px', color: '#666' } }>
445+
Default behavior: long labels overlap and become unreadable at narrow widths.
446+
</p>
447+
<div style={ { width: '350px', height: '250px', border: '1px solid #e0e0e0' } }>
448+
<BarChart data={ longLabelData } withTooltips={ true } gridVisibility="x" />
449+
</div>
450+
</div>
451+
<div>
452+
<h3>With labelOverflow: &apos;ellipsis&apos; (Labels Truncated)</h3>
453+
<p style={ { marginBottom: '20px', color: '#666' } }>
454+
With <code>labelOverflow: &apos;ellipsis&apos;</code>, labels are truncated to fit the
455+
available bandwidth. <strong>Hover over a label to see the full text.</strong>
456+
</p>
457+
<div style={ { width: '350px', height: '250px', border: '1px solid #e0e0e0' } }>
458+
<BarChart
459+
data={ longLabelData }
460+
withTooltips={ true }
461+
gridVisibility="x"
462+
options={ {
463+
axis: {
464+
x: {
465+
labelOverflow: 'ellipsis',
466+
},
467+
},
468+
} }
469+
/>
470+
</div>
471+
</div>
472+
</div>
473+
),
474+
parameters: {
475+
docs: {
476+
description: {
477+
story:
478+
"Demonstrates the `labelOverflow: 'ellipsis'` option that truncates long axis labels to fit the available bandwidth. The full label text is shown on hover via a native tooltip. This is useful for narrow widget contexts where space is limited.",
479+
},
480+
},
481+
},
482+
};

0 commit comments

Comments
 (0)