From 8984eb5ae9577b5bf791caa37aa6636ec70ecf65 Mon Sep 17 00:00:00 2001 From: Maddi personal Date: Mon, 15 Jun 2026 16:21:35 -0600 Subject: [PATCH 1/8] bug: implementing an expression function that is used in the title spec signal to wrap the title text when it is long or there is not enough space --- .../src/VegaChart.tsx | 6 +- .../src/stories/Chart.story.tsx | 20 +++++- packages/themes/src/spectrum2Theme.ts | 4 +- .../src/chartSpecBuilder.ts | 15 ++++- .../src/specUtils.test.ts | 35 ++++++++++ .../vega-spec-builder-s2/src/specUtils.ts | 67 ++++++++++--------- 6 files changed, 110 insertions(+), 37 deletions(-) diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.tsx index d94bb2d88..602ff1fdf 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.tsx @@ -17,7 +17,7 @@ import { Options as TooltipOptions } from 'vega-tooltip'; import { TABLE } from '@spectrum-charts/constants'; import { getLocale } from '@spectrum-charts/locales'; -import { ChartData, UserMeta, applyUserMetaConfigPatches, getVegaEmbedOptions } from '@spectrum-charts/vega-spec-builder-s2'; +import { ChartData, UserMeta, applyUserMetaConfigPatches, getVegaEmbedOptions, wrapTitleText } from '@spectrum-charts/vega-spec-builder-s2'; import { useDebugSpec } from './hooks/useDebugSpec'; import { extractValues, isVegaData } from './hooks/useSpec'; @@ -34,6 +34,10 @@ expressionFunction('rscContainerWidth', function (this: { context: { dataflow: V return viewWidth + (p.left ?? 0) + (p.right ?? 0); }); +expressionFunction('rscWrapTitle', (text: string, maxWidth: number): string[] => + wrapTitleText(text, maxWidth) +); + /** * Resizes an existing Vega view without recreating it. */ diff --git a/packages/react-spectrum-charts-s2/src/stories/Chart.story.tsx b/packages/react-spectrum-charts-s2/src/stories/Chart.story.tsx index db8d5d3c9..35bc2c8b6 100644 --- a/packages/react-spectrum-charts-s2/src/stories/Chart.story.tsx +++ b/packages/react-spectrum-charts-s2/src/stories/Chart.story.tsx @@ -37,6 +37,18 @@ const ChartLineStory: StoryFn = (args): ReactElement => { ); }; +const LongTitleStory: StoryFn = (args): ReactElement => { + const props = useChartProps(args); + return ( + + + + + + + ); +}; + const ChartTimeStory: StoryFn = (args): ReactElement => { const props = useChartProps(args); return ( @@ -124,4 +136,10 @@ HighlightedItem.args = { data, }; -export { Basic, BackgroundColor, Config, Height, HighlightedItem, Locale, TooltipAnchor, Width }; +const LongTitle = bindWithProps(LongTitleStory); +LongTitle.args = { + title: 'Daily Workspace Component Events by Panel Type for Add Fallout and Add Freeform Table Interactions', + data: workspaceTrendsData, +}; + +export { Basic, BackgroundColor, Config, Height, HighlightedItem, Locale, LongTitle, TooltipAnchor, Width }; diff --git a/packages/themes/src/spectrum2Theme.ts b/packages/themes/src/spectrum2Theme.ts index be7186601..21ba80969 100644 --- a/packages/themes/src/spectrum2Theme.ts +++ b/packages/themes/src/spectrum2Theme.ts @@ -30,6 +30,8 @@ import { spectrum2Colors } from './spectrum2Colors'; import { ADOBE_CLEAN_FONT } from './spectrumTheme'; import { getS2ColorValue } from './utils'; +export const S2_TITLE_FONT_SIZE = 22; + export function getSpectrum2VegaConfig(colorScheme: 'light' | 'dark'): Config { const FONT_COLOR = getS2ColorValue(DEFAULT_FONT_COLOR, colorScheme); const { @@ -152,7 +154,7 @@ export function getSpectrum2VegaConfig(colorScheme: 'light' | 'dark'): Config { title: { offset: 10, font: ADOBE_CLEAN_FONT, - fontSize: 22, + fontSize: S2_TITLE_FONT_SIZE, color: FONT_COLOR, anchor: 'start', frame: 'group', diff --git a/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts b/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts index 337d3fc80..0d95aed03 100644 --- a/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts +++ b/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts @@ -137,8 +137,11 @@ export function buildSpec({ title, titles, }; - let spec = initializeSpec(null, { backgroundColor, colorScheme, description, title }); + let spec = initializeSpec(null, { backgroundColor, colorScheme, description }); spec.signals = getDefaultSignals(options); + if (title) { + addTitleSignals(spec, title); + } spec.scales = getDefaultScales(colors, colorScheme, lineTypes, lineWidths, opacities, symbolShapes, symbolSizes); let { areaCount, barCount, bulletCount, comboCount, donutCount, lineCount, scatterCount, vennCount } = @@ -220,6 +223,16 @@ export function buildSpec({ return safeClone(spec); } +const addTitleSignals = (spec: ScSpec, title: string): void => { + spec.title = { text: { signal: 'rscWrappedTitleText' } }; + spec.signals = [ + ...(spec.signals ?? []), + { name: 'rscTitleText', value: title }, + { name: 'rscTitleLimit', update: 'width' }, + { name: 'rscWrappedTitleText', update: 'rscWrapTitle(rscTitleText, rscTitleLimit)' }, + ]; +}; + export const removeUnusedScales = produce((spec) => { spec.scales = spec.scales?.filter((scale) => { return !('domain' in scale && scale.domain && 'fields' in scale.domain && scale.domain.fields.length === 0); diff --git a/packages/vega-spec-builder-s2/src/specUtils.test.ts b/packages/vega-spec-builder-s2/src/specUtils.test.ts index ed3213c5f..6693dee6f 100644 --- a/packages/vega-spec-builder-s2/src/specUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/specUtils.test.ts @@ -37,6 +37,7 @@ import { getPathFromSymbolShape, getStrokeDashFromLineType, getVegaSymbolSizeFromRscSymbolSize, + wrapTitleText, } from './specUtils'; const defaultColorScale: OrdinalScale = { @@ -204,6 +205,40 @@ describe('getChartConfig()', () => { }); }); +describe('wrapTitleText()', () => { + test('returns text as single element when maxWidth is 0', () => { + expect(wrapTitleText('Page Views by Region', 0)).toStrictEqual(['Page Views by Region']); + }); + + test('returns text as single element when it fits on one line', () => { + // 440px → maxCharsPerLine=40; "Page Views by Region" = 20 chars + expect(wrapTitleText('Page Views by Region', 440)).toStrictEqual(['Page Views by Region']); + }); + + test('wraps a long title onto two lines at a word boundary', () => { + // 440px → maxCharsPerLine=40; "...by Product"(35) fits, adding "Category"(44) wraps + expect(wrapTitleText('Quarterly Revenue Growth by Product Category and Geographic Region', 440)).toStrictEqual([ + 'Quarterly Revenue Growth by Product', + 'Category and Geographic Region', + ]); + }); + + test('wraps a very long title onto four lines', () => { + // 264px → maxCharsPerLine=24 + expect( + wrapTitleText( + 'Total Year over Year Revenue Growth Rate by Region for All Products in Q4 2024 Report', + 264 + ) + ).toStrictEqual([ + 'Total Year over Year', + 'Revenue Growth Rate by', + 'Region for All Products', + 'in Q4 2024 Report', + ]); + }); +}); + describe('addUserMetaInteractiveMark()', () => { test('should add the interactive mark to the user meta', () => { expect(addUserMetaInteractiveMark({ interactiveMarks: [] }, 'line0')).toEqual({ interactiveMarks: ['line0'] }); diff --git a/packages/vega-spec-builder-s2/src/specUtils.ts b/packages/vega-spec-builder-s2/src/specUtils.ts index 9966f2085..6ed151c24 100644 --- a/packages/vega-spec-builder-s2/src/specUtils.ts +++ b/packages/vega-spec-builder-s2/src/specUtils.ts @@ -12,39 +12,15 @@ import { produce } from 'immer'; import { Config, Data, Scale, ScaleType, Spec, mergeConfig } from 'vega'; -import { - COLOR_SCALE, - DATE_PATH, - DEFAULT_TRANSFORMED_TIME_DIMENSION, - FILTERED_TABLE, - LINE_TYPE_SCALE, - MARK_ID, - OPACITY_SCALE, - ROUNDED_SQUARE_PATH, - SENTIMENT_NEGATIVE_PATH, - SENTIMENT_NEUTRAL_PATH, - SENTIMENT_POSITIVE_PATH, - TABLE, -} from '@spectrum-charts/constants'; -import { getS2ColorValue, getSpectrum2VegaConfig } from '@spectrum-charts/themes'; - -import { - ChartSpecOptions, - ChartSymbolShape, - ColorFacet, - ColorScheme, - DualFacet, - Icon, - LineType, - LineTypeFacet, - LineWidth, - NumberFormat, - OpacityFacet, - ScSpec, - SymbolSize, - SymbolSizeFacet, - UserMeta, -} from './types'; + + +import { COLOR_SCALE, DATE_PATH, DEFAULT_TRANSFORMED_TIME_DIMENSION, FILTERED_TABLE, LINE_TYPE_SCALE, MARK_ID, OPACITY_SCALE, ROUNDED_SQUARE_PATH, SENTIMENT_NEGATIVE_PATH, SENTIMENT_NEUTRAL_PATH, SENTIMENT_POSITIVE_PATH, TABLE } from '@spectrum-charts/constants'; +import { S2_TITLE_FONT_SIZE, getS2ColorValue, getSpectrum2VegaConfig } from '@spectrum-charts/themes'; + + + +import { ChartSpecOptions, ChartSymbolShape, ColorFacet, ColorScheme, DualFacet, Icon, LineType, LineTypeFacet, LineWidth, NumberFormat, OpacityFacet, ScSpec, SymbolSize, SymbolSizeFacet, UserMeta } from './types'; + /** * gets all the keys that are used to facet by @@ -247,6 +223,31 @@ export const initializeSpec = (spec: Spec | null = {}, chartOptions: Partial { + const maxCharsPerLine = Math.floor(maxWidth / (S2_TITLE_FONT_SIZE * AVG_CHAR_WIDTH_RATIO)); + if (maxCharsPerLine <= 0) return [text]; + + const lines = text.split(' ').reduce((lines, word) => { + const last = lines.at(-1); + if (last !== undefined) { + const candidate = `${last} ${word}`; + if (candidate.length <= maxCharsPerLine) { + return [...lines.slice(0, -1), candidate]; + } + } + return [...lines, word]; + }, []); + + return lines.length ? lines : [text]; +}; + /** * The inverse of `extractValues`. Given an array of Vega datasets and an object of key/value pairs * merge the values into the datasets. From 7c7098b2bebc6258c2d3f25a8e4a84b53079bdb4 Mon Sep 17 00:00:00 2001 From: Maddi personal Date: Tue, 16 Jun 2026 10:39:44 -0600 Subject: [PATCH 2/8] chore: adding test coverage per sonarqube --- .../src/VegaChart.test.tsx | 16 ++++++++++ .../src/chartSpecBuilder.test.ts | 30 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx index 4961ca085..c82671b62 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx @@ -92,6 +92,22 @@ describe('resizeView', () => { }); }); +describe('rscWrapTitle expression function', () => { + // The function is registered at module load time when VegaChart.tsx is imported above. + const fn = expressionFunction('rscWrapTitle') as (text: string, maxWidth: number) => string[]; + + test('returns text as a single-element array when it fits on one line', () => { + expect(fn('Page Views by Region', 440)).toStrictEqual(['Page Views by Region']); + }); + + test('wraps text onto multiple lines when it exceeds maxWidth', () => { + expect(fn('Quarterly Revenue Growth by Product Category and Geographic Region', 440)).toStrictEqual([ + 'Quarterly Revenue Growth by Product', + 'Category and Geographic Region', + ]); + }); +}); + describe('rscContainerWidth expression function', () => { // The function is registered at module load time when VegaChart.tsx is imported above. const fn = expressionFunction('rscContainerWidth') as (this: unknown) => number; diff --git a/packages/vega-spec-builder-s2/src/chartSpecBuilder.test.ts b/packages/vega-spec-builder-s2/src/chartSpecBuilder.test.ts index e627340a3..b135e8f6e 100644 --- a/packages/vega-spec-builder-s2/src/chartSpecBuilder.test.ts +++ b/packages/vega-spec-builder-s2/src/chartSpecBuilder.test.ts @@ -509,4 +509,34 @@ describe('Chart spec builder', () => { }); }); + + describe('addTitleSignals()', () => { + test('sets spec.title to use rscWrappedTitleText signal when title is provided', () => { + const spec = buildSpec({ ...defaultSpecOptions, title: 'Page Views by Region' }); + expect(spec.title).toEqual({ text: { signal: 'rscWrappedTitleText' } }); + }); + + test('appends rscTitleText, rscTitleLimit, and rscWrappedTitleText signals when title is provided', () => { + const spec = buildSpec({ ...defaultSpecOptions, title: 'Page Views by Region' }); + expect(spec.signals?.find((s) => s.name === 'rscTitleText')).toEqual({ + name: 'rscTitleText', + value: 'Page Views by Region', + }); + expect(spec.signals?.find((s) => s.name === 'rscTitleLimit')).toEqual({ + name: 'rscTitleLimit', + update: 'width', + }); + expect(spec.signals?.find((s) => s.name === 'rscWrappedTitleText')).toEqual({ + name: 'rscWrappedTitleText', + update: 'rscWrapTitle(rscTitleText, rscTitleLimit)', + }); + }); + + test('does not add title signals when title is empty', () => { + const spec = buildSpec({ ...defaultSpecOptions, title: '' }); + expect(spec.signals?.find((s) => s.name === 'rscTitleText')).toBeUndefined(); + expect(spec.signals?.find((s) => s.name === 'rscTitleLimit')).toBeUndefined(); + expect(spec.signals?.find((s) => s.name === 'rscWrappedTitleText')).toBeUndefined(); + }); + }); }); From 0ddf8bce5b3b661d87da9251a9f924da6c46d19d Mon Sep 17 00:00:00 2001 From: Maddi personal Date: Tue, 16 Jun 2026 11:15:23 -0600 Subject: [PATCH 3/8] chore: make LongTitle story resizable, move the resizablecomponent code into the story utils file to reduce duplicate code --- .../src/stories/Chart.story.tsx | 32 ++-- .../Features/ChartSize/ChartSize.story.tsx | 141 +++------------ .../DirectLabel/LineDirectLabel.story.tsx | 137 +++------------ .../Features/LineStrokeWidthOnHover.story.tsx | 137 ++++----------- .../ReferenceLine/LineReferenceLine.story.tsx | 143 +++------------ .../StaticPoint/LineStaticPoint.story.tsx | 165 ++++-------------- .../src/stories/storyUtils.ts | 30 ---- .../src/stories/storyUtils.tsx | 144 +++++++++++++++ 8 files changed, 299 insertions(+), 630 deletions(-) delete mode 100644 packages/react-spectrum-charts-s2/src/stories/storyUtils.ts create mode 100644 packages/react-spectrum-charts-s2/src/stories/storyUtils.tsx diff --git a/packages/react-spectrum-charts-s2/src/stories/Chart.story.tsx b/packages/react-spectrum-charts-s2/src/stories/Chart.story.tsx index 35bc2c8b6..53983dc22 100644 --- a/packages/react-spectrum-charts-s2/src/stories/Chart.story.tsx +++ b/packages/react-spectrum-charts-s2/src/stories/Chart.story.tsx @@ -20,6 +20,7 @@ import './Chart.story.css'; import { ChartBarStory } from './ChartBarStory'; import { ChartDynamicHeightBarStory } from './ChartDynamicHeightBarStory'; import { data, workspaceTrendsData } from './data/data'; +import { ResizableChart } from './storyUtils'; export default { title: 'RSC/Chart', @@ -37,17 +38,20 @@ const ChartLineStory: StoryFn = (args): ReactElement => { ); }; -const LongTitleStory: StoryFn = (args): ReactElement => { - const props = useChartProps(args); - return ( - - - - - - - ); -}; +const LONG_TITLE = 'Daily Workspace Component Events by Panel Type for Add Fallout and Add Freeform Table Interactions'; + +const LongTitleStory = (): ReactElement => ( + + {(width) => ( + + + + + + + )} + +); const ChartTimeStory: StoryFn = (args): ReactElement => { const props = useChartProps(args); @@ -136,10 +140,6 @@ HighlightedItem.args = { data, }; -const LongTitle = bindWithProps(LongTitleStory); -LongTitle.args = { - title: 'Daily Workspace Component Events by Panel Type for Add Fallout and Add Freeform Table Interactions', - data: workspaceTrendsData, -}; +const LongTitle = LongTitleStory; export { Basic, BackgroundColor, Config, Height, HighlightedItem, Locale, LongTitle, TooltipAnchor, Width }; diff --git a/packages/react-spectrum-charts-s2/src/stories/Chart/Features/ChartSize/ChartSize.story.tsx b/packages/react-spectrum-charts-s2/src/stories/Chart/Features/ChartSize/ChartSize.story.tsx index 4fc0f2208..e6c3832de 100644 --- a/packages/react-spectrum-charts-s2/src/stories/Chart/Features/ChartSize/ChartSize.story.tsx +++ b/packages/react-spectrum-charts-s2/src/stories/Chart/Features/ChartSize/ChartSize.story.tsx @@ -9,7 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { ReactElement, useState } from 'react'; +import { ReactElement } from 'react'; import { StoryFn } from '@storybook/react'; @@ -18,6 +18,7 @@ import { CHART_SIZE_BREAKPOINTS } from '@spectrum-charts/constants'; import { Chart } from '../../../../Chart'; import { Axis, Line } from '../../../../components'; import { workspaceTrendsData } from '../../../../stories/data/data'; +import { CHART_SIZE_THRESHOLDS, ResizableChart } from '../../../../stories/storyUtils'; import { bindWithProps } from '../../../../test-utils'; import { ChartProps } from '../../../../types'; @@ -28,125 +29,25 @@ export default { const defaultArgs: ChartProps = { data: workspaceTrendsData, width: 600, height: 300 }; -const CHART_HEIGHT = 300; -const MAX_WIDTH = CHART_SIZE_BREAKPOINTS.L + 200; -const THUMB_HEIGHT = 32; - -const THRESHOLDS = [ - { px: CHART_SIZE_BREAKPOINTS.M, label: 'M' }, - { px: CHART_SIZE_BREAKPOINTS.L, label: 'L' }, -]; - -// The range input is overlaid on the chart area. min=0 so the thumb position (in px) equals -// the value, placing it at the right edge of the chart as the user drags. -const HANDLE_STYLES = ` - .rsc-chart-size-handle { - -webkit-appearance: none; - appearance: none; - background: transparent; - border: none; - outline: none; - position: absolute; - top: 0; - left: 0; - height: ${CHART_HEIGHT}px; - pointer-events: none; - z-index: 20; - } - .rsc-chart-size-handle::-webkit-slider-runnable-track { - background: transparent; - height: ${CHART_HEIGHT}px; - } - .rsc-chart-size-handle::-webkit-slider-thumb { - -webkit-appearance: none; - width: 8px; - height: ${THUMB_HEIGHT}px; - border-radius: 4px; - background: #999; - cursor: ew-resize; - pointer-events: all; - margin-top: ${(CHART_HEIGHT - THUMB_HEIGHT) / 2}px; - } - .rsc-chart-size-handle::-moz-range-track { - background: transparent; - } - .rsc-chart-size-handle::-moz-range-thumb { - width: 8px; - height: ${THUMB_HEIGHT}px; - border-radius: 4px; - background: #999; - border: none; - cursor: ew-resize; - } -`; - -const AutoDetectStory = (): ReactElement => { - const [width, setWidth] = useState(600); - - let currentSize = 'L'; - if (width < CHART_SIZE_BREAKPOINTS.M) currentSize = 'S'; - else if (width < CHART_SIZE_BREAKPOINTS.L) currentSize = 'M'; - - return ( -
- -
- Width: {Math.round(width)}px — Size tier: {currentSize} -
- {/* Outer container holds threshold lines at fixed positions regardless of chart width */} -
- {THRESHOLDS.map(({ px, label }) => ( -
- - {label} ({px}px) - -
- ))} - -
- - - - - - - setWidth(Math.max(100, Number(e.target.value)))} - style={{ width: MAX_WIDTH }} - /> -
-
-
- ); -}; +const AutoDetectStory = (): ReactElement => ( + { + let currentSize = 'L'; + if (width < CHART_SIZE_BREAKPOINTS.M) currentSize = 'S'; + else if (width < CHART_SIZE_BREAKPOINTS.L) currentSize = 'M'; + return <>Width: {Math.round(width)}px — Size tier: {currentSize}; + }} + > + {(width) => ( + + + + + + )} + +); const FixedSizeStory: StoryFn = (args): ReactElement => ( diff --git a/packages/react-spectrum-charts-s2/src/stories/Line/Features/DirectLabel/LineDirectLabel.story.tsx b/packages/react-spectrum-charts-s2/src/stories/Line/Features/DirectLabel/LineDirectLabel.story.tsx index 76aeed402..17ad2723e 100644 --- a/packages/react-spectrum-charts-s2/src/stories/Line/Features/DirectLabel/LineDirectLabel.story.tsx +++ b/packages/react-spectrum-charts-s2/src/stories/Line/Features/DirectLabel/LineDirectLabel.story.tsx @@ -9,7 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { ReactElement, useState } from 'react'; +import { ReactElement } from 'react'; import { StoryFn } from '@storybook/react'; @@ -19,6 +19,7 @@ import { Chart } from '../../../../Chart'; import { Axis, ChartInspect, Legend, Line, LineDirectLabel } from '../../../../components'; import useChartProps from '../../../../hooks/useChartProps'; import { workspaceTrendsData } from '../../../../stories/data/data'; +import { CHART_SIZE_THRESHOLDS, ResizableChart } from '../../../../stories/storyUtils'; import { bindWithProps } from '../../../../test-utils'; import { ChartProps } from '../../../../types'; @@ -47,54 +48,6 @@ export default { const defaultChartProps: ChartProps = { data: workspaceTrendsData, minWidth: 100, maxWidth: 1000, height: 400, backgroundColor: 'gray-50' }; -const CHART_HEIGHT = 400; -const MAX_WIDTH = CHART_SIZE_BREAKPOINTS.L + 200; -const THUMB_HEIGHT = 32; - -const HANDLE_STYLES = ` - .rsc-dl-size-handle { - -webkit-appearance: none; - appearance: none; - background: transparent; - border: none; - outline: none; - position: absolute; - top: 0; - left: 0; - height: ${CHART_HEIGHT}px; - pointer-events: none; - z-index: 20; - } - .rsc-dl-size-handle::-webkit-slider-runnable-track { - background: transparent; - height: ${CHART_HEIGHT}px; - } - .rsc-dl-size-handle::-webkit-slider-thumb { - -webkit-appearance: none; - width: 8px; - height: ${THUMB_HEIGHT}px; - border-radius: 4px; - background: #999; - cursor: ew-resize; - pointer-events: all; - margin-top: ${(CHART_HEIGHT - THUMB_HEIGHT) / 2}px; - } - .rsc-dl-size-handle::-moz-range-track { background: transparent; } - .rsc-dl-size-handle::-moz-range-thumb { - width: 8px; - height: ${THUMB_HEIGHT}px; - border-radius: 4px; - background: #999; - border: none; - cursor: ew-resize; - } -`; - -const THRESHOLDS = [ - { px: CHART_SIZE_BREAKPOINTS.M, label: 'M' }, - { px: CHART_SIZE_BREAKPOINTS.L, label: 'L' }, -]; - // TEMPLATES const LineDirectLabelStory: StoryFn = (args): ReactElement => { @@ -158,70 +111,28 @@ const LineDirectLabelLabelCollisionStory: StoryFn = (arg const DirectLabelSizeScalingStory: StoryFn = (args): ReactElement => { const chartProps = useChartProps(defaultChartProps); - const [width, setWidth] = useState(600); - - let currentSize = 'L'; - if (width < CHART_SIZE_BREAKPOINTS.M) currentSize = 'S'; - else if (width < CHART_SIZE_BREAKPOINTS.L) currentSize = 'M'; - return ( -
- -
- Width: {Math.round(width)}px — Size tier: {currentSize} -
-
- {THRESHOLDS.map(({ px, label }) => ( -
- - {label} ({px}px) - -
- ))} -
- - - - - - - - - setWidth(Math.max(100, Number(e.target.value)))} - style={{ width: MAX_WIDTH }} - /> -
-
-
+ { + let currentSize = 'L'; + if (width < CHART_SIZE_BREAKPOINTS.M) currentSize = 'S'; + else if (width < CHART_SIZE_BREAKPOINTS.L) currentSize = 'M'; + return <>Width: {Math.round(width)}px — Size tier: {currentSize}; + }} + > + {(width) => ( + + + + + + + + + )} + ); }; @@ -257,4 +168,4 @@ export { DirectLabelControlledHighlight, DirectLabelLabelCollision, DirectLabelSizeScaling, -}; \ No newline at end of file +}; diff --git a/packages/react-spectrum-charts-s2/src/stories/Line/Features/LineStrokeWidthOnHover.story.tsx b/packages/react-spectrum-charts-s2/src/stories/Line/Features/LineStrokeWidthOnHover.story.tsx index 1ecca8fde..362955ad9 100644 --- a/packages/react-spectrum-charts-s2/src/stories/Line/Features/LineStrokeWidthOnHover.story.tsx +++ b/packages/react-spectrum-charts-s2/src/stories/Line/Features/LineStrokeWidthOnHover.story.tsx @@ -9,67 +9,20 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { ReactElement, useState } from 'react'; +import { ReactElement } from 'react'; import { CHART_SIZE_BREAKPOINTS, CHART_SIZE_HOVER_STROKE_WIDTHS, CHART_SIZE_STROKE_WIDTHS } from '@spectrum-charts/constants'; import { Chart } from '../../../Chart'; import { Axis, ChartInspect, Legend, Line } from '../../../components'; import { workspaceTrendsData } from '../../../stories/data/data'; +import { CHART_SIZE_THRESHOLDS, ResizableChart } from '../../../stories/storyUtils'; export default { title: 'React Spectrum Charts 2/Line/Features/StrokeWidthOnHover', component: Line, }; -const CHART_HEIGHT = 300; -const MAX_WIDTH = CHART_SIZE_BREAKPOINTS.L + 200; -const THUMB_HEIGHT = 32; - -const THRESHOLDS = [ - { px: CHART_SIZE_BREAKPOINTS.M, label: 'M' }, - { px: CHART_SIZE_BREAKPOINTS.L, label: 'L' }, -]; - -const HANDLE_STYLES = ` - .rsc-stroke-width-handle { - -webkit-appearance: none; - appearance: none; - background: transparent; - border: none; - outline: none; - position: absolute; - top: 0; - left: 0; - height: ${CHART_HEIGHT}px; - pointer-events: none; - z-index: 20; - } - .rsc-stroke-width-handle::-webkit-slider-runnable-track { - background: transparent; - height: ${CHART_HEIGHT}px; - } - .rsc-stroke-width-handle::-webkit-slider-thumb { - -webkit-appearance: none; - width: 8px; - height: ${THUMB_HEIGHT}px; - border-radius: 4px; - background: #999; - cursor: ew-resize; - pointer-events: all; - margin-top: ${(CHART_HEIGHT - THUMB_HEIGHT) / 2}px; - } - .rsc-stroke-width-handle::-moz-range-track { background: transparent; } - .rsc-stroke-width-handle::-moz-range-thumb { - width: 8px; - height: ${THUMB_HEIGHT}px; - border-radius: 4px; - background: #999; - border: none; - cursor: ew-resize; - } -`; - const getSizeTier = (width: number): 'S' | 'M' | 'L' => { if (width < CHART_SIZE_BREAKPOINTS.M) return 'S'; if (width < CHART_SIZE_BREAKPOINTS.L) return 'M'; @@ -83,62 +36,24 @@ const groupedData = [ ...workspaceTrendsData.slice(21).map((d) => ({ ...d, category: 'Visualize' })), ]; -const ResizableChart = ({ children }: { children: (width: number) => ReactElement }): ReactElement => { - const [width, setWidth] = useState(600); - const tier = getSizeTier(width); - - return ( -
- -
-
Width: {Math.round(width)}px — Size tier: {tier}
-
- Stroke width: {CHART_SIZE_STROKE_WIDTHS[tier]}px base →{' '} - {CHART_SIZE_HOVER_STROKE_WIDTHS[tier]}px on hover/select -
-
-
- {THRESHOLDS.map(({ px, label }) => ( -
- - {label} ({px}px) - +export const StrokeWidthOnHover = (): ReactElement => ( + { + const tier = getSizeTier(width); + return ( +
+
Width: {Math.round(width)}px — Size tier: {tier}
+
+ Stroke width: {CHART_SIZE_STROKE_WIDTHS[tier]}px base →{' '} + {CHART_SIZE_HOVER_STROKE_WIDTHS[tier]}px on hover/select
- ))} -
- {children(width)} - setWidth(Math.max(100, Number(e.target.value)))} - style={{ width: MAX_WIDTH }} - />
-
-
- ); -}; - -export const StrokeWidthOnHover = (): ReactElement => ( - + ); + }} + > {(width) => ( - + @@ -158,9 +73,23 @@ export const StrokeWidthOnHover = (): ReactElement => ( ); export const StrokeWidthOnHoverGroupLegend = (): ReactElement => ( - + { + const tier = getSizeTier(width); + return ( +
+
Width: {Math.round(width)}px — Size tier: {tier}
+
+ Stroke width: {CHART_SIZE_STROKE_WIDTHS[tier]}px base →{' '} + {CHART_SIZE_HOVER_STROKE_WIDTHS[tier]}px on hover/select +
+
+ ); + }} + > {(width) => ( - + diff --git a/packages/react-spectrum-charts-s2/src/stories/Line/Features/ReferenceLine/LineReferenceLine.story.tsx b/packages/react-spectrum-charts-s2/src/stories/Line/Features/ReferenceLine/LineReferenceLine.story.tsx index b76911198..41ef9e50a 100644 --- a/packages/react-spectrum-charts-s2/src/stories/Line/Features/ReferenceLine/LineReferenceLine.story.tsx +++ b/packages/react-spectrum-charts-s2/src/stories/Line/Features/ReferenceLine/LineReferenceLine.story.tsx @@ -9,7 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { ReactElement, useState } from 'react'; +import { ReactElement } from 'react'; import { StoryFn } from '@storybook/react'; @@ -19,6 +19,7 @@ import { Chart } from '../../../../Chart'; import { Axis, Legend, Line, ReferenceLine } from '../../../../components'; import useChartProps from '../../../../hooks/useChartProps'; import { workspaceTrendsData } from '../../../../stories/data/data'; +import { CHART_SIZE_THRESHOLDS, ResizableChart } from '../../../../stories/storyUtils'; import { bindWithProps } from '../../../../test-utils'; import { ChartProps } from '../../../../types'; @@ -64,123 +65,29 @@ WithSize.args = { size: 'L', }; -const CHART_HEIGHT = 400; -const MAX_WIDTH = CHART_SIZE_BREAKPOINTS.L + 200; -const THUMB_HEIGHT = 32; - -const THRESHOLDS = [ - { px: CHART_SIZE_BREAKPOINTS.M, label: 'M' }, - { px: CHART_SIZE_BREAKPOINTS.L, label: 'L' }, -]; - -const HANDLE_STYLES = ` - .rsc-ref-size-handle { - -webkit-appearance: none; - appearance: none; - background: transparent; - border: none; - outline: none; - position: absolute; - top: 0; - left: 0; - height: ${CHART_HEIGHT}px; - pointer-events: none; - z-index: 20; - } - .rsc-ref-size-handle::-webkit-slider-runnable-track { - background: transparent; - height: ${CHART_HEIGHT}px; - } - .rsc-ref-size-handle::-webkit-slider-thumb { - -webkit-appearance: none; - width: 8px; - height: ${THUMB_HEIGHT}px; - border-radius: 4px; - background: #999; - cursor: ew-resize; - pointer-events: all; - margin-top: ${(CHART_HEIGHT - THUMB_HEIGHT) / 2}px; - } - .rsc-ref-size-handle::-moz-range-track { background: transparent; } - .rsc-ref-size-handle::-moz-range-thumb { - width: 8px; - height: ${THUMB_HEIGHT}px; - border-radius: 4px; - background: #999; - border: none; - cursor: ew-resize; - } -`; - -const AutoDetectSizeStory = (): ReactElement => { - const [width, setWidth] = useState(600); - - let currentSize = 'L'; - if (width < CHART_SIZE_BREAKPOINTS.M) currentSize = 'S'; - else if (width < CHART_SIZE_BREAKPOINTS.L) currentSize = 'M'; - - return ( -
- -
- Width: {Math.round(width)}px — Reference line size tier: {currentSize} -
-
- {THRESHOLDS.map(({ px, label }) => ( -
- - {label} ({px}px) - -
- ))} - -
- - - - - - - - - - setWidth(Math.max(100, Number(e.target.value)))} - style={{ width: MAX_WIDTH }} - /> -
-
-
- ); -}; +const AutoDetectSizeStory = (): ReactElement => ( + { + let currentSize = 'L'; + if (width < CHART_SIZE_BREAKPOINTS.M) currentSize = 'S'; + else if (width < CHART_SIZE_BREAKPOINTS.L) currentSize = 'M'; + return <>Width: {Math.round(width)}px — Reference line size tier: {currentSize}; + }} + > + {(width) => ( + + + + + + + + + )} + +); export const AutoDetectSize = AutoDetectSizeStory; diff --git a/packages/react-spectrum-charts-s2/src/stories/Line/Features/StaticPoint/LineStaticPoint.story.tsx b/packages/react-spectrum-charts-s2/src/stories/Line/Features/StaticPoint/LineStaticPoint.story.tsx index 90e55a4e2..f60b43ab2 100644 --- a/packages/react-spectrum-charts-s2/src/stories/Line/Features/StaticPoint/LineStaticPoint.story.tsx +++ b/packages/react-spectrum-charts-s2/src/stories/Line/Features/StaticPoint/LineStaticPoint.story.tsx @@ -9,7 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { ReactElement, useState } from 'react'; +import { ReactElement } from 'react'; import { StoryFn } from '@storybook/react'; @@ -19,7 +19,7 @@ import { Chart } from '../../../../Chart'; import { Axis, ChartInspect, Legend, Line } from '../../../../components'; import useChartProps from '../../../../hooks/useChartProps'; import { workspaceTrendsDataWithVisiblePoints } from '../../../../stories/data/data'; -import { formatTimestamp } from '../../../../stories/storyUtils'; +import { CHART_SIZE_THRESHOLDS, ResizableChart, formatTimestamp } from '../../../../stories/storyUtils'; import { bindWithProps } from '../../../../test-utils'; import { ChartProps } from '../../../../types'; @@ -62,139 +62,46 @@ StaticPointSolidFill.args = { staticPoint: 'staticPoint', }; -const CHART_HEIGHT = 300; -const MAX_WIDTH = CHART_SIZE_BREAKPOINTS.L + 200; -const THUMB_HEIGHT = 32; - -const POINT_SIZE_THRESHOLDS = [ - { px: CHART_SIZE_BREAKPOINTS.M, label: 'M' }, - { px: CHART_SIZE_BREAKPOINTS.L, label: 'L' }, -]; - const POINT_DIAMETERS: Record = { S: `${Math.sqrt(CHART_SIZE_POINT_SIZES.S)}px`, M: `${Math.sqrt(CHART_SIZE_POINT_SIZES.M)}px`, L: `${Math.sqrt(CHART_SIZE_POINT_SIZES.L)}px`, }; -const HANDLE_STYLES = ` - .rsc-static-point-size-handle { - -webkit-appearance: none; - appearance: none; - background: transparent; - border: none; - outline: none; - position: absolute; - top: 0; - left: 0; - height: ${CHART_HEIGHT}px; - pointer-events: none; - z-index: 20; - } - .rsc-static-point-size-handle::-webkit-slider-runnable-track { - background: transparent; - height: ${CHART_HEIGHT}px; - } - .rsc-static-point-size-handle::-webkit-slider-thumb { - -webkit-appearance: none; - width: 8px; - height: ${THUMB_HEIGHT}px; - border-radius: 4px; - background: #999; - cursor: ew-resize; - pointer-events: all; - margin-top: ${(CHART_HEIGHT - THUMB_HEIGHT) / 2}px; - } - .rsc-static-point-size-handle::-moz-range-track { - background: transparent; - } - .rsc-static-point-size-handle::-moz-range-thumb { - width: 8px; - height: ${THUMB_HEIGHT}px; - border-radius: 4px; - background: #999; - border: none; - cursor: ew-resize; - } -`; - -const StaticPointSizeAutoDetectStory = (): ReactElement => { - const [width, setWidth] = useState(600); - - let currentSize = 'L'; - if (width < CHART_SIZE_BREAKPOINTS.M) currentSize = 'S'; - else if (width < CHART_SIZE_BREAKPOINTS.L) currentSize = 'M'; - - return ( -
- -
- Width: {Math.round(width)}px — Size tier: {currentSize} — Point diameter:{' '} - {POINT_DIAMETERS[currentSize]} -
-
- {POINT_SIZE_THRESHOLDS.map(({ px, label }) => ( -
- - {label} ({px}px) - -
- ))} - -
- - - - - - {(datum) => ( -
-
{formatTimestamp(datum.datetime as number)}
-
Event: {datum.series as string}
-
Users: {Number(datum.value).toLocaleString()}
-
- )} -
-
-
- - setWidth(Math.max(100, Number(e.target.value)))} - style={{ width: MAX_WIDTH }} - /> -
-
-
- ); -}; +const StaticPointSizeAutoDetectStory = (): ReactElement => ( + { + let currentSize = 'L'; + if (width < CHART_SIZE_BREAKPOINTS.M) currentSize = 'S'; + else if (width < CHART_SIZE_BREAKPOINTS.L) currentSize = 'M'; + return ( + <> + Width: {Math.round(width)}px — Size tier: {currentSize} — Point diameter:{' '} + {POINT_DIAMETERS[currentSize]} + + ); + }} + > + {(width) => ( + + + + + + {(datum) => ( +
+
{formatTimestamp(datum.datetime as number)}
+
Event: {datum.series as string}
+
Users: {Number(datum.value).toLocaleString()}
+
+ )} +
+
+
+ )} +
+); export const StaticPointSizeAutoDetect = StaticPointSizeAutoDetectStory; diff --git a/packages/react-spectrum-charts-s2/src/stories/storyUtils.ts b/packages/react-spectrum-charts-s2/src/stories/storyUtils.ts deleted file mode 100644 index 99f22a86b..000000000 --- a/packages/react-spectrum-charts-s2/src/stories/storyUtils.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ -/** - * converts a timestamp to a MMM D format - * @param timestamp - * @returns - */ -export const formatTimestamp = (timestamp: number): string => { - // Create a Date object from the timestamp (assuming the timestamp is in milliseconds) - const date = new Date(timestamp); - - // Define an array of month abbreviations - const monthAbbreviations = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - - // Get the month and day from the Date object - const month = monthAbbreviations[date.getMonth()]; - const day = date.getDate(); - - // Format the date in 'MMM D' format - return `${month} ${day}`; -}; diff --git a/packages/react-spectrum-charts-s2/src/stories/storyUtils.tsx b/packages/react-spectrum-charts-s2/src/stories/storyUtils.tsx new file mode 100644 index 000000000..654cc1fd1 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/stories/storyUtils.tsx @@ -0,0 +1,144 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { ReactElement, ReactNode, useState } from 'react'; + +import { CHART_SIZE_BREAKPOINTS } from '@spectrum-charts/constants'; + +const RESIZE_THUMB_HEIGHT = 32; +export const RESIZE_MAX_WIDTH = CHART_SIZE_BREAKPOINTS.L + 200; + +export const CHART_SIZE_THRESHOLDS = [ + { px: CHART_SIZE_BREAKPOINTS.M, label: 'M' }, + { px: CHART_SIZE_BREAKPOINTS.L, label: 'L' }, +]; + +interface ResizableChartProps { + chartHeight?: number; + maxWidth?: number; + thresholds?: Array<{ px: number; label: string }>; + renderLabel?: (width: number) => ReactNode; + initialWidth?: number; + children: (width: number) => ReactElement; +} + +export const ResizableChart = ({ + chartHeight = 300, + maxWidth = RESIZE_MAX_WIDTH, + thresholds, + renderLabel, + initialWidth = 600, + children, +}: ResizableChartProps): ReactElement => { + const [width, setWidth] = useState(initialWidth); + + const handleStyles = ` + .rsc-resize-handle { + -webkit-appearance: none; + appearance: none; + background: transparent; + border: none; + outline: none; + position: absolute; + top: 0; + left: 0; + height: ${chartHeight}px; + pointer-events: none; + z-index: 20; + } + .rsc-resize-handle::-webkit-slider-runnable-track { + background: transparent; + height: ${chartHeight}px; + } + .rsc-resize-handle::-webkit-slider-thumb { + -webkit-appearance: none; + width: 8px; + height: ${RESIZE_THUMB_HEIGHT}px; + border-radius: 4px; + background: #999; + cursor: ew-resize; + pointer-events: all; + margin-top: ${(chartHeight - RESIZE_THUMB_HEIGHT) / 2}px; + } + .rsc-resize-handle::-moz-range-track { background: transparent; } + .rsc-resize-handle::-moz-range-thumb { + width: 8px; + height: ${RESIZE_THUMB_HEIGHT}px; + border-radius: 4px; + background: #999; + border: none; + cursor: ew-resize; + } + `; + + return ( +
+ +
+ {renderLabel ? renderLabel(width) : <>Width: {Math.round(width)}px} +
+
+ {thresholds?.map(({ px, label }) => ( +
+ + {label} ({px}px) + +
+ ))} +
+ {children(width)} + setWidth(Math.max(100, Number(e.target.value)))} + style={{ width: maxWidth }} + /> +
+
+
+ ); +}; + +/** + * converts a timestamp to a MMM D format + * @param timestamp + * @returns + */ +export const formatTimestamp = (timestamp: number): string => { + // Create a Date object from the timestamp (assuming the timestamp is in milliseconds) + const date = new Date(timestamp); + + // Define an array of month abbreviations + const monthAbbreviations = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + // Get the month and day from the Date object + const month = monthAbbreviations[date.getMonth()]; + const day = date.getDate(); + + // Format the date in 'MMM D' format + return `${month} ${day}`; +}; From d42855d64a56a8ec586f2f890d8b70c8c9941815 Mon Sep 17 00:00:00 2001 From: Maddi personal Date: Tue, 16 Jun 2026 15:17:17 -0600 Subject: [PATCH 4/8] fix: update wrapTitleText logic to use getLabelWidth func for accurate text width representation, updated tests --- .../src/VegaChart.test.tsx | 3 +- .../src/specUtils.test.ts | 9 +++--- .../vega-spec-builder-s2/src/specUtils.ts | 30 ++++++++++++++----- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx index c82671b62..4f7b7e725 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx @@ -101,7 +101,8 @@ describe('rscWrapTitle expression function', () => { }); test('wraps text onto multiple lines when it exceeds maxWidth', () => { - expect(fn('Quarterly Revenue Growth by Product Category and Geographic Region', 440)).toStrictEqual([ + // jsdom measureText returns text.length px; maxWidth=40 fits "...by Product" (35) but not "...Category" (44) + expect(fn('Quarterly Revenue Growth by Product Category and Geographic Region', 40)).toStrictEqual([ 'Quarterly Revenue Growth by Product', 'Category and Geographic Region', ]); diff --git a/packages/vega-spec-builder-s2/src/specUtils.test.ts b/packages/vega-spec-builder-s2/src/specUtils.test.ts index 6693dee6f..22478de5f 100644 --- a/packages/vega-spec-builder-s2/src/specUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/specUtils.test.ts @@ -211,24 +211,23 @@ describe('wrapTitleText()', () => { }); test('returns text as single element when it fits on one line', () => { - // 440px → maxCharsPerLine=40; "Page Views by Region" = 20 chars expect(wrapTitleText('Page Views by Region', 440)).toStrictEqual(['Page Views by Region']); }); test('wraps a long title onto two lines at a word boundary', () => { - // 440px → maxCharsPerLine=40; "...by Product"(35) fits, adding "Category"(44) wraps - expect(wrapTitleText('Quarterly Revenue Growth by Product Category and Geographic Region', 440)).toStrictEqual([ + // jsdom measureText returns text.length px; maxWidth=40 fits "...by Product" (35) but not "...Category" (44) + expect(wrapTitleText('Quarterly Revenue Growth by Product Category and Geographic Region', 40)).toStrictEqual([ 'Quarterly Revenue Growth by Product', 'Category and Geographic Region', ]); }); test('wraps a very long title onto four lines', () => { - // 264px → maxCharsPerLine=24 + // jsdom measureText returns text.length px; maxWidth=24 gives ~24 chars per line expect( wrapTitleText( 'Total Year over Year Revenue Growth Rate by Region for All Products in Q4 2024 Report', - 264 + 24 ) ).toStrictEqual([ 'Total Year over Year', diff --git a/packages/vega-spec-builder-s2/src/specUtils.ts b/packages/vega-spec-builder-s2/src/specUtils.ts index 6ed151c24..8938aa294 100644 --- a/packages/vega-spec-builder-s2/src/specUtils.ts +++ b/packages/vega-spec-builder-s2/src/specUtils.ts @@ -19,7 +19,24 @@ import { S2_TITLE_FONT_SIZE, getS2ColorValue, getSpectrum2VegaConfig } from '@sp -import { ChartSpecOptions, ChartSymbolShape, ColorFacet, ColorScheme, DualFacet, Icon, LineType, LineTypeFacet, LineWidth, NumberFormat, OpacityFacet, ScSpec, SymbolSize, SymbolSizeFacet, UserMeta } from './types'; +import { expressionFunctions } from './expressionFunctions/expressionFunctions'; +import { + ChartSpecOptions, + ChartSymbolShape, + ColorFacet, + ColorScheme, + DualFacet, + Icon, + LineType, + LineTypeFacet, + LineWidth, + NumberFormat, + OpacityFacet, + ScSpec, + SymbolSize, + SymbolSizeFacet, + UserMeta, +} from './types'; /** @@ -223,22 +240,19 @@ export const initializeSpec = (spec: Spec | null = {}, chartOptions: Partial { - const maxCharsPerLine = Math.floor(maxWidth / (S2_TITLE_FONT_SIZE * AVG_CHAR_WIDTH_RATIO)); - if (maxCharsPerLine <= 0) return [text]; + if (maxWidth <= 0) return [text]; const lines = text.split(' ').reduce((lines, word) => { const last = lines.at(-1); if (last !== undefined) { const candidate = `${last} ${word}`; - if (candidate.length <= maxCharsPerLine) { + const candidateWidth = expressionFunctions.getLabelWidth(candidate, 'bold', S2_TITLE_FONT_SIZE); + if (candidateWidth <= maxWidth) { return [...lines.slice(0, -1), candidate]; } } From e0126535ccc0e280b55819dbedf708e956d9576b Mon Sep 17 00:00:00 2001 From: Maddi personal Date: Wed, 17 Jun 2026 11:32:46 -0600 Subject: [PATCH 5/8] fix: call wrapTitleText init right after fonts are loaded, to prevent jumpy re-rendering --- .../src/VegaChart.test.tsx | 1 + .../src/VegaChart.tsx | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx index 4f7b7e725..597481352 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx @@ -30,6 +30,7 @@ const createMockView = (): View => resize: mockResize, height: mockHeight, width: mockWidth, + signal: jest.fn(), finalize: jest.fn(), }) as unknown as View; diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.tsx index 602ff1fdf..a40400a4f 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.tsx @@ -34,6 +34,7 @@ expressionFunction('rscContainerWidth', function (this: { context: { dataflow: V return viewWidth + (p.left ?? 0) + (p.right ?? 0); }); +// Handles making sure the title wraps correctly on resize expressionFunction('rscWrapTitle', (text: string, maxWidth: number): string[] => wrapTitleText(text, maxWidth) ); @@ -144,6 +145,25 @@ export const VegaChart: FC = ({ view.runAsync(); // One additional render to settle all resize calculations setTimeout(() => view.runAsync(), 0); + + // getLabelWidth uses canvas measureText which depends on loaded fonts. Call it once after + // fonts are ready so the initial title wrapping uses accurate Adobe Clean metrics. Subsequent + // evaluations fire via the rscWrapTitle expression on resize, by which time the font is loaded. + // Only run when the spec has title signals (i.e. a title was set). + const hasTitleSignal = specCopy.signals?.some((s) => s.name === 'rscTitleText'); + if (hasTitleSignal) { + (document.fonts?.ready ?? Promise.resolve()).then(() => { + if (view !== chartView.current) { + return; + } + const titleText = view.signal('rscTitleText') as string; + if (!titleText) { + return; + } + const limit = view.signal('rscTitleLimit') as number; + view.signal('rscWrappedTitleText', wrapTitleText(titleText, limit)).runAsync(); + }); + } }); } return () => { From f74eeb8d8fa370c9c6a23c3cd155433a020c3b3f Mon Sep 17 00:00:00 2001 From: Maddi personal Date: Wed, 17 Jun 2026 13:15:19 -0600 Subject: [PATCH 6/8] chore: adding test to VegaChart for init wrap text call --- .../src/VegaChart.test.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx index 597481352..44898608e 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx @@ -131,6 +131,40 @@ describe('rscContainerWidth expression function', () => { }); }); +describe('title wrapping after font load', () => { + let mockView: View; + + beforeEach(() => { + jest.clearAllMocks(); + mockView = createMockView(); + mockEmbed.mockResolvedValue({ view: mockView } as unknown as Awaited>); + }); + + test('does not call view.signal when spec has no title signals', async () => { + render(); + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + // flush all pending promises so the fonts.ready path would have had a chance to run + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mockView.signal).not.toHaveBeenCalled(); + }); + + test('sets rscWrappedTitleText after fonts load when spec has a title signal', async () => { + const titleSpec: Spec = { + signals: [{ name: 'rscTitleText', value: 'My Title' }], + }; + (mockView.signal as jest.Mock) + .mockReturnValueOnce('My Title') // read rscTitleText + .mockReturnValueOnce(440) // read rscTitleLimit + .mockReturnValue(mockView); // write rscWrappedTitleText — return view for .runAsync() chaining + + render(); + + await waitFor(() => + expect(mockView.signal).toHaveBeenCalledWith('rscWrappedTitleText', expect.any(Array)) + ); + }); +}); + // AN-445759: regression tests for the init render cycle fix describe('VegaChart init render cycle', () => { beforeEach(() => { From 9f0683f332a54cefde0c4078de0124074e3ddfd7 Mon Sep 17 00:00:00 2001 From: Maddi personal Date: Wed, 17 Jun 2026 13:35:27 -0600 Subject: [PATCH 7/8] chore: add tests for early returns --- .../src/VegaChart.test.tsx | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx index 44898608e..b31163a1d 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx @@ -140,6 +140,10 @@ describe('title wrapping after font load', () => { mockEmbed.mockResolvedValue({ view: mockView } as unknown as Awaited>); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + test('does not call view.signal when spec has no title signals', async () => { render(); await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); @@ -163,6 +167,54 @@ describe('title wrapping after font load', () => { expect(mockView.signal).toHaveBeenCalledWith('rscWrappedTitleText', expect.any(Array)) ); }); + + test('skips signal update when title text is empty', async () => { + const titleSpec: Spec = { + signals: [{ name: 'rscTitleText', value: '' }], + }; + (mockView.signal as jest.Mock).mockReturnValueOnce(''); + + render(); + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockView.signal).not.toHaveBeenCalledWith('rscWrappedTitleText', expect.any(Array)); + }); + + test('skips signal update when view is stale at font load time', async () => { + let resolveFontsReady!: () => void; + const deferred = new Promise((resolve) => { + resolveFontsReady = resolve; + }); + + // Override document.fonts with a deferred ready promise so we control when it fires. + // jest.spyOn can't reach it because it lives on the prototype, not the document instance. + const savedDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts'); + Object.defineProperty(document, 'fonts', { get: () => ({ ready: deferred }), configurable: true }); + + const titleSpec: Spec = { + signals: [{ name: 'rscTitleText', value: 'My Title' }], + }; + + const { unmount } = render(); + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + + // Unmount sets chartView.current = undefined via effect cleanup + unmount(); + + // Resolve fonts.ready after unmount — stale view guard should prevent signal update + resolveFontsReady(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockView.signal).not.toHaveBeenCalledWith('rscWrappedTitleText', expect.any(Array)); + + // Restore the original descriptor (delete the own-property override to re-expose the prototype getter) + if (savedDescriptor) { + Object.defineProperty(document, 'fonts', savedDescriptor); + } else { + delete (document as unknown as Record).fonts; + } + }); }); // AN-445759: regression tests for the init render cycle fix From 18bc0b5957e16eea41a95c634b0d8986f4627daf Mon Sep 17 00:00:00 2001 From: Tanu Garg Date: Tue, 7 Jul 2026 15:59:38 -0600 Subject: [PATCH 8/8] fix: fix title flicker upon hovering --- .../src/VegaChart.test.tsx | 159 ++++++++++++++++-- .../src/VegaChart.tsx | 120 +++++++++---- .../vega-spec-builder-s2/src/specUtils.ts | 12 +- 3 files changed, 248 insertions(+), 43 deletions(-) diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx index b31163a1d..f190014e7 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.test.tsx @@ -13,6 +13,8 @@ import { render, waitFor } from '@testing-library/react'; import { Spec, View, expressionFunction } from 'vega'; import embed from 'vega-embed'; +import { getTitleFontShorthand } from '@spectrum-charts/vega-spec-builder-s2'; + import { VegaChart, VegaChartProps, resizeView } from './VegaChart'; jest.mock('vega-embed'); @@ -50,6 +52,20 @@ const defaultProps: VegaChartProps = { width: 800, }; +// Overrides document.fonts (not implemented by jsdom) for the duration of a test. jest.spyOn +// can't reach it because it lives on the prototype, not the document instance. +const stubDocumentFonts = (fonts: unknown): (() => void) => { + const savedDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts'); + Object.defineProperty(document, 'fonts', { get: () => fonts, configurable: true }); + return () => { + if (savedDescriptor) { + Object.defineProperty(document, 'fonts', savedDescriptor); + } else { + delete (document as unknown as Record).fonts; + } + }; +}; + describe('resizeView', () => { beforeEach(() => { jest.clearAllMocks(); @@ -187,10 +203,9 @@ describe('title wrapping after font load', () => { resolveFontsReady = resolve; }); - // Override document.fonts with a deferred ready promise so we control when it fires. - // jest.spyOn can't reach it because it lives on the prototype, not the document instance. - const savedDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts'); - Object.defineProperty(document, 'fonts', { get: () => ({ ready: deferred }), configurable: true }); + // `load` resolves immediately so the pre-embed gate doesn't block this test's initial + // embed() call; `ready` stays pending so we control when the post-embed corrective path fires. + const restoreFonts = stubDocumentFonts({ load: jest.fn().mockResolvedValue([]), ready: deferred }); const titleSpec: Spec = { signals: [{ name: 'rscTitleText', value: 'My Title' }], @@ -208,12 +223,136 @@ describe('title wrapping after font load', () => { expect(mockView.signal).not.toHaveBeenCalledWith('rscWrappedTitleText', expect.any(Array)); - // Restore the original descriptor (delete the own-property override to re-expose the prototype getter) - if (savedDescriptor) { - Object.defineProperty(document, 'fonts', savedDescriptor); - } else { - delete (document as unknown as Record).fonts; - } + restoreFonts(); + }); +}); + +describe('initial embed font readiness gate', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockEmbed.mockResolvedValue({ view: createMockView() } as unknown as Awaited>); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const titleSpec: Spec = { signals: [{ name: 'rscTitleText', value: 'My Title' }] }; + + test('delays the initial embed until document.fonts.load resolves when a title is present', async () => { + let resolveLoad!: () => void; + const load = jest.fn().mockReturnValue(new Promise((resolve) => (resolveLoad = resolve))); + const restoreFonts = stubDocumentFonts({ load }); + + render(); + + expect(load).toHaveBeenCalledTimes(1); + expect(mockEmbed).not.toHaveBeenCalled(); + + resolveLoad(); + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + + restoreFonts(); + }); + + test('calls document.fonts.load with the same font shorthand wrapTitleText measures with', async () => { + const load = jest.fn().mockResolvedValue([]); + const restoreFonts = stubDocumentFonts({ load }); + + render(); + + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + expect(load).toHaveBeenCalledWith(getTitleFontShorthand()); + + restoreFonts(); + }); + + test('does not call document.fonts.load and embeds immediately when there is no title', async () => { + const load = jest.fn().mockResolvedValue([]); + const restoreFonts = stubDocumentFonts({ load }); + + render(); + + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + expect(load).not.toHaveBeenCalled(); + + restoreFonts(); + }); + + test('does not call document.fonts.load and embeds immediately when the title is an empty string', async () => { + const load = jest.fn().mockResolvedValue([]); + const restoreFonts = stubDocumentFonts({ load }); + const emptyTitleSpec: Spec = { signals: [{ name: 'rscTitleText', value: '' }] }; + + render(); + + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + expect(load).not.toHaveBeenCalled(); + + restoreFonts(); + }); + + test('still embeds if document.fonts.load rejects', async () => { + const load = jest.fn().mockRejectedValue(new Error('font fetch failed')); + const restoreFonts = stubDocumentFonts({ load }); + + render(); + + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + + restoreFonts(); + }); + + test('proceeds with the initial embed after the timeout even if document.fonts.load never resolves', async () => { + jest.useFakeTimers(); + const load = jest.fn().mockReturnValue(new Promise(() => {})); + const restoreFonts = stubDocumentFonts({ load }); + + render(); + expect(mockEmbed).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(250); + + expect(mockEmbed).toHaveBeenCalledTimes(1); + + restoreFonts(); + }); + + test('gates only the first embed — a later re-embed does not wait on document.fonts.load again', async () => { + const load = jest.fn().mockResolvedValue([]); + const restoreFonts = stubDocumentFonts({ load }); + + const { rerender } = render(); + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + expect(load).toHaveBeenCalledTimes(1); + + // A new spec object reference (e.g. from a hover-driven prop update upstream) re-runs the + // embed effect; it must re-embed synchronously rather than waiting on the font again. + const secondTitleSpec: Spec = { signals: [{ name: 'rscTitleText', value: 'My Title' }] }; + rerender(); + + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(2)); + expect(load).toHaveBeenCalledTimes(1); + + restoreFonts(); + }); + + test('does not gate a title that first appears after an untitled initial mount', async () => { + const load = jest.fn().mockResolvedValue([]); + const restoreFonts = stubDocumentFonts({ load }); + + const { rerender } = render(); + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + expect(load).not.toHaveBeenCalled(); + + // Title arrives on a later render (e.g. computed from async data) — this must not be + // treated as the "initial" embed and delayed, since the chart is already visible. + rerender(); + + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(2)); + expect(load).not.toHaveBeenCalled(); + + restoreFonts(); }); }); diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.tsx index a40400a4f..ae8dc532f 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.tsx @@ -17,7 +17,14 @@ import { Options as TooltipOptions } from 'vega-tooltip'; import { TABLE } from '@spectrum-charts/constants'; import { getLocale } from '@spectrum-charts/locales'; -import { ChartData, UserMeta, applyUserMetaConfigPatches, getVegaEmbedOptions, wrapTitleText } from '@spectrum-charts/vega-spec-builder-s2'; +import { + ChartData, + UserMeta, + applyUserMetaConfigPatches, + getTitleFontShorthand, + getVegaEmbedOptions, + wrapTitleText, +} from '@spectrum-charts/vega-spec-builder-s2'; import { useDebugSpec } from './hooks/useDebugSpec'; import { extractValues, isVegaData } from './hooks/useSpec'; @@ -50,6 +57,31 @@ export const resizeView = (view: View | undefined, width: number, height: number } }; +// Upper bound on how long the initial embed will wait for the title font. Font loads are +// normally near-instant (cached or already triggered by other chart text), but a blocked or +// slow font fetch must never delay the chart's first paint indefinitely. +const TITLE_FONT_READY_TIMEOUT_MS = 250; + +/** + * Resolves once the title font is available for canvas measurement, or after + * TITLE_FONT_READY_TIMEOUT_MS, whichever comes first. Vega evaluates the rscWrapTitle expression + * synchronously while parsing the spec in embed(), so if the font isn't loaded yet, getLabelWidth + * silently measures with a fallback font and the title wraps incorrectly on the first paint. + * Awaiting this before the initial embed avoids that bad frame in the common case. Never rejects + * and never blocks longer than the timeout, so a stalled or failed font fetch can't block rendering. + */ +const ensureTitleFontReady = (): Promise => { + if (typeof document === 'undefined' || !document.fonts) { + return Promise.resolve(); + } + const fontReady = + typeof document.fonts.load === 'function' + ? document.fonts.load(getTitleFontShorthand()).catch(() => undefined) + : (document.fonts.ready ?? Promise.resolve()); + const timeout = new Promise((resolve) => setTimeout(resolve, TITLE_FONT_READY_TIMEOUT_MS)); + return Promise.race([fontReady, timeout]); +}; + export interface VegaChartProps { config: Config; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -83,6 +115,12 @@ export const VegaChart: FC = ({ const containerRef = useRef(null); const chartView = useRef(undefined); const hasMounted = useRef(false); + // Only the very first embed attempt (titled or not) may wait on font readiness — every later + // re-embed (triggered by prop identity changes, e.g. hover-driven state updates, or a title + // appearing after an initial untitled mount) must stay synchronous, since by then the font has + // virtually certainly already loaded and any added delay makes an otherwise-instant view + // destroy/recreate visibly flicker. + const hasAttemptedEmbed = useRef(false); // AN-445759: flipped to true when dimensions become valid post-mount with no existing view, // forcing the embed effect to run even though width/height are not in its deps. const [needsInitEmbed, setNeedsInitEmbed] = useState(false); @@ -120,6 +158,7 @@ export const VegaChart: FC = ({ }, [width, height]); useEffect(() => { + let cancelled = false; if (width && height && containerRef.current) { const specCopy = JSON.parse(JSON.stringify(spec)) as Spec; const tableData = specCopy.data?.find((d) => d.name === TABLE); @@ -134,39 +173,58 @@ export const VegaChart: FC = ({ return signal; }); } - const embedOptions = getVegaEmbedOptions({ locale, height, width, padding, renderer, config }); - const { patches } = (specCopy.usermeta as UserMeta | undefined) ?? {}; - const finalConfig = applyUserMetaConfigPatches(patches, embedOptions.config); - - embed(containerRef.current, specCopy, { ...embedOptions, config: finalConfig, tooltip }).then(({ view }) => { - chartView.current = view; - onNewView(view); - view.resize(); - view.runAsync(); - // One additional render to settle all resize calculations - setTimeout(() => view.runAsync(), 0); - - // getLabelWidth uses canvas measureText which depends on loaded fonts. Call it once after - // fonts are ready so the initial title wrapping uses accurate Adobe Clean metrics. Subsequent - // evaluations fire via the rscWrapTitle expression on resize, by which time the font is loaded. - // Only run when the spec has title signals (i.e. a title was set). - const hasTitleSignal = specCopy.signals?.some((s) => s.name === 'rscTitleText'); - if (hasTitleSignal) { - (document.fonts?.ready ?? Promise.resolve()).then(() => { - if (view !== chartView.current) { - return; - } - const titleText = view.signal('rscTitleText') as string; - if (!titleText) { - return; - } - const limit = view.signal('rscTitleLimit') as number; - view.signal('rscWrappedTitleText', wrapTitleText(titleText, limit)).runAsync(); - }); + + const titleSignal = specCopy.signals?.find((s) => s.name === 'rscTitleText'); + const titleValue = titleSignal && 'value' in titleSignal ? titleSignal.value : undefined; + const hasTitle = typeof titleValue === 'string' && titleValue.length > 0; + + const runEmbed = () => { + if (cancelled || !containerRef.current) { + return; } - }); + const embedOptions = getVegaEmbedOptions({ locale, height, width, padding, renderer, config }); + const { patches } = (specCopy.usermeta as UserMeta | undefined) ?? {}; + const finalConfig = applyUserMetaConfigPatches(patches, embedOptions.config); + + embed(containerRef.current, specCopy, { ...embedOptions, config: finalConfig, tooltip }).then(({ view }) => { + chartView.current = view; + onNewView(view); + view.resize(); + view.runAsync(); + // One additional render to settle all resize calculations + setTimeout(() => view.runAsync(), 0); + + // The initial embed above is already gated on font readiness via ensureTitleFontReady + // when a title is present, so this re-evaluates the wrap as a safety net in case the + // font wasn't actually ready for canvas measurement by the time embed() parsed the spec. + // Subsequent evaluations fire via the rscWrapTitle expression on resize. + if (hasTitle) { + (document.fonts?.ready ?? Promise.resolve()).then(() => { + if (view !== chartView.current) { + return; + } + const titleText = view.signal('rscTitleText') as string; + if (!titleText) { + return; + } + const limit = view.signal('rscTitleLimit') as number; + view.signal('rscWrappedTitleText', wrapTitleText(titleText, limit)).runAsync(); + }); + } + }); + }; + + const isInitialEmbedAttempt = !hasAttemptedEmbed.current; + hasAttemptedEmbed.current = true; + + if (hasTitle && isInitialEmbedAttempt) { + ensureTitleFontReady().then(runEmbed); + } else { + runEmbed(); + } } return () => { + cancelled = true; // destroy the chart on unmount if (chartView.current) { chartView.current.finalize(); diff --git a/packages/vega-spec-builder-s2/src/specUtils.ts b/packages/vega-spec-builder-s2/src/specUtils.ts index 8938aa294..6fcb2f8d5 100644 --- a/packages/vega-spec-builder-s2/src/specUtils.ts +++ b/packages/vega-spec-builder-s2/src/specUtils.ts @@ -15,7 +15,7 @@ import { Config, Data, Scale, ScaleType, Spec, mergeConfig } from 'vega'; import { COLOR_SCALE, DATE_PATH, DEFAULT_TRANSFORMED_TIME_DIMENSION, FILTERED_TABLE, LINE_TYPE_SCALE, MARK_ID, OPACITY_SCALE, ROUNDED_SQUARE_PATH, SENTIMENT_NEGATIVE_PATH, SENTIMENT_NEUTRAL_PATH, SENTIMENT_POSITIVE_PATH, TABLE } from '@spectrum-charts/constants'; -import { S2_TITLE_FONT_SIZE, getS2ColorValue, getSpectrum2VegaConfig } from '@spectrum-charts/themes'; +import { ADOBE_CLEAN_FONT, S2_TITLE_FONT_SIZE, getS2ColorValue, getSpectrum2VegaConfig } from '@spectrum-charts/themes'; @@ -240,6 +240,14 @@ export const initializeSpec = (spec: Spec | null = {}, chartOptions: Partial `${TITLE_FONT_WEIGHT} ${S2_TITLE_FONT_SIZE}px ${ADOBE_CLEAN_FONT}`; + /** * Splits a title string into lines that fit within maxWidth pixels, breaking at word boundaries. * Uses canvas text measurement with the S2 title font for accurate line widths. @@ -251,7 +259,7 @@ export const wrapTitleText = (text: string, maxWidth: number): string[] => { const last = lines.at(-1); if (last !== undefined) { const candidate = `${last} ${word}`; - const candidateWidth = expressionFunctions.getLabelWidth(candidate, 'bold', S2_TITLE_FONT_SIZE); + const candidateWidth = expressionFunctions.getLabelWidth(candidate, TITLE_FONT_WEIGHT, S2_TITLE_FONT_SIZE); if (candidateWidth <= maxWidth) { return [...lines.slice(0, -1), candidate]; }