Skip to content
16 changes: 16 additions & 0 deletions packages/react-spectrum-charts-s2/src/VegaChart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion packages/react-spectrum-charts-s2/src/VegaChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
*/
Expand Down
20 changes: 19 additions & 1 deletion packages/react-spectrum-charts-s2/src/stories/Chart.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ const ChartLineStory: StoryFn<typeof Chart> = (args): ReactElement => {
);
};

const LongTitleStory: StoryFn<typeof Chart> = (args): ReactElement => {
const props = useChartProps(args);
return (
<Chart {...props}>
<Axis position="bottom" baseline ticks labelFormat="time" />
<Axis position="left" grid />
<Line dimension="datetime" metric="value" color="series" scaleType="time" />
<Legend />
</Chart>
);
};

const ChartTimeStory: StoryFn<typeof Chart> = (args): ReactElement => {
const props = useChartProps(args);
return (
Expand Down Expand Up @@ -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 };
4 changes: 3 additions & 1 deletion packages/themes/src/spectrum2Theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
Expand Down
30 changes: 30 additions & 0 deletions packages/vega-spec-builder-s2/src/chartSpecBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
15 changes: 14 additions & 1 deletion packages/vega-spec-builder-s2/src/chartSpecBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down Expand Up @@ -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<ScSpec>((spec) => {
spec.scales = spec.scales?.filter((scale) => {
return !('domain' in scale && scale.domain && 'fields' in scale.domain && scale.domain.fields.length === 0);
Expand Down
35 changes: 35 additions & 0 deletions packages/vega-spec-builder-s2/src/specUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
getPathFromSymbolShape,
getStrokeDashFromLineType,
getVegaSymbolSizeFromRscSymbolSize,
wrapTitleText,
} from './specUtils';

const defaultColorScale: OrdinalScale = {
Expand Down Expand Up @@ -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'] });
Expand Down
67 changes: 34 additions & 33 deletions packages/vega-spec-builder-s2/src/specUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -247,6 +223,31 @@ export const initializeSpec = (spec: Spec | null = {}, chartOptions: Partial<Cha
return { ...baseSpec, ...(spec || {}) };
};

// Average character width ratio for adobe-clean at the title font size
const AVG_CHAR_WIDTH_RATIO = 0.5;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may not be dynamic enough.
Title accepts fontWeight. We might be able to estimate size using the fontWeight value, but we have an existing expression function that draws the actual text to get the exact size.

If we could either use that or make a title specific function (if necessary), we wouldn't have to worry about future props effecting this size either.

const getLabelWidth = (text: string, fontWeight: FontWeight = 'bold', fontSize: number = 12) => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (context === null) return 0;
context.font = `${fontWeight} ${fontSize}px ${ADOBE_CLEAN_FONT}`;
return context.measureText(text).width;
};

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, this was my biggest concern with this solution. Let me try using this existing function!


/**
* Splits a title string into lines that fit within maxWidth pixels, breaking at word boundaries.
* Uses a font-metric approximation based on the S2 title font size.
*/
export const wrapTitleText = (text: string, maxWidth: number): string[] => {
const maxCharsPerLine = Math.floor(maxWidth / (S2_TITLE_FONT_SIZE * AVG_CHAR_WIDTH_RATIO));
if (maxCharsPerLine <= 0) return [text];

const lines = text.split(' ').reduce<string[]>((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.
Expand Down
Loading