From 5887c567acd4d9e35b3ef1c7e3818238ef9f4310 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 9 Sep 2026 11:20:26 +0800 Subject: [PATCH 1/4] fix: keep the first and last time-axis tick labels inside the chart --- .../charts/changelog/uni-753-axis-edge-labels | 4 + .../src/hooks/test/use-chart-margin.test.tsx | 82 +++++++++++++++++++ .../charts/src/hooks/use-chart-margin.tsx | 33 +++++++- .../charts/src/utils/get-edge-tick-widths.ts | 30 +++++++ .../js-packages/charts/src/utils/index.ts | 1 + .../utils/test/get-edge-tick-widths.test.ts | 46 +++++++++++ .../js-packages/charts/tests/jest.config.cjs | 1 + .../charts/tests/setup-text-measurement.js | 12 +++ .../changelog/uni-753-axis-edge-labels | 4 + .../__tests__/comparative-bar-chart.test.tsx | 3 +- .../comparative-bar-chart.tsx | 9 +- .../comparative-line-chart.tsx | 9 +- .../changelog/uni-753-axis-edge-labels | 4 + .../changelog/uni-753-axis-edge-labels | 4 + 14 files changed, 226 insertions(+), 16 deletions(-) create mode 100644 projects/js-packages/charts/changelog/uni-753-axis-edge-labels create mode 100644 projects/js-packages/charts/src/utils/get-edge-tick-widths.ts create mode 100644 projects/js-packages/charts/src/utils/test/get-edge-tick-widths.test.ts create mode 100644 projects/js-packages/charts/tests/setup-text-measurement.js create mode 100644 projects/packages/premium-analytics/changelog/uni-753-axis-edge-labels create mode 100644 projects/plugins/jetpack/changelog/uni-753-axis-edge-labels create mode 100644 projects/plugins/premium-analytics/changelog/uni-753-axis-edge-labels diff --git a/projects/js-packages/charts/changelog/uni-753-axis-edge-labels b/projects/js-packages/charts/changelog/uni-753-axis-edge-labels new file mode 100644 index 000000000000..9ee4ed97296a --- /dev/null +++ b/projects/js-packages/charts/changelog/uni-753-axis-edge-labels @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Charts: Reserve room for the first and last labels on a time axis so they are no longer cut off at the chart's edge. diff --git a/projects/js-packages/charts/src/hooks/test/use-chart-margin.test.tsx b/projects/js-packages/charts/src/hooks/test/use-chart-margin.test.tsx index 95f05c73eb2d..fab437edf998 100644 --- a/projects/js-packages/charts/src/hooks/test/use-chart-margin.test.tsx +++ b/projects/js-packages/charts/src/hooks/test/use-chart-margin.test.tsx @@ -9,6 +9,13 @@ jest.mock( '../../utils/get-longest-tick-width', () => ( { getLongestTickWidth: ( ...args: unknown[] ) => mockGetLongestTickWidth( ...args ), } ) ); +// jsdom has no getComputedTextLength, so the real measurement always returns null. +const mockGetEdgeTickWidths = jest.fn(); +jest.mock( '../../utils/get-edge-tick-widths', () => ( { + ...jest.requireActual( '../../utils/get-edge-tick-widths' ), + getEdgeTickWidths: ( ...args: unknown[] ) => mockGetEdgeTickWidths( ...args ), +} ) ); + describe( 'useChartMargin', () => { const baseTheme = { axisStyles: { @@ -43,6 +50,8 @@ describe( 'useChartMargin', () => { beforeEach( () => { mockGetLongestTickWidth.mockReset(); mockGetLongestTickWidth.mockReturnValue( 40 ); + mockGetEdgeTickWidths.mockReset(); + mockGetEdgeTickWidths.mockReturnValue( { first: null, last: null } ); } ); it( 'calculates left margin for left y axis', () => { @@ -185,6 +194,79 @@ describe( 'useChartMargin', () => { expect( result.current.bottom ).toBe( 25 ); } ); + describe( 'x-axis edge tick labels', () => { + const tickFormat = ( value: number ) => new Date( value ).toDateString(); + const tickValues = [ 1, 2, 3 ]; + const datedXOptions = ( xOverrides = {} ) => ( { + ...optionsBase, + axis: { ...optionsBase.axis, x: { tickValues, tickFormat, ...xOverrides } }, + } ); + + it( 'reserves half of the last label on the right', () => { + mockGetEdgeTickWidths.mockReturnValue( { first: null, last: 60 } ); + + const { result } = renderHook( () => + useChartMargin( 300, datedXOptions(), data, baseTheme ) + ); + + expect( result.current.right ).toBe( 30 ); + } ); + + it( 'keeps the default right margin when the last label fits inside it', () => { + mockGetEdgeTickWidths.mockReturnValue( { first: null, last: 30 } ); + + const { result } = renderHook( () => + useChartMargin( 300, datedXOptions(), data, baseTheme ) + ); + + expect( result.current.right ).toBe( 20 ); + } ); + + it( 'widens the left margin past the y-axis reservation when the first label needs it', () => { + mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: null } ); + + const { result } = renderHook( () => + useChartMargin( 300, datedXOptions(), data, baseTheme ) + ); + + // 60 for the label's overhanging half, over the 51 the y-axis ticks need. + expect( result.current.left ).toBe( 60 ); + } ); + + it( 'measures the axis tick values with the x tick label style', () => { + const theme = { + ...baseTheme, + axisStyles: { + ...baseTheme.axisStyles, + x: { + bottom: { tickLabel: { fontSize: 11 }, tickLength: 8 } as unknown as never, + top: {} as unknown as never, + }, + }, + } as XYChartTheme; + + renderHook( () => useChartMargin( 300, datedXOptions(), data, theme ) ); + + // px, not the theme's bare 11: CSSOM drops a unitless length, so the + // measurer would silently fall back to the host's font size. + expect( mockGetEdgeTickWidths ).toHaveBeenCalledWith( tickValues, tickFormat, { + fontSize: '11px', + } ); + } ); + + it( 'reserves nothing for a hidden x axis', () => { + mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 60 } ); + + const { result } = renderHook( () => + useChartMargin( 300, datedXOptions( { display: false } ), data, baseTheme ) + ); + + expect( mockGetEdgeTickWidths ).not.toHaveBeenCalled(); + expect( result.current.right ).toBe( 20 ); + expect( result.current.left ).toBe( 51 ); + } ); + } ); + describe( 'horizontal y ticks', () => { const horizontalOptions = ( tickFormat: ( value: string | number ) => string ) => ( { ...optionsBase, diff --git a/projects/js-packages/charts/src/hooks/use-chart-margin.tsx b/projects/js-packages/charts/src/hooks/use-chart-margin.tsx index 7b022ddf0ec0..3c68d503bf32 100644 --- a/projects/js-packages/charts/src/hooks/use-chart-margin.tsx +++ b/projects/js-packages/charts/src/hooks/use-chart-margin.tsx @@ -1,6 +1,6 @@ import { createScale, getTicks } from '@visx/scale'; import { useMemo } from 'react'; -import { getLongestTickWidth, resolveFontSize } from '../utils'; +import { getEdgeTickWidths, getLongestTickWidth, resolveFontSize } from '../utils'; import type { BaseChartProps, DataPointDate, SeriesData } from '../types'; import type { XYChartTheme } from '@visx/xychart'; @@ -61,7 +61,17 @@ const getXAxisLabelMetrics = ( theme: XYChartTheme, orientation: 'top' | 'bottom const tickLength = xAxisStyles?.tickLength ?? DEFAULT_TICK_LENGTH; - return { fontSize, tickLength }; + // The measurer assigns this through CSSOM, which drops a unitless length, so + // the theme's plain-number fontSize has to carry its unit or labels measure + // at whatever the host body inherits. + const tickLabel = xAxisStyles?.tickLabel; + const tickLabelFontSize = resolveFontSize( tickLabel?.fontSize ); + const tickLabelStyle = + tickLabel && tickLabelFontSize !== undefined + ? { ...tickLabel, fontSize: `${ tickLabelFontSize }px` } + : tickLabel; + + return { fontSize, tickLength, tickLabelStyle }; }; export const useChartMargin = ( @@ -135,7 +145,7 @@ export const useChartMargin = ( // This mirrors Y-axis behavior where margin is based on label size and tick length, // but keeps the padding minimal so consumers can control container spacing themselves. const xOrientation = options.axis?.x?.orientation === 'top' ? 'top' : 'bottom'; - const { fontSize, tickLength } = getXAxisLabelMetrics( theme, xOrientation ); + const { fontSize, tickLength, tickLabelStyle } = getXAxisLabelMetrics( theme, xOrientation ); const computedXMargin = fontSize + tickLength; if ( xOrientation === 'top' ) { @@ -145,6 +155,23 @@ export const useChartMargin = ( defaultMargin.bottom = Math.max( defaultMargin.bottom, computedXMargin ); } + // An X-axis label is centered on its tick, so the ones at either end of the + // scale hang half their width outside the plot area and clip at the SVG edge. + if ( options.axis?.x?.display !== false ) { + const { first, last } = getEdgeTickWidths( + options.axis?.x?.tickValues ?? [], + options.axis?.x?.tickFormat, + tickLabelStyle + ); + + if ( first !== null ) { + defaultMargin.left = Math.max( defaultMargin.left, Math.ceil( first / 2 ) ); + } + if ( last !== null ) { + defaultMargin.right = Math.max( defaultMargin.right, Math.ceil( last / 2 ) ); + } + } + return defaultMargin; }, [ options, theme, yTicks ] ); }; diff --git a/projects/js-packages/charts/src/utils/get-edge-tick-widths.ts b/projects/js-packages/charts/src/utils/get-edge-tick-widths.ts new file mode 100644 index 000000000000..1cdf2ffcd331 --- /dev/null +++ b/projects/js-packages/charts/src/utils/get-edge-tick-widths.ts @@ -0,0 +1,30 @@ +import { getStringWidth } from '@visx/text'; +import type { TickFormatter } from '@visx/axis'; +import type { AnyD3Scale, ScaleInput } from '@visx/scale'; + +/** + * Rendered widths of the first and last tick labels on an axis. + * + * @param ticks - Tick values, in axis order. + * @param formatTick - Function to format a tick. + * @param {object} labelStyle - Style object for the label. + * @return {object} - Widths in pixels, null for a label that cannot be measured. + */ +export const getEdgeTickWidths = < T extends AnyD3Scale >( + ticks: ScaleInput< T >[], + formatTick?: TickFormatter< ScaleInput< T > >, + labelStyle?: object +): { first: number | null; last: number | null } => { + if ( ! ticks.length ) { + return { first: null, last: null }; + } + + const lastIndex = ticks.length - 1; + const label = ( tick: ScaleInput< T >, index: number ) => + String( formatTick ? formatTick( tick, index, [] ) ?? '' : tick ); + + return { + first: getStringWidth( label( ticks[ 0 ], 0 ), labelStyle ), + last: getStringWidth( label( ticks[ lastIndex ], lastIndex ), labelStyle ), + }; +}; diff --git a/projects/js-packages/charts/src/utils/index.ts b/projects/js-packages/charts/src/utils/index.ts index 3ffe35a2c60c..6bf1aed84a5a 100644 --- a/projects/js-packages/charts/src/utils/index.ts +++ b/projects/js-packages/charts/src/utils/index.ts @@ -13,6 +13,7 @@ export type { MetricValueType } from './format-metric-value'; export { formatPercentage } from './format-percentage'; // Chart measurement utilities +export { getEdgeTickWidths } from './get-edge-tick-widths'; export { getLongestTickWidth } from './get-longest-tick-width'; // Style and theming utilities diff --git a/projects/js-packages/charts/src/utils/test/get-edge-tick-widths.test.ts b/projects/js-packages/charts/src/utils/test/get-edge-tick-widths.test.ts new file mode 100644 index 000000000000..443f4295e747 --- /dev/null +++ b/projects/js-packages/charts/src/utils/test/get-edge-tick-widths.test.ts @@ -0,0 +1,46 @@ +import { getEdgeTickWidths } from '../get-edge-tick-widths'; + +const mockGetStringWidth = jest.fn(); +jest.mock( '@visx/text', () => ( { + ...jest.requireActual( '@visx/text' ), + getStringWidth: ( ...args: unknown[] ) => mockGetStringWidth( ...args ), +} ) ); + +describe( 'getEdgeTickWidths', () => { + beforeEach( () => { + mockGetStringWidth.mockReset(); + mockGetStringWidth.mockImplementation( ( label: string ) => label.length ); + } ); + + it( 'measures the formatted first and last ticks', () => { + const style = { fontSize: 11 }; + + const widths = getEdgeTickWidths( [ 1, 2, 3 ], value => `tick-${ value }`, style ); + + expect( widths ).toEqual( { first: 6, last: 6 } ); + expect( mockGetStringWidth ).toHaveBeenCalledWith( 'tick-1', style ); + expect( mockGetStringWidth ).toHaveBeenCalledWith( 'tick-3', style ); + } ); + + it( 'passes each tick its own index', () => { + getEdgeTickWidths( [ 'a', 'b', 'c' ], ( value, index ) => `${ index }:${ value }` ); + + expect( mockGetStringWidth ).toHaveBeenCalledWith( '0:a', undefined ); + expect( mockGetStringWidth ).toHaveBeenCalledWith( '2:c', undefined ); + } ); + + it( 'measures a single tick as both edges', () => { + expect( getEdgeTickWidths( [ 42 ], value => `${ value }` ) ).toEqual( { first: 2, last: 2 } ); + } ); + + it( 'measures the raw value when there is no formatter', () => { + getEdgeTickWidths( [ 'abcd' ] ); + + expect( mockGetStringWidth ).toHaveBeenCalledWith( 'abcd', undefined ); + } ); + + it( 'returns nothing to reserve for an axis with no ticks', () => { + expect( getEdgeTickWidths( [] ) ).toEqual( { first: null, last: null } ); + expect( mockGetStringWidth ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/projects/js-packages/charts/tests/jest.config.cjs b/projects/js-packages/charts/tests/jest.config.cjs index 19555a4c2405..bea985acf1ee 100644 --- a/projects/js-packages/charts/tests/jest.config.cjs +++ b/projects/js-packages/charts/tests/jest.config.cjs @@ -21,5 +21,6 @@ module.exports = { setupFilesAfterEnv: [ ...( baseConfig.setupFilesAfterEnv || [] ), path.join( __dirname, 'setup-element-size-mock.js' ), + path.join( __dirname, 'setup-text-measurement.js' ), ], }; diff --git a/projects/js-packages/charts/tests/setup-text-measurement.js b/projects/js-packages/charts/tests/setup-text-measurement.js new file mode 100644 index 000000000000..60846e1e96f3 --- /dev/null +++ b/projects/js-packages/charts/tests/setup-text-measurement.js @@ -0,0 +1,12 @@ +/** + * Hide visx's text-measurement node from Testing Library's text queries. + * + * `@visx/text`'s getStringWidth measures by parking the string in a shared, + * offscreen node on document.body and leaving it there. Whatever the + * axis measured last therefore answers `getByText` a second time — an axis + * label measured for the chart margin collides with the tick that renders it. + */ + +const { configure } = require( '@testing-library/react' ); + +configure( { defaultIgnore: 'script, style, #__react_svg_text_measurement_id' } ); diff --git a/projects/packages/premium-analytics/changelog/uni-753-axis-edge-labels b/projects/packages/premium-analytics/changelog/uni-753-axis-edge-labels new file mode 100644 index 000000000000..c8760a88bf1e --- /dev/null +++ b/projects/packages/premium-analytics/changelog/uni-753-axis-edge-labels @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Stop the last date on a chart's horizontal axis from being cut off. diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/__tests__/comparative-bar-chart.test.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/__tests__/comparative-bar-chart.test.tsx index bd7939d69d67..0a715a6ad77f 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/__tests__/comparative-bar-chart.test.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/__tests__/comparative-bar-chart.test.tsx @@ -490,7 +490,8 @@ describe( 'ComparativeBarChart', () => { render( ); expect( recordedOptions() ).not.toHaveProperty( 'yScale' ); - expect( recordedProps().margin ).toEqual( { right: 0 } ); + // No override, so the chart keeps the gutters `useChartMargin` measured. + expect( recordedProps().margin ).toBeUndefined(); } ); } ); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/comparative-bar-chart.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/comparative-bar-chart.tsx index c1a82f128371..57bc12c92e81 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/comparative-bar-chart.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/comparative-bar-chart.tsx @@ -36,9 +36,6 @@ import type { ComparativeDatePointDate } from '../chart-comparative-line/types'; import type { TooltipStyle } from '../chart-tooltip'; import type { ComponentProps } from 'react'; -/** The y-axis is on the left, so the right margin is always 0. */ -const DEFAULT_MARGIN = { right: 0 }; - /** * Chart-area height (px) below which `compactWhenShort` degrades the chart to * a sparkline (no y-axis, grid, or legend). Matches the comparative line chart @@ -320,12 +317,12 @@ export function ComparativeBarChart( { }, [ xTickFormat, tickResolution, yTickFormat, isCompact, fixedYAxis ] ); const margin = useMemo( () => { - // With the y-axis hidden, reclaim its reserved left margin for the bars. + // A sparkline is full bleed: it hides both axes and gives their gutters to the bars. if ( isCompact ) { - return { ...DEFAULT_MARGIN, left: 0 }; + return { right: 0, left: 0 }; } - return fixedYAxis ? { ...DEFAULT_MARGIN, left: fixedYAxis.marginLeft } : DEFAULT_MARGIN; + return fixedYAxis ? { left: fixedYAxis.marginLeft } : undefined; }, [ isCompact, fixedYAxis ] ); return ( diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx index 124a60c3e033..a7cd753125cb 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx @@ -56,9 +56,6 @@ function resolveSeriesStyles( } ); } -/** The y-axis is on the left, so the right margin is always 0. */ -const DEFAULT_MARGIN = { right: 0 }; - /** * Chart-area height (px) below which `compactWhenShort` degrades the chart to * a sparkline (no y-axis, grid, or legend). @@ -281,7 +278,7 @@ export function ComparativeLineChart( { return { ...baseOptions, yScale: { domain: fixedYAxis.domain } }; }, [ xTickFormat, xTickFormatType, tickResolution, yTickFormat, fixedYAxis, isCompact ] ); - const margin = fixedYAxis ? { ...DEFAULT_MARGIN, left: fixedYAxis.marginLeft } : DEFAULT_MARGIN; + const margin = fixedYAxis ? { left: fixedYAxis.marginLeft } : undefined; return ( @@ -292,8 +289,8 @@ export function ComparativeLineChart( { options={ chartOptions } defaultHiddenSeries={ defaultHiddenSeries } legend={ legendConfig } - // With the y-axis hidden, reclaim its reserved left margin for the line. - margin={ isCompact ? { ...margin, left: 0 } : margin } + // A sparkline is full bleed: it hides both axes and gives their gutters to the line. + margin={ isCompact ? { right: 0, left: 0 } : margin } maxWidth={ maxWidth } gridVisibility={ isCompact ? 'none' : undefined } resizeDebounceTime={ RESIZE_DEBOUNCE_MS } diff --git a/projects/plugins/jetpack/changelog/uni-753-axis-edge-labels b/projects/plugins/jetpack/changelog/uni-753-axis-edge-labels new file mode 100644 index 000000000000..c4d58807996b --- /dev/null +++ b/projects/plugins/jetpack/changelog/uni-753-axis-edge-labels @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +Premium Analytics: Stop the last date on a chart's horizontal axis from being cut off. diff --git a/projects/plugins/premium-analytics/changelog/uni-753-axis-edge-labels b/projects/plugins/premium-analytics/changelog/uni-753-axis-edge-labels new file mode 100644 index 000000000000..c8760a88bf1e --- /dev/null +++ b/projects/plugins/premium-analytics/changelog/uni-753-axis-edge-labels @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Stop the last date on a chart's horizontal axis from being cut off. From b0ffb4ea293cd755456baa30769fbb3a9d2949b8 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 9 Sep 2026 14:49:22 +0800 Subject: [PATCH 2/4] fix: size chart gutters from the axes as rendered, and drop PA's overrides useChartMargin measured the y gutter from the data's own min/max, so a caller-pinned domain was sized for narrower labels than the axis renders. Premium Analytics worked around that by passing its own margin.left, which in turn discarded the reservation the first x-axis label needs. Measure the pinned domain instead, reserve nothing for a hidden y axis, and drop both margin overrides so sparklines keep their date labels. The px-normalisation now covers letterSpacing and the y axis too, and no longer claims CSSOM drops a bare number, which it does not on an SVG . --- .../charts/changelog/uni-753-axis-edge-labels | 2 +- .../charts/pie-chart/test/pie-chart.test.tsx | 15 +-- .../src/hooks/test/use-chart-margin.test.tsx | 106 ++++++++++++++++-- .../charts/src/hooks/use-chart-margin.tsx | 64 +++++++---- .../charts/src/utils/get-edge-tick-widths.ts | 13 ++- .../utils/test/get-edge-tick-widths.test.ts | 11 +- .../changelog/uni-753-axis-edge-labels | 2 +- .../__tests__/comparative-bar-chart.test.tsx | 14 ++- .../comparative-bar-chart.tsx | 18 +-- .../__tests__/comparative-line-chart.test.tsx | 50 ++++++++- .../comparative-line-chart.tsx | 12 +- .../src/helpers/fixed-y-axis.ts | 23 +--- .../changelog/uni-753-axis-edge-labels | 2 +- .../changelog/uni-753-axis-edge-labels | 2 +- .../social/changelog/uni-753-axis-edge-labels | 4 + .../changelog/uni-753-axis-edge-labels | 4 + 16 files changed, 242 insertions(+), 100 deletions(-) create mode 100644 projects/plugins/social/changelog/uni-753-axis-edge-labels create mode 100644 projects/plugins/videopress/changelog/uni-753-axis-edge-labels diff --git a/projects/js-packages/charts/changelog/uni-753-axis-edge-labels b/projects/js-packages/charts/changelog/uni-753-axis-edge-labels index 9ee4ed97296a..ea178eaad327 100644 --- a/projects/js-packages/charts/changelog/uni-753-axis-edge-labels +++ b/projects/js-packages/charts/changelog/uni-753-axis-edge-labels @@ -1,4 +1,4 @@ Significance: patch Type: fixed -Charts: Reserve room for the first and last labels on a time axis so they are no longer cut off at the chart's edge. +Reserve room for the first and last labels on a time axis, size the y-axis gutter from a pinned domain, and reserve nothing for a hidden y axis. diff --git a/projects/js-packages/charts/src/charts/pie-chart/test/pie-chart.test.tsx b/projects/js-packages/charts/src/charts/pie-chart/test/pie-chart.test.tsx index 5c59e82a773a..564d7de64851 100644 --- a/projects/js-packages/charts/src/charts/pie-chart/test/pie-chart.test.tsx +++ b/projects/js-packages/charts/src/charts/pie-chart/test/pie-chart.test.tsx @@ -123,18 +123,9 @@ describe( 'PieChart', () => { test( 'hides labels when showLabels is false', () => { renderWithTheme( { showLabels: false } ); - // When showLabels is false, the chart should not display the data labels - // We filter out measurement elements by checking that text is not inside measurement element - const labelElements = screen.queryAllByText( ( content, element ) => { - // Check if this text element is not the measurement element - return ( - ( content === 'A' || content === 'B' ) && - element?.id !== '__react_svg_text_measurement_id' - ); - } ); - - // Labels should not be present in the rendered output (excluding measurement text) - expect( labelElements ).toHaveLength( 0 ); + // A plain query, so this also fails if the measurement node stops being + // ignored — see tests/setup-text-measurement.js. + expect( screen.queryAllByText( /^[AB]$/ ) ).toHaveLength( 0 ); } ); test( 'shows labels when showLabels is explicitly true', () => { diff --git a/projects/js-packages/charts/src/hooks/test/use-chart-margin.test.tsx b/projects/js-packages/charts/src/hooks/test/use-chart-margin.test.tsx index fab437edf998..aba406b2be5f 100644 --- a/projects/js-packages/charts/src/hooks/test/use-chart-margin.test.tsx +++ b/projects/js-packages/charts/src/hooks/test/use-chart-margin.test.tsx @@ -51,7 +51,7 @@ describe( 'useChartMargin', () => { mockGetLongestTickWidth.mockReset(); mockGetLongestTickWidth.mockReturnValue( 40 ); mockGetEdgeTickWidths.mockReset(); - mockGetEdgeTickWidths.mockReturnValue( { first: null, last: null } ); + mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 0 } ); } ); it( 'calculates left margin for left y axis', () => { @@ -71,7 +71,7 @@ describe( 'useChartMargin', () => { expect( mockGetLongestTickWidth ).toHaveBeenCalledWith( expect.any( Array ), options.axis.y.tickFormat, - theme.axisStyles.y.left.axisLabel + { fontSize: '12px' } ); // 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset expect( result.current.left ).toBe( 51 ); @@ -94,7 +94,7 @@ describe( 'useChartMargin', () => { expect( mockGetLongestTickWidth ).toHaveBeenCalledWith( expect.any( Array ), options.axis.y.tickFormat, - theme.axisStyles.y.right.axisLabel + { fontSize: '12px' } ); // 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset expect( result.current.right ).toBe( 51 ); @@ -117,7 +117,7 @@ describe( 'useChartMargin', () => { expect( mockGetLongestTickWidth ).toHaveBeenCalledWith( [ 0, 1000 ], options.axis.y.tickFormat, - theme.axisStyles.y.left.axisLabel + { fontSize: '12px' } ); } ); @@ -203,7 +203,7 @@ describe( 'useChartMargin', () => { } ); it( 'reserves half of the last label on the right', () => { - mockGetEdgeTickWidths.mockReturnValue( { first: null, last: 60 } ); + mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 60 } ); const { result } = renderHook( () => useChartMargin( 300, datedXOptions(), data, baseTheme ) @@ -213,7 +213,7 @@ describe( 'useChartMargin', () => { } ); it( 'keeps the default right margin when the last label fits inside it', () => { - mockGetEdgeTickWidths.mockReturnValue( { first: null, last: 30 } ); + mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 30 } ); const { result } = renderHook( () => useChartMargin( 300, datedXOptions(), data, baseTheme ) @@ -223,7 +223,7 @@ describe( 'useChartMargin', () => { } ); it( 'widens the left margin past the y-axis reservation when the first label needs it', () => { - mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: null } ); + mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 0 } ); const { result } = renderHook( () => useChartMargin( 300, datedXOptions(), data, baseTheme ) @@ -247,13 +247,41 @@ describe( 'useChartMargin', () => { renderHook( () => useChartMargin( 300, datedXOptions(), data, theme ) ); - // px, not the theme's bare 11: CSSOM drops a unitless length, so the - // measurer would silently fall back to the host's font size. expect( mockGetEdgeTickWidths ).toHaveBeenCalledWith( tickValues, tickFormat, { fontSize: '11px', } ); } ); + it( 'falls back to the raw tick label style when its font size is a relative unit', () => { + const theme = { + ...baseTheme, + axisStyles: { + ...baseTheme.axisStyles, + x: { + bottom: { tickLabel: { fontSize: '0.875rem' }, tickLength: 8 } as unknown as never, + top: {} as unknown as never, + }, + }, + } as XYChartTheme; + + renderHook( () => useChartMargin( 300, datedXOptions(), data, theme ) ); + + expect( mockGetEdgeTickWidths ).toHaveBeenCalledWith( tickValues, tickFormat, { + fontSize: '0.875rem', + } ); + } ); + + it( 'reserves the edge labels on a top x axis too', () => { + mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 60 } ); + + const { result } = renderHook( () => + useChartMargin( 300, datedXOptions( { orientation: 'top' } ), data, baseTheme ) + ); + + expect( result.current.right ).toBe( 30 ); + expect( result.current.left ).toBe( 60 ); + } ); + it( 'reserves nothing for a hidden x axis', () => { mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 60 } ); @@ -267,6 +295,66 @@ describe( 'useChartMargin', () => { } ); } ); + describe( 'real measurement', () => { + // Everything else here mocks the measurer out; this block runs it for real, + // so that a width that never reaches the margin would fail something. + const actual = jest.requireActual( '../../utils/get-edge-tick-widths' ); + type Measurable = { getComputedTextLength?: () => number }; + + afterEach( () => { + delete ( window.SVGElement.prototype as Measurable ).getComputedTextLength; + } ); + + it( 'turns a measured edge label into a reserved margin', () => { + // jsdom ships no getComputedTextLength, so @visx/text cannot measure at all. + ( window.SVGElement.prototype as Measurable ).getComputedTextLength = function ( + this: SVGElement + ) { + return ( this.textContent ?? '' ).length * 8; + }; + mockGetEdgeTickWidths.mockImplementation( actual.getEdgeTickWidths ); + + const options = { + ...optionsBase, + axis: { + ...optionsBase.axis, + x: { + tickValues: [ 1, 2 ], + tickFormat: ( _value: number, index: number ) => + index === 0 ? 'AA' : 'MEASURED-LAST', + }, + }, + }; + + const { result } = renderHook( () => useChartMargin( 300, options, data, baseTheme ) ); + + // 'MEASURED-LAST' is 13 characters, so 104px wide, and half of it is reserved. + expect( result.current.right ).toBe( 52 ); + } ); + } ); + + describe( 'y axis gutter', () => { + it( 'measures a caller-pinned domain rather than the data range', () => { + const options = { ...optionsBase, yScale: { domain: [ 0, 1 ] as [ number, number ] } }; + + renderHook( () => useChartMargin( 300, options, data, baseTheme ) ); + + const ticks = mockGetLongestTickWidth.mock.calls[ 0 ][ 0 ] as number[]; + expect( Math.max( ...ticks ) ).toBeLessThanOrEqual( 1 ); + } ); + + it( 'reserves no gutter for a hidden y axis', () => { + const options = { + ...optionsBase, + axis: { ...optionsBase.axis, y: { ...optionsBase.axis.y, display: false } }, + }; + + const { result } = renderHook( () => useChartMargin( 300, options, data, baseTheme ) ); + + expect( result.current.left ).toBe( 20 ); + } ); + } ); + describe( 'horizontal y ticks', () => { const horizontalOptions = ( tickFormat: ( value: string | number ) => string ) => ( { ...optionsBase, diff --git a/projects/js-packages/charts/src/hooks/use-chart-margin.tsx b/projects/js-packages/charts/src/hooks/use-chart-margin.tsx index 3c68d503bf32..3715d628f444 100644 --- a/projects/js-packages/charts/src/hooks/use-chart-margin.tsx +++ b/projects/js-packages/charts/src/hooks/use-chart-margin.tsx @@ -50,6 +50,33 @@ const DEFAULT_TICK_LENGTH = 8; */ const DEFAULT_Y_TICK_WIDTH = 40; +type LabelStyle = { fontSize?: number | string; letterSpacing?: number | string }; + +/** + * Copy a label style with its lengths spelled out in px. + * + * `getStringWidth` applies the style through CSSOM. Blink resolves a bare number + * on an SVG ``, but that is its own leniency rather than the CSS rule, and + * `buildChartTheme` hands us bare numbers. + * + * @param style - Raw label style from the theme. + * @return The same style with px-qualified lengths. + */ +const toMeasurableStyle = < T extends LabelStyle >( style?: T ) => { + if ( ! style ) { + return style; + } + + const fontSize = resolveFontSize( style.fontSize ); + const { letterSpacing } = style; + + return { + ...style, + ...( fontSize === undefined ? {} : { fontSize: `${ fontSize }px` } ), + ...( typeof letterSpacing === 'number' ? { letterSpacing: `${ letterSpacing }px` } : {} ), + }; +}; + const getXAxisLabelMetrics = ( theme: XYChartTheme, orientation: 'top' | 'bottom' ) => { const xAxisStyles = orientation === 'top' ? theme.axisStyles?.x?.top : theme.axisStyles?.x?.bottom; @@ -61,17 +88,7 @@ const getXAxisLabelMetrics = ( theme: XYChartTheme, orientation: 'top' | 'bottom const tickLength = xAxisStyles?.tickLength ?? DEFAULT_TICK_LENGTH; - // The measurer assigns this through CSSOM, which drops a unitless length, so - // the theme's plain-number fontSize has to carry its unit or labels measure - // at whatever the host body inherits. - const tickLabel = xAxisStyles?.tickLabel; - const tickLabelFontSize = resolveFontSize( tickLabel?.fontSize ); - const tickLabelStyle = - tickLabel && tickLabelFontSize !== undefined - ? { ...tickLabel, fontSize: `${ tickLabelFontSize }px` } - : tickLabel; - - return { fontSize, tickLength, tickLabelStyle }; + return { fontSize, tickLength, tickLabelStyle: toMeasurableStyle( xAxisStyles?.tickLabel ) }; }; export const useChartMargin = ( @@ -98,7 +115,9 @@ export const useChartMargin = ( const maxY = Math.max( ...allDataPoints.map( d => d.value ) ); const yScale = createScale( { ...options.yScale, - domain: [ minY, maxY ], + // A pinned domain is what the axis actually renders, so measure those + // ticks; the data's range would size the gutter for narrower labels. + domain: options.yScale?.domain ?? [ minY, maxY ], range: [ height, 0 ], } ); @@ -121,7 +140,7 @@ export const useChartMargin = ( const yTickWidth = getLongestTickWidth( yTicks, options.axis?.y?.tickFormat, - yAxisStyles.axisLabel + toMeasurableStyle( yAxisStyles.axisLabel ) ); // visx's default axis theme pushes y-axis tick labels a further 0.25em // away from the axis (dx of -0.25em on the left, 0.25em on the right), so @@ -135,10 +154,13 @@ export const useChartMargin = ( ( yAxisStyles?.tickLength ?? 0 ) + Math.ceil( yTickLabelFontSize * 0.25 ); - if ( yAxisOrientation === 'right' ) { - defaultMargin.right = yMarginValue; - } else { - defaultMargin.left = yMarginValue; + // A hidden y axis reserves nothing; its gutter belongs to the plot area. + if ( options.axis?.y?.display !== false ) { + if ( yAxisOrientation === 'right' ) { + defaultMargin.right = yMarginValue; + } else { + defaultMargin.left = yMarginValue; + } } // Dynamically compute X-axis margin (bottom by default, or top if orientation is 'top'). @@ -164,12 +186,8 @@ export const useChartMargin = ( tickLabelStyle ); - if ( first !== null ) { - defaultMargin.left = Math.max( defaultMargin.left, Math.ceil( first / 2 ) ); - } - if ( last !== null ) { - defaultMargin.right = Math.max( defaultMargin.right, Math.ceil( last / 2 ) ); - } + defaultMargin.left = Math.max( defaultMargin.left, Math.ceil( first / 2 ) ); + defaultMargin.right = Math.max( defaultMargin.right, Math.ceil( last / 2 ) ); } return defaultMargin; diff --git a/projects/js-packages/charts/src/utils/get-edge-tick-widths.ts b/projects/js-packages/charts/src/utils/get-edge-tick-widths.ts index 1cdf2ffcd331..7107561b2447 100644 --- a/projects/js-packages/charts/src/utils/get-edge-tick-widths.ts +++ b/projects/js-packages/charts/src/utils/get-edge-tick-widths.ts @@ -5,18 +5,21 @@ import type { AnyD3Scale, ScaleInput } from '@visx/scale'; /** * Rendered widths of the first and last tick labels on an axis. * + * An unmeasurable label reserves nothing, which is what a width of 0 already + * means to every caller, so it is reported as 0 rather than as its own case. + * * @param ticks - Tick values, in axis order. * @param formatTick - Function to format a tick. * @param {object} labelStyle - Style object for the label. - * @return {object} - Widths in pixels, null for a label that cannot be measured. + * @return {object} - Widths in pixels. */ export const getEdgeTickWidths = < T extends AnyD3Scale >( ticks: ScaleInput< T >[], formatTick?: TickFormatter< ScaleInput< T > >, labelStyle?: object -): { first: number | null; last: number | null } => { +): { first: number; last: number } => { if ( ! ticks.length ) { - return { first: null, last: null }; + return { first: 0, last: 0 }; } const lastIndex = ticks.length - 1; @@ -24,7 +27,7 @@ export const getEdgeTickWidths = < T extends AnyD3Scale >( String( formatTick ? formatTick( tick, index, [] ) ?? '' : tick ); return { - first: getStringWidth( label( ticks[ 0 ], 0 ), labelStyle ), - last: getStringWidth( label( ticks[ lastIndex ], lastIndex ), labelStyle ), + first: getStringWidth( label( ticks[ 0 ], 0 ), labelStyle ) ?? 0, + last: getStringWidth( label( ticks[ lastIndex ], lastIndex ), labelStyle ) ?? 0, }; }; diff --git a/projects/js-packages/charts/src/utils/test/get-edge-tick-widths.test.ts b/projects/js-packages/charts/src/utils/test/get-edge-tick-widths.test.ts index 443f4295e747..f1900e08c43b 100644 --- a/projects/js-packages/charts/src/utils/test/get-edge-tick-widths.test.ts +++ b/projects/js-packages/charts/src/utils/test/get-edge-tick-widths.test.ts @@ -40,7 +40,16 @@ describe( 'getEdgeTickWidths', () => { } ); it( 'returns nothing to reserve for an axis with no ticks', () => { - expect( getEdgeTickWidths( [] ) ).toEqual( { first: null, last: null } ); + expect( getEdgeTickWidths( [] ) ).toEqual( { first: 0, last: 0 } ); expect( mockGetStringWidth ).not.toHaveBeenCalled(); } ); + + it( 'reserves nothing for a label the measurer cannot size', () => { + mockGetStringWidth.mockReturnValue( null ); + + expect( getEdgeTickWidths( [ 1, 2, 3 ], value => `tick-${ value }` ) ).toEqual( { + first: 0, + last: 0, + } ); + } ); } ); diff --git a/projects/packages/premium-analytics/changelog/uni-753-axis-edge-labels b/projects/packages/premium-analytics/changelog/uni-753-axis-edge-labels index c8760a88bf1e..f3793536e488 100644 --- a/projects/packages/premium-analytics/changelog/uni-753-axis-edge-labels +++ b/projects/packages/premium-analytics/changelog/uni-753-axis-edge-labels @@ -1,4 +1,4 @@ Significance: patch Type: fixed -Stop the last date on a chart's horizontal axis from being cut off. +Stop the first and last dates on a chart's horizontal axis from being cut off. diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/__tests__/comparative-bar-chart.test.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/__tests__/comparative-bar-chart.test.tsx index 0a715a6ad77f..9e13946c2ed4 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/__tests__/comparative-bar-chart.test.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/__tests__/comparative-bar-chart.test.tsx @@ -478,12 +478,13 @@ describe( 'ComparativeBarChart', () => { expect( recordedOptions().yScale?.domain ).toEqual( [ 0, 1 ] ); } ); - it( 'reserves a left margin for a pinned domain', () => { + it( 'leaves the pinned domain to size its own gutter', () => { render( ); - // `useChartMargin` sizes the gutter from the data's own min/max, so the - // pinned domain's widest tick would otherwise be clipped. - expect( recordedProps().margin.left ).toBeGreaterThan( 0 ); + // `useChartMargin` measures the pinned domain's own ticks, so there is + // nothing left for this component to override. + expect( recordedOptions().yScale.domain ).toBeDefined(); + expect( recordedProps().margin ).toBeUndefined(); } ); it( 'lets the chart scale to the data otherwise', () => { @@ -504,8 +505,9 @@ describe( 'ComparativeBarChart', () => { expect( recordedOptions().axis.y.display ).toBe( false ); expect( recordedProps().gridVisibility ).toBe( 'none' ); - // The hidden axis frees its gutter for the bars. - expect( recordedProps().margin ).toEqual( { right: 0, left: 0 } ); + // The hidden axis frees its gutter inside `useChartMargin`, so the bars + // gain the room without this component clipping the date labels away. + expect( recordedProps().margin ).toBeUndefined(); expect( screen.queryByTestId( 'bar-chart-legend' ) ).not.toBeInTheDocument(); } ); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/comparative-bar-chart.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/comparative-bar-chart.tsx index 57bc12c92e81..17617a4313ce 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/comparative-bar-chart.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/comparative-bar-chart.tsx @@ -286,12 +286,12 @@ export function ComparativeBarChart( { ); /** - * A pinned domain for percentage metrics and all-zero periods, with the left - * margin its widest tick needs. Null lets the chart scale to the data. + * A pinned domain for percentage metrics and all-zero periods. Null lets the + * chart scale to the data. */ const fixedYAxis = useMemo( - () => getFixedYAxis( dataFormat.type, isEmptyData, yTickFormat ), - [ dataFormat.type, isEmptyData, yTickFormat ] + () => getFixedYAxis( dataFormat.type, isEmptyData ), + [ dataFormat.type, isEmptyData ] ); const chartOptions = useMemo( () => { @@ -316,15 +316,6 @@ export function ComparativeBarChart( { return { ...baseOptions, yScale: { domain: fixedYAxis.domain } }; }, [ xTickFormat, tickResolution, yTickFormat, isCompact, fixedYAxis ] ); - const margin = useMemo( () => { - // A sparkline is full bleed: it hides both axes and gives their gutters to the bars. - if ( isCompact ) { - return { right: 0, left: 0 }; - } - - return fixedYAxis ? { left: fixedYAxis.marginLeft } : undefined; - }, [ isCompact, fixedYAxis ] ); - return ( { }; } ); +// jsdom's ResizeObserver is a no-op stub, so the real hook's callback never fires +// and the chart measures as infinitely tall, leaving `compactWhenShort` unreachable. +let mockChartHeight = Infinity; + jest.mock( '@wordpress/compose', () => ( { ...jest.requireActual( '@wordpress/compose' ), - useResizeObserver: () => () => undefined, + useResizeObserver: + ( onResize: ( entries: { contentRect: { height: number } }[] ) => void ) => + ( element: HTMLElement | null ) => { + if ( element ) { + onResize( [ { contentRect: { height: mockChartHeight } } ] ); + } + }, } ) ); jest.mock( '../../../hooks', () => ( { @@ -132,6 +142,8 @@ type RecordedLineProps = { chartId?: string; defaultHiddenSeries?: readonly string[]; legend: { collapseGroups: boolean; interactive: boolean }; + margin?: Record< string, number >; + options?: { yScale?: { domain?: [ number, number ] }; axis: { y: { display?: boolean } } }; renderTooltip: ( params: unknown ) => { props: { getLabel: GetTooltipLabel } }; }; @@ -176,6 +188,42 @@ describe( 'ComparativeLineChart', () => { beforeEach( () => { mockLineSpy.mockClear(); mockLegendSpy.mockClear(); + mockChartHeight = Infinity; + } ); + + describe( 'margin', () => { + it( 'never overrides the gutters the chart measured', () => { + render( ); + + expect( recordedProps().margin ).toBeUndefined(); + } ); + + it( 'leaves the pinned domain to size its own gutter', () => { + render( + + ); + + // `useChartMargin` measures the pinned domain's own ticks, so there is + // nothing left for this component to override. + expect( recordedProps().options.yScale.domain ).toBeDefined(); + expect( recordedProps().margin ).toBeUndefined(); + } ); + + it( 'keeps the date labels on a sparkline', () => { + mockChartHeight = 80; + + render( + + ); + + // The hidden y axis frees its gutter inside `useChartMargin`; zeroing the + // margin here would clip the first and last dates, which still render. + expect( recordedProps().options.axis.y.display ).toBe( false ); + expect( recordedProps().margin ).toBeUndefined(); + } ); } ); it( 'passes visibility settings through to the chart and legend', () => { diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx index a7cd753125cb..012d5cc312e5 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx @@ -242,11 +242,11 @@ export function ComparativeLineChart( { const isEmptyData = useMemo( () => isEmptyChartData( styledSeries ), [ styledSeries ] ); - // A pinned domain for percentage metrics and all-zero periods, with the left - // margin its widest tick needs. Null lets the chart scale to the data. + // A pinned domain for percentage metrics and all-zero periods. Null lets the + // chart scale to the data. const fixedYAxis = useMemo( - () => getFixedYAxis( dataFormat.type, isEmptyData, yTickFormat ), - [ dataFormat.type, isEmptyData, yTickFormat ] + () => getFixedYAxis( dataFormat.type, isEmptyData ), + [ dataFormat.type, isEmptyData ] ); const xTickFormat = useCallback( @@ -278,8 +278,6 @@ export function ComparativeLineChart( { return { ...baseOptions, yScale: { domain: fixedYAxis.domain } }; }, [ xTickFormat, xTickFormatType, tickResolution, yTickFormat, fixedYAxis, isCompact ] ); - const margin = fixedYAxis ? { left: fixedYAxis.marginLeft } : undefined; - return ( string -): FixedYAxis | null { +export function getFixedYAxis( metricType: string, isEmptyData: boolean ): FixedYAxis | null { let domain: [ number, number ] | null = null; if ( metricType === 'percentage' ) { @@ -42,7 +33,5 @@ export function getFixedYAxis( return null; } - // Rough but stable: the chart library gives us no way to measure the rendered - // tick, so estimate from the formatted string's length. - return { domain, marginLeft: formatTick( domain[ 1 ] ).length * 10 }; + return { domain }; } diff --git a/projects/plugins/jetpack/changelog/uni-753-axis-edge-labels b/projects/plugins/jetpack/changelog/uni-753-axis-edge-labels index c4d58807996b..9b1e58a4901a 100644 --- a/projects/plugins/jetpack/changelog/uni-753-axis-edge-labels +++ b/projects/plugins/jetpack/changelog/uni-753-axis-edge-labels @@ -1,4 +1,4 @@ Significance: patch Type: bugfix -Premium Analytics: Stop the last date on a chart's horizontal axis from being cut off. +Charts: Stop the first and last dates on a chart's horizontal axis from being cut off. diff --git a/projects/plugins/premium-analytics/changelog/uni-753-axis-edge-labels b/projects/plugins/premium-analytics/changelog/uni-753-axis-edge-labels index c8760a88bf1e..f3793536e488 100644 --- a/projects/plugins/premium-analytics/changelog/uni-753-axis-edge-labels +++ b/projects/plugins/premium-analytics/changelog/uni-753-axis-edge-labels @@ -1,4 +1,4 @@ Significance: patch Type: fixed -Stop the last date on a chart's horizontal axis from being cut off. +Stop the first and last dates on a chart's horizontal axis from being cut off. diff --git a/projects/plugins/social/changelog/uni-753-axis-edge-labels b/projects/plugins/social/changelog/uni-753-axis-edge-labels new file mode 100644 index 000000000000..494d9b824eca --- /dev/null +++ b/projects/plugins/social/changelog/uni-753-axis-edge-labels @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Charts: Stop the first and last dates on a chart's horizontal axis from being cut off. diff --git a/projects/plugins/videopress/changelog/uni-753-axis-edge-labels b/projects/plugins/videopress/changelog/uni-753-axis-edge-labels new file mode 100644 index 000000000000..494d9b824eca --- /dev/null +++ b/projects/plugins/videopress/changelog/uni-753-axis-edge-labels @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Charts: Stop the first and last dates on a chart's horizontal axis from being cut off. From b4b953fbc5a0bf4dc7e08c9fd0b958342a09ce5a Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 9 Sep 2026 15:10:05 +0800 Subject: [PATCH 3/4] test: add a Sparkline story for the compact comparative line chart compactWhenShort is not in argTypes, so the compact branch was unreachable from both URL args and the Controls panel. This PR changes how that branch sizes its margins, so make it visible. --- .../comparative-line-chart.stories.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/stories/comparative-line-chart.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/stories/comparative-line-chart.stories.tsx index e19b044fb59d..a85e5f9f762c 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/stories/comparative-line-chart.stories.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/stories/comparative-line-chart.stories.tsx @@ -596,3 +596,23 @@ export const Resizable: Story = { layout: 'padded', }, }; + +/** + * Sparkline: how the chart degrades on a tile too short for a y axis. + * The dates stay readable at both ends; the chart reserves room for them. + */ +export const Sparkline: Story = { + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + series: singleSeries, + styles: SERIES_STYLES, + dataFormat: { type: 'currency' }, + compactWhenShort: true, + }, +}; From a231887f7b4459f448755276d1d5b0cc3915eb1c Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 9 Sep 2026 17:29:50 +0800 Subject: [PATCH 4/4] docs: drop the stale left-margin note from the bar chart README --- .../src/components/chart-comparative-bar/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/README.md index cfe97528f85c..6876f1059449 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/README.md +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-bar/README.md @@ -56,6 +56,5 @@ unreadable. ## Y-axis domain Percentage metrics are pinned to 0%–100% and an all-zero period gets a readable axis instead of a -flat baseline, both via the shared `getFixedYAxis` helper, which also supplies the left margin such -a pinned domain needs. Zero-value bars are drawn as hairline stubs (`showZeroValues`) so a quiet day -reads as zero rather than missing data. +flat baseline, both via the shared `getFixedYAxis` helper. Zero-value bars are drawn as hairline +stubs (`showZeroValues`) so a quiet day reads as zero rather than missing data.