Skip to content
Open
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
34 changes: 16 additions & 18 deletions pages/common/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

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

Is there a specific reason why the threshold of this condition changes from 0.01 (i.e, 1e-2) to 1e-9?

@pan-kot Would this be a problem?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In this PR, I also updated how numbers are truncated, to reflect how CloudWatch charts are currently behaving. The number 0.00086661402 is truncated in CDS, but not on the console.

I am open to the discussion about that. It may be valuable to expose that setting to the chart props?

Copy link
Member

@pan-kot pan-kot Nov 12, 2025

Choose a reason for hiding this comment

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

You can do that in the test pages - but let's not change the current precision in the default chart formatters. What is good for one team might not work for another.

All formatters can be already overridden in both public and core APIs.

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) {
Expand Down
25 changes: 25 additions & 0 deletions src/__tests__/__snapshots__/documenter.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/core/__tests__/chart-core-axes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});

Expand All @@ -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");
});
});

Expand Down
2 changes: 2 additions & 0 deletions src/core/chart-api/chart-extra-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export namespace ChartExtraContext {
tooltipEnabled: boolean;
keyboardNavigationEnabled: boolean;
labels: ChartLabels;
locale: string;
}

export interface Handlers {
Expand Down Expand Up @@ -63,6 +64,7 @@ export function createChartContext(): ChartExtraContext {
tooltipEnabled: false,
keyboardNavigationEnabled: false,
labels: {},
locale: "en-US",
},
handlers: {},
state: {},
Expand Down
20 changes: 16 additions & 4 deletions src/core/chart-api/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,27 +265,39 @@ 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: () => {
const current = this.chartExtraTooltip.get();
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,
);
}
},
};
Expand Down
25 changes: 17 additions & 8 deletions src/core/chart-core.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@ 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";
import { InternalBaseComponentProps } from "../internal/base-component/use-base-component";
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";
Expand Down Expand Up @@ -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(),
Expand All @@ -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);
Expand Down Expand Up @@ -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,
},
},
Expand All @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -356,6 +361,7 @@ export function InternalCoreChart({

{context.tooltipEnabled && (
<ChartTooltip
locale={locale}
{...tooltipOptions}
i18nStrings={i18nStrings}
getTooltipContent={rest.getTooltipContent}
Expand Down Expand Up @@ -399,11 +405,14 @@ function axisTitle<O extends Highcharts.XAxisTitleOptions | Highcharts.YAxisTitl

// We use custom formatters instead of Highcharts defaults to ensure consistent formatting
// between axis ticks and tooltip contents.
function axisLabels<O extends Highcharts.XAxisLabelsOptions | Highcharts.YAxisLabelsOptions>(options: O): O {
function axisLabels<O extends Highcharts.XAxisLabelsOptions | Highcharts.YAxisLabelsOptions>(
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, "<br />");
},
...options,
Expand Down
10 changes: 8 additions & 2 deletions src/core/components/core-tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExpandedSeriesState>({});
const tooltip = useSelector(api.tooltipStore, (s) => s);
Expand Down Expand Up @@ -86,6 +88,7 @@ export function ChartTooltip({
hideTooltip: () => {
api.hideTooltip();
},
locale,
});
if (!content) {
return null;
Expand Down Expand Up @@ -135,6 +138,7 @@ function getTooltipContent(
renderers?: CoreChartProps.TooltipContentRenderer;
hideTooltip: () => void;
seriesSorting: NonNullable<CoreChartProps.TooltipOptions["seriesSorting"]>;
locale: string;
} & ExpandedSeriesStateProps,
): null | RenderedTooltipContent {
if (props.point && props.point.series.type === "pie") {
Expand All @@ -156,8 +160,10 @@ function getTooltipContentCartesian(
setExpandedSeries,
hideTooltip,
seriesSorting,
locale,
}: CoreChartProps.GetTooltipContentProps & {
renderers?: CoreChartProps.TooltipContentRenderer;
locale: string;
hideTooltip: () => void;
seriesSorting: NonNullable<CoreChartProps.TooltipOptions["seriesSorting"]>;
} & ExpandedSeriesStateProps,
Expand All @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading