diff --git a/pages/common/formatters.ts b/pages/common/formatters.ts index 94523fa6..178bcbeb 100644 --- a/pages/common/formatters.ts +++ b/pages/common/formatters.ts @@ -17,36 +17,34 @@ export function dateFormatter(value: null | number) { .join("\n"); } -export function numberFormatter(value: null | number) { +/** + * @see https://www.unicode.org/cldr/cldr-aux/charts/29/verify/numbers/en.html + */ +export function numberFormatter(value: number | null, locale: string = "en-US"): string { if (value === null) { return ""; } - const format = (num: number) => parseFloat(num.toFixed(2)).toString(); // trims unnecessary decimals - const absValue = Math.abs(value); if (absValue === 0) { return "0"; } - if (absValue < 0.01) { - return value.toExponential(0); - } - - if (absValue >= 1e9) { - return format(value / 1e9) + "G"; - } - - if (absValue >= 1e6) { - return format(value / 1e6) + "M"; - } - - if (absValue >= 1e3) { - return format(value / 1e3) + "K"; + // Use scientific notation for very small numbers + if (absValue < 1e-9) { + return new Intl.NumberFormat(locale, { + notation: "scientific", + maximumFractionDigits: 2, + }).format(value); } - return format(value); + // Use compact notation for normal range + return new Intl.NumberFormat(locale, { + notation: "compact", + compactDisplay: "short", + maximumFractionDigits: 2, + }).format(value); } export function moneyFormatter(value: null | number) { diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 65578760..6163cccb 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -232,6 +232,12 @@ This property corresponds to [chart.inverted](https://api.highcharts.com/highcha "optional": true, "type": "BaseLegendOptions", }, + { + "description": "The locale to be used for formatting numbers, dates, etc. Defaults to the browser locale.", + "name": "locale", + "optional": true, + "type": "string", + }, { "description": "Defines options to represent empty, no-match, loading, and error state of the chart, including: * \`statusType\` (optional, "finished" | "loading" | "error") - Specifies the current status of loading data. @@ -879,6 +885,12 @@ Supported Highcharts versions: 12.", "optional": true, "type": "BaseLegendOptions", }, + { + "description": "The locale to be used for formatting numbers, dates, etc. Defaults to the browser locale.", + "name": "locale", + "optional": true, + "type": "string", + }, { "description": "Defines options to represent empty, no-match, loading, and error state of the chart, including: * \`statusType\` (optional, "finished" | "loading" | "error") - Specifies the current status of loading data. @@ -1647,6 +1659,19 @@ Supported Highcharts versions: 12.", "type": "CoreChartProps.LegendOptions", "visualRefreshTag": undefined, }, + { + "analyticsTag": undefined, + "defaultValue": undefined, + "deprecatedTag": undefined, + "description": "The locale to be used for formatting numbers, dates, etc. Defaults to the browser locale.", + "i18nTag": undefined, + "inlineType": undefined, + "name": "locale", + "optional": true, + "systemTags": undefined, + "type": "string", + "visualRefreshTag": undefined, + }, { "analyticsTag": undefined, "defaultValue": undefined, diff --git a/src/core/__tests__/chart-core-axes.test.tsx b/src/core/__tests__/chart-core-axes.test.tsx index d9455e86..a643384d 100644 --- a/src/core/__tests__/chart-core-axes.test.tsx +++ b/src/core/__tests__/chart-core-axes.test.tsx @@ -130,7 +130,7 @@ describe("CoreChart: axes", () => { expect(formatter.call(mockAxisContext({ value: 1 }))).toBe("1"); expect(formatter.call(mockAxisContext({ value: 1_000 }))).toBe("1K"); expect(formatter.call(mockAxisContext({ value: 1_000_000 }))).toBe("1M"); - expect(formatter.call(mockAxisContext({ value: 1_000_000_000 }))).toBe("1G"); + expect(formatter.call(mockAxisContext({ value: 1_000_000_000 }))).toBe("1B"); }); }); @@ -140,7 +140,7 @@ describe("CoreChart: axes", () => { expect(formatter.call(mockAxisContext({ value: 2.0 }))).toBe("2"); expect(formatter.call(mockAxisContext({ value: 2.03 }))).toBe("2.03"); expect(formatter.call(mockAxisContext({ value: 0.03 }))).toBe("0.03"); - expect(formatter.call(mockAxisContext({ value: 0.003 }))).toBe("3e-3"); + expect(formatter.call(mockAxisContext({ value: 0.003 }))).toBe("3E-3"); }); }); diff --git a/src/core/chart-api/chart-extra-context.tsx b/src/core/chart-api/chart-extra-context.tsx index f1fd257d..f24c3639 100644 --- a/src/core/chart-api/chart-extra-context.tsx +++ b/src/core/chart-api/chart-extra-context.tsx @@ -32,6 +32,7 @@ export namespace ChartExtraContext { tooltipEnabled: boolean; keyboardNavigationEnabled: boolean; labels: ChartLabels; + locale: string; } export interface Handlers { @@ -63,6 +64,7 @@ export function createChartContext(): ChartExtraContext { tooltipEnabled: false, keyboardNavigationEnabled: false, labels: {}, + locale: "en-US", }, handlers: {}, state: {}, diff --git a/src/core/chart-api/index.tsx b/src/core/chart-api/index.tsx index fa82d253..3cd65b21 100644 --- a/src/core/chart-api/index.tsx +++ b/src/core/chart-api/index.tsx @@ -265,19 +265,28 @@ export class ChartAPI { }, onFocusGroup: (group: Highcharts.Point[]) => { this.highlightActions(group, { isApiCall: false, overrideTooltipLock: true }); - this.chartExtraNavigation.announceElement(getGroupAccessibleDescription(group), false); + this.chartExtraNavigation.announceElement( + getGroupAccessibleDescription(group, this.context.settings.locale), + false, + ); }, onFocusPoint: (point: Highcharts.Point) => { const labels = this.context.settings.labels; this.highlightActions(point, { isApiCall: false, overrideTooltipLock: true }); - this.chartExtraNavigation.announceElement(getPointAccessibleDescription(point, labels), false); + this.chartExtraNavigation.announceElement( + getPointAccessibleDescription(point, labels, this.context.settings.locale), + false, + ); }, onBlur: () => this.clearChartHighlight({ isApiCall: false }), onActivateGroup: () => { const current = this.chartExtraTooltip.get(); if (current.group.length > 0) { this.chartExtraTooltip.pinTooltip(); - this.chartExtraNavigation.announceElement(getGroupAccessibleDescription(current.group), true); + this.chartExtraNavigation.announceElement( + getGroupAccessibleDescription(current.group, this.context.settings.locale), + true, + ); } }, onActivatePoint: () => { @@ -285,7 +294,10 @@ export class ChartAPI { if (current.point) { const labels = this.context.settings.labels; this.chartExtraTooltip.pinTooltip(); - this.chartExtraNavigation.announceElement(getPointAccessibleDescription(current.point, labels), true); + this.chartExtraNavigation.announceElement( + getPointAccessibleDescription(current.point, labels, this.context.settings.locale), + true, + ); } }, }; diff --git a/src/core/chart-core.tsx b/src/core/chart-core.tsx index c7a62722..7b63c771 100644 --- a/src/core/chart-core.tsx +++ b/src/core/chart-core.tsx @@ -6,8 +6,8 @@ import clsx from "clsx"; import type Highcharts from "highcharts"; import HighchartsReact from "highcharts-react-official"; -import { getIsRtl, useMergeRefs, useUniqueId } from "@cloudscape-design/component-toolkit/internal"; -import { isDevelopment } from "@cloudscape-design/component-toolkit/internal"; +import { getIsRtl, isDevelopment, useMergeRefs, useUniqueId } from "@cloudscape-design/component-toolkit/internal"; +import { normalizeLocale } from "@cloudscape-design/component-toolkit/internal/locale"; import Spinner from "@cloudscape-design/components/spinner"; import { getDataAttributes } from "../internal/base-component/get-data-attributes"; @@ -15,6 +15,7 @@ import { InternalBaseComponentProps } from "../internal/base-component/use-base- import * as Styles from "../internal/chart-styles"; import { castArray } from "../internal/utils/utils"; import { useChartAPI } from "./chart-api"; +import { ChartExtraContext } from "./chart-api/chart-extra-context"; import { ChartContainer } from "./chart-container"; import { ChartApplication } from "./components/core-application"; import { ChartFilters } from "./components/core-filters"; @@ -66,6 +67,9 @@ export function InternalCoreChart({ ...rest }: CoreChartProps & InternalBaseComponentProps) { const highcharts = rest.highcharts as null | typeof Highcharts; + + const locale = normalizeLocale("InternalCoreChart", rest.locale ?? null); + const labels = useChartI18n({ ariaLabel, ariaDescription, i18nStrings }); const context = { chartId: useUniqueId(), @@ -74,7 +78,8 @@ export function InternalCoreChart({ tooltipEnabled: tooltipOptions?.enabled !== false, keyboardNavigationEnabled: keyboardNavigation, labels, - }; + locale, + } satisfies ChartExtraContext.Settings; const handlers = { onHighlight, onClearHighlight, onVisibleItemsChange }; const state = { visibleItems }; const api = useChartAPI(context, handlers, state); @@ -179,7 +184,7 @@ export function InternalCoreChart({ keyboardNavigation: options.accessibility?.keyboardNavigation ?? { enabled: !keyboardNavigation }, point: { // Point description formatter is overridden to respect custom axes value formatters. - descriptionFormatter: (point) => getPointAccessibleDescription(point, labels), + descriptionFormatter: (point) => getPointAccessibleDescription(point, labels, locale), ...options.accessibility?.point, }, }, @@ -193,7 +198,7 @@ export function InternalCoreChart({ opposite: inverted && isRtl ? !xAxisOptions.opposite : xAxisOptions.opposite, className: xAxisClassName(inverted, xAxisOptions.className), title: axisTitle(xAxisOptions.title ?? {}, !inverted || verticalAxisTitlePlacement === "side"), - labels: axisLabels(xAxisOptions.labels ?? {}), + labels: axisLabels(xAxisOptions.labels ?? {}, locale), })), yAxis: castArray(options.yAxis)?.map((yAxisOptions) => ({ ...Styles.yAxisOptions, @@ -203,7 +208,7 @@ export function InternalCoreChart({ opposite: !inverted && isRtl ? !yAxisOptions.opposite : yAxisOptions.opposite, className: yAxisClassName(inverted, yAxisOptions.className), title: axisTitle(yAxisOptions.title ?? {}, inverted || verticalAxisTitlePlacement === "side"), - labels: axisLabels(yAxisOptions.labels ?? {}), + labels: axisLabels(yAxisOptions.labels ?? {}, locale), plotLines: yAxisPlotLines(yAxisOptions.plotLines, emphasizeBaseline), // We use reversed stack by default so that the order of points in the tooltip and series in the legend // correspond the order of stacks. @@ -356,6 +361,7 @@ export function InternalCoreChart({ {context.tooltipEnabled && ( (options: O): O { +function axisLabels( + options: O, + locale: string, +): O { return { style: Styles.axisLabelsCss, formatter: function () { - const formattedValue = getFormatter(this.axis)(this.value); + const formattedValue = getFormatter(locale, this.axis)(this.value); return formattedValue.replace(/\n/g, "
"); }, ...options, diff --git a/src/core/components/core-tooltip.tsx b/src/core/components/core-tooltip.tsx index e67cd2a5..ff3e47d2 100644 --- a/src/core/components/core-tooltip.tsx +++ b/src/core/components/core-tooltip.tsx @@ -46,12 +46,14 @@ export function ChartTooltip({ getTooltipContent: getTooltipContentOverrides, api, i18nStrings, + locale, debounce = false, seriesSorting = "as-added", }: CoreChartProps.TooltipOptions & { i18nStrings?: BaseI18nStrings; getTooltipContent?: CoreChartProps.GetTooltipContent; api: ChartAPI; + locale: string; }) { const [expandedSeries, setExpandedSeries] = useState({}); const tooltip = useSelector(api.tooltipStore, (s) => s); @@ -86,6 +88,7 @@ export function ChartTooltip({ hideTooltip: () => { api.hideTooltip(); }, + locale, }); if (!content) { return null; @@ -135,6 +138,7 @@ function getTooltipContent( renderers?: CoreChartProps.TooltipContentRenderer; hideTooltip: () => void; seriesSorting: NonNullable; + locale: string; } & ExpandedSeriesStateProps, ): null | RenderedTooltipContent { if (props.point && props.point.series.type === "pie") { @@ -156,8 +160,10 @@ function getTooltipContentCartesian( setExpandedSeries, hideTooltip, seriesSorting, + locale, }: CoreChartProps.GetTooltipContentProps & { renderers?: CoreChartProps.TooltipContentRenderer; + locale: string; hideTooltip: () => void; seriesSorting: NonNullable; } & ExpandedSeriesStateProps, @@ -170,7 +176,7 @@ function getTooltipContentCartesian( api.renderMarker(getSeriesMarkerType(series), getSeriesColor(series), true); const matchedItems = findTooltipSeriesItems(getChartSeries(chart.series), group, seriesSorting); const detailItems: ChartSeriesDetailItem[] = matchedItems.map((item) => { - const valueFormatter = getFormatter(item.point.series.yAxis); + const valueFormatter = getFormatter(locale, item.point.series.yAxis); const itemY = isXThreshold(item.point.series) ? null : (item.point.y ?? null); const customContent = renderers.point ? renderers.point({ @@ -203,7 +209,7 @@ function getTooltipContentCartesian( }; }); // We only support cartesian charts with a single x axis. - const titleFormatter = getFormatter(chart.xAxis[0]); + const titleFormatter = getFormatter(locale, chart.xAxis[0]); const slotRenderProps: CoreChartProps.TooltipSlotProps = { x, items: matchedItems, diff --git a/src/core/formatters.tsx b/src/core/formatters.tsx index 91656d2e..ca422ce0 100644 --- a/src/core/formatters.tsx +++ b/src/core/formatters.tsx @@ -7,7 +7,7 @@ import { CoreChartProps } from "./interfaces"; // Takes value formatter from the axis options (InternalXAxisOptions.valueFormatter or InternalYAxisOptions.valueFormatter), // or provides a default formatter for numeric and datetime values. -export function getFormatter(axis?: Highcharts.Axis) { +export function getFormatter(locale: string, axis?: Highcharts.Axis) { return (value: unknown): string => { if (typeof value === "string") { return value; @@ -28,13 +28,13 @@ export function getFormatter(axis?: Highcharts.Axis) { if (axis.options.type === "datetime") { const extremes = axis.getExtremes(); const formatter = getDefaultDatetimeFormatter([extremes.dataMin, extremes.dataMax]); - return formatter(value); + return formatter(value, locale); } - return numberFormatter(value); + return numberFormatter(value, locale); }; } -function getDefaultDatetimeFormatter(extremes: [number, number]): (value: number) => string { +function getDefaultDatetimeFormatter(extremes: [number, number]): (value: number, locale: string) => string { const second = 1000; const minute = 60 * second; const hour = 60 * minute; @@ -59,51 +59,56 @@ function getDefaultDatetimeFormatter(extremes: [number, number]): (value: number return secondFormatter; } -function yearFormatter(value: number) { - return new Date(value).toLocaleDateString("en-US", { +function yearFormatter(value: number, locale: string) { + return new Date(value).toLocaleDateString(locale, { year: "numeric", }); } -function monthFormatter(value: number) { - return new Date(value).toLocaleDateString("en-US", { +function monthFormatter(value: number, locale: string) { + return new Date(value).toLocaleDateString(locale, { year: "numeric", month: "short", }); } -function dayFormatter(value: number) { - return new Date(value).toLocaleDateString("en-US", { +function dayFormatter(value: number, locale: string) { + return new Date(value).toLocaleDateString(locale, { month: "short", day: "numeric", }); } -function hourFormatter(value: number) { - return new Date(value).toLocaleDateString("en-US", { +function hourFormatter(value: number, locale: string) { + return new Date(value).toLocaleDateString(locale, { month: "short", day: "numeric", hour: "numeric", }); } -function minuteFormatter(value: number) { - return new Date(value).toLocaleDateString("en-US", { +function minuteFormatter(value: number, locale: string) { + return new Date(value).toLocaleDateString(locale, { hour: "numeric", minute: "numeric", }); } -function secondFormatter(value: number) { - return new Date(value).toLocaleDateString("en-US", { +function secondFormatter(value: number, locale: string) { + return new Date(value).toLocaleDateString(locale, { hour: "numeric", minute: "numeric", second: "numeric", }); } -function numberFormatter(value: number): string { - const format = (num: number) => parseFloat(num.toFixed(2)).toString(); // trims unnecessary decimals +/** + * @see https://www.unicode.org/cldr/cldr-aux/charts/29/verify/numbers/en.html + */ +export function numberFormatter(value: number | null, locale: string): string { + if (value === null) { + return ""; + } const absValue = Math.abs(value); @@ -111,21 +116,18 @@ function numberFormatter(value: number): string { return "0"; } - if (absValue < 0.01) { - return value.toExponential(0); - } - - if (absValue >= 1e9) { - return format(value / 1e9) + "G"; - } - - if (absValue >= 1e6) { - return format(value / 1e6) + "M"; - } - - if (absValue >= 1e3) { - return format(value / 1e3) + "K"; + // Use scientific notation for very small numbers + if (absValue < 10e-3) { + return new Intl.NumberFormat(locale, { + notation: "scientific", + maximumFractionDigits: 2, + }).format(value); } - return format(value); + // Use compact notation for normal range + return new Intl.NumberFormat(locale, { + notation: "compact", + compactDisplay: "short", + maximumFractionDigits: 2, + }).format(value); } diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index fd2c2a0e..d92b70c1 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -91,6 +91,12 @@ export interface BaseChartOptions { * * `additionalFilters` (optional, slot) - A slot for custom chart filters at the top of the chart. */ filter?: BaseFilterOptions; + + /** + * The locale to be used for formatting numbers, dates, etc. Defaults to the browser locale. + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#Locale_identification_and_negotiation + */ + locale?: string; } export interface BaseLegendOptions { @@ -320,6 +326,7 @@ export interface CoreChartProps | "ariaDescription" | "filter" | "noData" + | "locale" >, CoreCartesianOptions { /** diff --git a/src/core/utils.ts b/src/core/utils.ts index c934990f..1d6e2263 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -319,10 +319,10 @@ export function getChartAccessibleDescription(chart: Highcharts.Chart) { return chart.options.lang?.accessibility?.chartContainerLabel ?? ""; } -export function getPointAccessibleDescription(point: Highcharts.Point, labels: ChartLabels) { +export function getPointAccessibleDescription(point: Highcharts.Point, labels: ChartLabels, locale: string) { if (point.series.xAxis && point.series.yAxis) { - const formattedX = getFormatter(point.series.xAxis)(point.x); - const formattedY = getFormatter(point.series.yAxis)(point.y); + const formattedX = getFormatter(locale, point.series.xAxis)(point.x); + const formattedY = getFormatter(locale, point.series.yAxis)(point.y); return `${formattedX} ${formattedY}, ${point.series.name}`; } else if (point.series.type === "pie") { const segmentLabel = labels.chartSegmentLabel ? `${labels.chartSegmentLabel} ` : ""; @@ -332,9 +332,9 @@ export function getPointAccessibleDescription(point: Highcharts.Point, labels: C } } -export function getGroupAccessibleDescription(group: readonly Highcharts.Point[]) { +export function getGroupAccessibleDescription(group: readonly Highcharts.Point[], locale: string) { const firstPoint = group[0]; - return getFormatter(firstPoint.series.xAxis)(firstPoint.x); + return getFormatter(locale, firstPoint.series.xAxis)(firstPoint.x); } // The area-, line-, or scatter series markers are rendered as single graphic elements, diff --git a/test/functional/cartesian-chart.test.ts b/test/functional/cartesian-chart.test.ts index 4cffc106..b3067e9a 100644 --- a/test/functional/cartesian-chart.test.ts +++ b/test/functional/cartesian-chart.test.ts @@ -14,8 +14,8 @@ test( "pins chart tooltip after hovering chart point and then chart point group", setupTest("#/01-cartesian-chart/column-chart-test", async (page) => { const chart = w.findCartesianHighcharts('[data-testid="grouped-column-chart"]'); - const point = chart.find('[aria-label="Jul 2019 6.32K, Prev costs"]'); - const expectedTooltipContent = ["Jul 2019\nCosts\n8.77K\nPrev costs\n6.32K"]; + const point = chart.find('[aria-label="Jul 2019 6.322K, Prev costs"]'); + const expectedTooltipContent = ["Jul 2019\nCosts\n8.768K\nPrev costs\n6.322K"]; const pointBox = await page.getBoundingBox(point.toSelector()); const pointCenter = [pointBox.left + pointBox.width / 2, pointBox.top + pointBox.height / 2]; diff --git a/tsconfig.json b/tsconfig.json index ca919918..c0c3c358 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "target": "ES2019", "jsx": "react-jsx", "types": [], - "lib": ["es2019", "dom", "dom.iterable"], + "lib": ["es2020", "dom", "dom.iterable"], "module": "ESNext", "moduleResolution": "Node", "esModuleInterop": true,