Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ module.exports = {
'react/jsx-uses-vars': 'error',
'react/jsx-uses-react': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
'jest/no-focused-tests': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
Expand Down
15 changes: 14 additions & 1 deletion packages/react-spectrum-charts/src/VegaChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ import { Options as TooltipOptions } from 'vega-tooltip';

import { TABLE } from '@spectrum-charts/constants';

import { expressionFunctions, formatLocaleCurrency, formatTimeDurationLabels } from './expressionFunctions';
import {
expressionFunctions,
formatLocaleCurrency,
formatShortNumber,
formatTimeDurationLabels,
} from './expressionFunctions';
import { useDebugSpec } from './hooks/useDebugSpec';
import { extractValues, isVegaData } from './hooks/useSpec';
import { ChartData, ChartProps } from './types';
Expand Down Expand Up @@ -57,6 +62,12 @@ export const VegaChart: FC<VegaChartProps> = ({
const chartView = useRef<View>();

const { number: numberLocale, time: timeLocale } = useMemo(() => getLocale(locale), [locale]);
const localeCode = useMemo(() => {
if (typeof locale === 'string') {
return locale;
}
return locale?.number;
}, [locale]);

// Need to de a deep copy of the data because vega tries to transform the data
const chartData = useMemo(() => {
Expand Down Expand Up @@ -96,6 +107,7 @@ export const VegaChart: FC<VegaChartProps> = ({
...expressionFunctions,
formatTimeDurationLabels: formatTimeDurationLabels(numberLocale),
formatLocaleCurrency: formatLocaleCurrency(numberLocale),
formatShortNumber: formatShortNumber(localeCode),
},
formatLocale: numberLocale as unknown as Record<string, unknown>, // these are poorly typed by vega-embed
height,
Expand Down Expand Up @@ -134,6 +146,7 @@ export const VegaChart: FC<VegaChartProps> = ({
spec,
tooltip,
width,
localeCode,
]);

return <div ref={containerRef} className="rsc"></div>;
Expand Down
43 changes: 43 additions & 0 deletions packages/react-spectrum-charts/src/expressionFunctions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
expressionFunctions,
formatHorizontalTimeAxisLabels,
formatLocaleCurrency,
formatShortNumber,
formatTimeDurationLabels,
formatVerticalAxisTimeLabels,
} from './expressionFunctions';
Expand Down Expand Up @@ -157,3 +158,45 @@ describe('formatVerticalAxisTimeLabels()', () => {
expect(formatter({ index: 1, label: '2024 \u2000Feb', value: 1 })).toBe('Feb');
});
});

describe('formatShortNumber()', () => {
test('should revturn the correst string based on the value', () => {
expect(formatShortNumber('en-US')(123)).toBe('123');
expect(formatShortNumber('en-US')(1234)).toBe('1.2K');
expect(formatShortNumber('en-US')(12345)).toBe('12K');
expect(formatShortNumber('en-US')(123456)).toBe('123K');
expect(formatShortNumber('en-US')(1234567)).toBe('1.2M');
expect(formatShortNumber('en-US')(12345678)).toBe('12M');
expect(formatShortNumber('en-US')(123456789)).toBe('123M');
expect(formatShortNumber('en-US')(1234567890)).toBe('1.2B');
expect(formatShortNumber('en-US')(12345678900)).toBe('12B');
expect(formatShortNumber('en-US')(123456789000)).toBe('123B');
expect(formatShortNumber('en-US')(1234567890000)).toBe('1.2T');
expect(formatShortNumber('en-US')(12345678900000)).toBe('12T');
expect(formatShortNumber('en-US')(123456789000000)).toBe('123T');
expect(formatShortNumber('en-US')(1234567890000000)).toBe('1235T');
});
test('should return the correct string based on locale', () => {
expect(formatShortNumber('en-US')(123456789)).toBe('123M');
expect(formatShortNumber('es-ES')(123456789)).toBe('123\u00a0M');
expect(formatShortNumber('fr-FR')(123456789)).toBe('123\u00a0M');
expect(formatShortNumber('de-DE')(123456789)).toBe('123\u00a0Mio.');
expect(formatShortNumber('ja-JP')(123456789)).toBe('1.2億');
expect(formatShortNumber('zh-CN')(123456789)).toBe('1.2亿');
expect(formatShortNumber('zh-TW')(123456789)).toBe('1.2億');
expect(formatShortNumber('ko-KR')(123456789)).toBe('1.2억');
expect(formatShortNumber('ru-RU')(123456789)).toBe('123\u00a0млн');
expect(formatShortNumber('pt-BR')(123456789)).toBe('123\u00a0mi');
});
test('should use custom decimal symbol if provided', () => {
expect(
formatShortNumber({
decimal: ',',
thousands: '\u00a0',
grouping: [3],
currency: ['', '\u00a0€'],
percent: '\u202f%',
})(1234567)
).toBe('1,2M');
});
});
22 changes: 22 additions & 0 deletions packages/react-spectrum-charts/src/expressionFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,28 @@ export interface LabelDatum {
value: string | number;
}

/**
* Formats a number using the compact notation.
* @param numberLocale
* @returns formatted string
*/
export const formatShortNumber = (numberLocale?: string | FormatLocaleDefinition) => {
const locale = typeof numberLocale === 'string' ? numberLocale : navigator.language;
const customDecimalSymbol = typeof numberLocale === 'object' ? numberLocale.decimal : undefined;
return (value: number) => {
// get the decimal symbol for the locale by formatting a number with decimals
const decimalSymbol = new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
.format(1.1)
.replace(/\d/g, '');

const shortNumber = Intl.NumberFormat(locale, { notation: 'compact' }).format(value);
if (customDecimalSymbol) {
return shortNumber.replace(decimalSymbol, customDecimalSymbol);
}
return shortNumber;
};
};

/**
* Formats currency values using a currency specific locale and currency code for the position and
* type of currency symbol.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,19 @@ describe('DonutSummary renders properly', () => {

test('metric value should be centered if there is not a label', async () => {
render(<NoLabel {...NoLabel.args} />);
const metricValue = await screen.findByText('40.4K');
const metricValue = await screen.findByText('40K');
expect(metricValue).toHaveAttribute('transform', 'translate(175,190)');
});

test('metric value should be above center if there is a label', async () => {
render(<Basic {...Basic.args} />);
const metricValue = await screen.findByText('40.4K');
const metricValue = await screen.findByText('40K');
expect(metricValue).toHaveAttribute('transform', 'translate(175,175)');
});

test('metric label text should be 1/2 the size of the metric value text', async () => {
render(<Basic {...Basic.args} width={200} height={200} />);
const metricValue = await screen.findByText('40.4K');
const metricValue = await screen.findByText('40K');
expect(metricValue).toHaveAttribute('font-size', '28px');
const metricLabel = await screen.findByText('Visitors');
expect(metricLabel).toHaveAttribute('font-size', '14px');
Expand All @@ -58,62 +58,62 @@ describe('Responsive font sizes should snap to correct font size based on inner
// 160 / 2 * 0.85 * 0.35 = 23.8
// this should be clamped to the min of 28
render(<Basic {...Basic.args} width={160} height={160} />);
expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '28px');
expect(await screen.findByText('40K')).toHaveAttribute('font-size', '28px');
});

test('should snap to 28 if 28 < target < 32', async () => {
// 200 / 2 * 0.85 * 0.35 = 29.75
// this is less than 32 and greater than 28 so it should snap to 28
render(<Basic {...Basic.args} width={200} height={200} />);
expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '28px');
expect(await screen.findByText('40K')).toHaveAttribute('font-size', '28px');
});

test('should snap to 32 if 32 < target < 36', async () => {
// 240 / 2 * 0.85 * 0.35 = 35.7
// this is less than 36 and greater than 32 so it should snap to 32
render(<Basic {...Basic.args} width={240} height={240} />);
expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '32px');
expect(await screen.findByText('40K')).toHaveAttribute('font-size', '32px');
});

test('should snap to 36 if 36 < target < 40', async () => {
// 260 / 2 * 0.85 * 0.35 = 38.675
// this is less than 40 and greater than 36 so it should snap to 36
render(<Basic {...Basic.args} width={260} height={260} />);
expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '36px');
expect(await screen.findByText('40K')).toHaveAttribute('font-size', '36px');
});

test('should snap to 40 if 40 < target < 45', async () => {
// 300 / 2 * 0.85 * 0.35 = 44.625
// this is less than 44 and greater than 40 so it should snap to 40
render(<Basic {...Basic.args} width={300} height={300} />);
expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '40px');
expect(await screen.findByText('40K')).toHaveAttribute('font-size', '40px');
});

test('should snap to 45 if 45 < target < 50', async () => {
// 320 / 2 * 0.85 * 0.35 = 47.6
// this is less than 50 and greater than 45 so it should snap to 45
render(<Basic {...Basic.args} width={320} height={320} />);
expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '45px');
expect(await screen.findByText('40K')).toHaveAttribute('font-size', '45px');
});

test('should snap to 50 if 50 < target < 60', async () => {
// 400 / 2 * 0.85 * 0.35 = 59
// this is less than 60 and greater than 50 so it should snap to 50
render(<Basic {...Basic.args} width={400} height={400} />);
expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '50px');
expect(await screen.findByText('40K')).toHaveAttribute('font-size', '50px');
});

test('should snap to max if target > 60', async () => {
// 600 / 2 * 0.85 * 0.35 = 89.25
// this is greater than 60 so it should snap to 60
render(<Basic {...Basic.args} width={600} height={600} />);
expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '60px');
expect(await screen.findByText('40K')).toHaveAttribute('font-size', '60px');
});
});

describe('Small radius', () => {
test('should hide the summary if the donut inner radius is < DONUT_SUMMARY_MIN_RADIUS', async () => {
render(<Basic {...Basic.args} width={DONUT_SUMMARY_MIN_RADIUS * 2} height={DONUT_SUMMARY_MIN_RADIUS * 2} />);
expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '0px');
expect(await screen.findByText('40K')).toHaveAttribute('font-size', '0px');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ describe('Legend', () => {
expect(view).toBeInTheDocument();
});

test.only('Popover renders properly', async () => {
test('Popover renders properly', async () => {
render(<Popover {...Popover.args} />);
const chart = await findChart();
const entries = getAllLegendEntries(chart);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ describe('SegmentLabel', () => {
const chart = await findChart();
expect(chart).toBeInTheDocument();

expect(screen.getByText('10.4K')).toBeInTheDocument();
expect(screen.getByText('7.05K')).toBeInTheDocument();
expect(screen.getByText('10K')).toBeInTheDocument();
expect(screen.getByText('7K')).toBeInTheDocument();
expect(screen.getByText('4.2K')).toBeInTheDocument();
});

Expand Down
4 changes: 2 additions & 2 deletions packages/vega-spec-builder/src/axis/axisSpecBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ const defaultAxis: Axis = {
update: {
text: [
{
test: "isNumber(datum['value']) && abs(datum['value']) >= 1000",
signal: "upper(replace(format(datum['value'], '.3~s'), /(\\d+)G/, '$1B'))",
test: "isNumber(datum['value'])",
signal: "formatShortNumber(datum['value'])",
},
{ signal: 'datum.value' },
],
Expand Down
8 changes: 4 additions & 4 deletions packages/vega-spec-builder/src/axis/axisUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ describe('getDefaultAxis()', () => {
update: {
text: [
{
test: "isNumber(datum['value']) && abs(datum['value']) >= 1000",
signal: "upper(replace(format(datum['value'], '.3~s'), /(\\d+)G/, '$1B'))",
test: "isNumber(datum['value'])",
signal: "formatShortNumber(datum['value'])",
},
{
signal: 'datum.value',
Expand Down Expand Up @@ -144,8 +144,8 @@ describe('getDefaultAxis()', () => {
update: {
text: [
{
test: "isNumber(datum['value']) && abs(datum['value']) >= 1000",
signal: "upper(replace(format(datum['value'], '.3~s'), /(\\d+)G/, '$1B'))",
test: "isNumber(datum['value'])",
signal: "formatShortNumber(datum['value'])",
},
{
signal: 'datum.value',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ describe('getSummaryValueText()', () => {
const result = getSummaryValueText(defaultDonutSummaryOptions);
expect(result).toEqual([
{
signal: "upper(replace(format(datum['sum'], '.3~s'), /(\\d+)G/, '$1B'))",
test: "isNumber(datum['sum']) && abs(datum['sum']) >= 1000",
signal: "formatShortNumber(datum['sum'])",
test: "isNumber(datum['sum'])",
},
{ field: 'sum' },
]);
Expand Down
2 changes: 1 addition & 1 deletion packages/vega-spec-builder/src/textUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe('getTextNumberFormat()', () => {
test('should return correct signal for shortNumber', () => {
const format = getTextNumberFormat('shortNumber');
expect(format).toHaveLength(1);
expect(format[0]).toHaveProperty('signal', "upper(replace(format(datum['value'], '.3~s'), /(\\d+)G/, '$1B'))");
expect(format[0]).toHaveProperty('signal', "formatShortNumber(datum['value'])");
});
test('should return correct signal for shortCurrency', () => {
const format = getTextNumberFormat('shortCurrency');
Expand Down
7 changes: 1 addition & 6 deletions packages/vega-spec-builder/src/textUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,7 @@ export const getTextNumberFormat = (
} & TextValueRef)[] => {
const test = `isNumber(datum['${datumProperty}'])`;
if (numberFormat === 'shortNumber') {
return [
{
test: `${test} && abs(datum['${datumProperty}']) >= 1000`,
signal: `upper(replace(format(datum['${datumProperty}'], '.3~s'), /(\\d+)G/, '$1B'))`,
},
];
return [{ test, signal: `formatShortNumber(datum['${datumProperty}'])` }];
}
if (numberFormat === 'shortCurrency') {
return [
Expand Down