From 1792272375fadda7eec8c9d692d4659122f9b32d Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Tue, 24 Mar 2026 02:49:59 +0100 Subject: [PATCH 01/11] feat: Add bubble series to cartesian chart component --- .../01-cartesian-chart/bubble-chart.page.tsx | 57 +++++++++++++++++++ .../__snapshots__/documenter.test.ts.snap | 1 + .../__tests__/cartesian-chart-series.test.tsx | 7 ++- .../cartesian-chart-tooltip.test.tsx | 7 ++- .../chart-cartesian-internal.tsx | 1 + .../chart-series-cartesian.tsx | 5 +- src/cartesian-chart/index.tsx | 19 ++++++- src/cartesian-chart/interfaces.ts | 4 ++ src/core/__tests__/chart-core-utils.test.tsx | 1 + src/core/interfaces.ts | 11 ++++ src/core/utils.ts | 2 + .../series-marker/render-marker.tsx | 4 +- 12 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 pages/01-cartesian-chart/bubble-chart.page.tsx diff --git a/pages/01-cartesian-chart/bubble-chart.page.tsx b/pages/01-cartesian-chart/bubble-chart.page.tsx new file mode 100644 index 00000000..674ca3eb --- /dev/null +++ b/pages/01-cartesian-chart/bubble-chart.page.tsx @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CartesianChart, CartesianChartProps } from "../../lib/components"; +import { dateFormatter } from "../common/formatters"; +import { useChartSettings } from "../common/page-settings"; +import { Page } from "../common/templates"; +import pseudoRandom from "../utils/pseudo-random"; + +function randomInt(min: number, max: number) { + return min + Math.floor(pseudoRandom() * (max - min)); +} + +const baseline = [ + { x: 1600984800000, y: 58020 }, + { x: 1600985700000, y: 102402 }, + { x: 1600986600000, y: 104920 }, + { x: 1600987500000, y: 94031 }, + { x: 1600988400000, y: 125021 }, + { x: 1600989300000, y: 159219 }, + { x: 1600990200000, y: 193082 }, + { x: 1600991100000, y: 162592 }, + { x: 1600992000000, y: 274021 }, + { x: 1600992900000, y: 264286 }, +]; + +const series: CartesianChartProps.SeriesOptions[] = [ + { + name: "Series A", + type: "bubble", + data: baseline.map(({ x, y }) => ({ x, y, z: randomInt(100, 300) })), + }, + { + name: "Series B", + type: "bubble", + data: baseline.map(({ x, y }) => ({ + x: x + (Math.random() > 0.2 ? randomInt(-5000000, 5000000) : 0), + y: y + randomInt(-50000, 50000), + z: randomInt(50, 400), + })), + }, +]; + +export default function () { + const { chartProps } = useChartSettings({ more: true }); + return ( + + + + ); +} diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 7d572bdd..f8009163 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -315,6 +315,7 @@ Supported types: * [column](https://api.highcharts.com/highcharts/series.column). * [errorbar](https://api.highcharts.com/highcharts/series.errorbar) - requires "highcharts/highcharts-more" module. * [line](https://api.highcharts.com/highcharts/series.line). +* [bubble](https://api.highcharts.com/highcharts/series.bubble) - requires "highcharts/highcharts-more" module. * [scatter](https://api.highcharts.com/highcharts/series.scatter). * [spline](https://api.highcharts.com/highcharts/series.spline). * x-threshold - The line-like series to represent x-axis threshold (vertical, when \`inverted=false\`). diff --git a/src/cartesian-chart/__tests__/cartesian-chart-series.test.tsx b/src/cartesian-chart/__tests__/cartesian-chart-series.test.tsx index 39499771..8e77a515 100644 --- a/src/cartesian-chart/__tests__/cartesian-chart-series.test.tsx +++ b/src/cartesian-chart/__tests__/cartesian-chart-series.test.tsx @@ -18,14 +18,15 @@ const allSeries: CartesianChartProps.SeriesOptions[] = [ { type: "line", name: "Line", data: [{ x: 1, y: 6 }], color: "5" }, { type: "scatter", name: "Scatter", data: [{ x: 1, y: 7 }], color: "6" }, { type: "spline", name: "Spline", data: [{ x: 1, y: 8 }], color: "7" }, - { type: "x-threshold", name: "X threshold", value: 1, color: "8" }, - { type: "y-threshold", name: "Y threshold", value: 9, color: "9" }, + { type: "bubble", name: "Bubble", data: [{ x: 1, y: 9, z: 5 }], color: "8" }, + { type: "x-threshold", name: "X threshold", value: 1, color: "9" }, + { type: "y-threshold", name: "Y threshold", value: 9, color: "10" }, ]; describe("CartesianChart: series", () => { test("renders all supported series types", () => { renderCartesianChart({ highcharts, series: allSeries }); - expect(getChart().findSeries()).toHaveLength(9); + expect(getChart().findSeries()).toHaveLength(10); }); test("series color is assigned", () => { diff --git a/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx b/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx index d1f3863a..f0c40e08 100644 --- a/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx +++ b/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx @@ -67,8 +67,9 @@ describe("CartesianChart: tooltip", () => { { type: "line", name: "\nLine", data: [{ x, y: 6 }] }, { type: "scatter", name: "\nScatter", data: [{ x, y: 7 }] }, { type: "spline", name: "\nSpline", data: [{ x, y: 8 }] }, + { type: "bubble", name: "\nBubble", data: [{ x, y: 9, z: 5 }] }, { type: "x-threshold", name: "\nX threshold", value: x }, - { type: "y-threshold", name: "\nY threshold", value: 9 }, + { type: "y-threshold", name: "\nY threshold", value: 10 }, ], }); @@ -77,9 +78,9 @@ describe("CartesianChart: tooltip", () => { await waitFor(() => expect(getTooltip()).not.toBe(null)); expect(getTooltipHeader().getElement().textContent).toBe(x === 0.01 ? "0.01" : x.toString()); - expect(getAllTooltipSeries()).toHaveLength(8); // Error bar is not counted as a series + expect(getAllTooltipSeries()).toHaveLength(9); // Error bar is not counted as a series expect(getTooltipBody().getElement().textContent).toBe( - `Area1\nArea spline2\nColumn3\nError bar4 - 5\nLine6\nScatter7\nSpline8\nX threshold\nY threshold9`, + `Area1\nArea spline2\nColumn3\nError bar4 - 5\nLine6\nScatter7\nSpline8\nBubble9\nX threshold\nY threshold10`, ); }, ); diff --git a/src/cartesian-chart/chart-cartesian-internal.tsx b/src/cartesian-chart/chart-cartesian-internal.tsx index be4ff121..0c509acc 100644 --- a/src/cartesian-chart/chart-cartesian-internal.tsx +++ b/src/cartesian-chart/chart-cartesian-internal.tsx @@ -63,6 +63,7 @@ export const InternalCartesianChart = forwardRef( return { x: item.point.x, y: isXThreshold(item.point.series) ? null : (item.point.y ?? null), + z: item.point.options.z ?? undefined, series, errorRanges: item.errorRanges.map((point) => ({ low: point.options.low ?? 0, diff --git a/src/cartesian-chart/chart-series-cartesian.tsx b/src/cartesian-chart/chart-series-cartesian.tsx index a7ccd09e..4caee896 100644 --- a/src/cartesian-chart/chart-series-cartesian.tsx +++ b/src/cartesian-chart/chart-series-cartesian.tsx @@ -5,7 +5,7 @@ import type Highcharts from "highcharts"; import { colorChartsErrorBarMarker } from "@cloudscape-design/design-tokens"; -import { PointDataItemType, RangeDataItemOptions } from "../core/interfaces"; +import { PointDataItemType, RangeDataItemOptions, ZPointDataItemOptions } from "../core/interfaces"; import { createThresholdMetadata, getOptionsId } from "../core/utils"; import * as Styles from "../internal/chart-styles"; import { Writeable } from "../internal/utils/utils"; @@ -75,6 +75,9 @@ export const transformCartesianSeries = ( const colors = { stemColor: color, whiskerColor: color }; return { ...s, data: s.data as Writeable, ...colors }; } + if (s.type === "bubble") { + return { ...s, data: s.data as Writeable, ...shared, ...getColorProps(s) }; + } return { ...s, data: s.data as Writeable, ...shared, ...getColorProps(s) }; } const series = originalSeries.map(transformSeriesToHighcharts); diff --git a/src/cartesian-chart/index.tsx b/src/cartesian-chart/index.tsx index 011ff3ef..a3e0190f 100644 --- a/src/cartesian-chart/index.tsx +++ b/src/cartesian-chart/index.tsx @@ -5,7 +5,7 @@ import { forwardRef } from "react"; import { warnOnce } from "@cloudscape-design/component-toolkit/internal"; -import { PointDataItemType, RangeDataItemOptions } from "../core/interfaces"; +import { PointDataItemType, RangeDataItemOptions, ZPointDataItemOptions } from "../core/interfaces"; import { getDataAttributes } from "../internal/base-component/get-data-attributes"; import useBaseComponent from "../internal/base-component/use-base-component"; import { applyDisplayName } from "../internal/utils/apply-display-name"; @@ -83,6 +83,8 @@ function transformSeries( return transformColumnSeries(s, untransformedSeries); case "scatter": return transformScatterSeries(s, untransformedSeries); + case "bubble": + return transformBubbleSeries(s, untransformedSeries); case "errorbar": return transformErrorBarSeries(s, untransformedSeries, stacking); case "x-threshold": @@ -163,6 +165,17 @@ function transformScatterSeries( + s: S, + allSeries: readonly CartesianChartProps.SeriesOptions[], +): null | S { + if (!validateLinkedTo(s, allSeries)) { + return null; + } + const data = transformBubbleData(s.data); + return { type: s.type, id: s.id, name: s.name, color: s.color, data } as S; +} + function transformThresholdSeries< S extends CartesianChartProps.XThresholdSeriesOptions | CartesianChartProps.YThresholdSeriesOptions, >(s: S): null | S { @@ -199,6 +212,10 @@ function transformPointData(data: readonly PointDataItemType[]): readonly PointD return data.map((d) => (d && typeof d === "object" ? { x: d.x, y: d.y } : d)); } +function transformBubbleData(data: readonly ZPointDataItemOptions[]): readonly ZPointDataItemOptions[] { + return data.map((d) => ({ x: d.x, y: d.y, z: d.z })); +} + function transformRangeData(data: readonly RangeDataItemOptions[]): readonly RangeDataItemOptions[] { return data.map((d) => ({ x: d.x, low: d.low, high: d.high })); } diff --git a/src/cartesian-chart/interfaces.ts b/src/cartesian-chart/interfaces.ts index 79b32313..2073140e 100644 --- a/src/cartesian-chart/interfaces.ts +++ b/src/cartesian-chart/interfaces.ts @@ -32,6 +32,7 @@ export interface CartesianChartProps * * [column](https://api.highcharts.com/highcharts/series.column). * * [errorbar](https://api.highcharts.com/highcharts/series.errorbar) - requires "highcharts/highcharts-more" module. * * [line](https://api.highcharts.com/highcharts/series.line). + * * [bubble](https://api.highcharts.com/highcharts/series.bubble) - requires "highcharts/highcharts-more" module. * * [scatter](https://api.highcharts.com/highcharts/series.scatter). * * [spline](https://api.highcharts.com/highcharts/series.spline). * * x-threshold - The line-like series to represent x-axis threshold (vertical, when `inverted=false`). @@ -122,6 +123,7 @@ export namespace CartesianChartProps { export type SeriesOptions = | AreaSeriesOptions | AreaSplineSeriesOptions + | BubbleSeriesOptions | ColumnSeriesOptions | ErrorBarSeriesOptions | LineSeriesOptions @@ -132,6 +134,7 @@ export namespace CartesianChartProps { export type AreaSeriesOptions = CoreTypes.AreaSeriesOptions; export type AreaSplineSeriesOptions = CoreTypes.AreaSplineSeriesOptions; + export type BubbleSeriesOptions = CoreTypes.BubbleSeriesOptions; export type ColumnSeriesOptions = CoreTypes.ColumnSeriesOptions; export type ErrorBarSeriesOptions = CoreTypes.ErrorBarSeriesOptions; export type LineSeriesOptions = CoreTypes.LineSeriesOptions; @@ -178,6 +181,7 @@ export namespace CartesianChartProps { export interface TooltipPointItem { x: number; y: number | null; + z?: number | null; errorRanges: { low: number; high: number; series: CartesianChartProps.ErrorBarSeriesOptions }[]; series: NonErrorBarSeriesOptions; } diff --git a/src/core/__tests__/chart-core-utils.test.tsx b/src/core/__tests__/chart-core-utils.test.tsx index 4f5dc2fc..5392263f 100644 --- a/src/core/__tests__/chart-core-utils.test.tsx +++ b/src/core/__tests__/chart-core-utils.test.tsx @@ -37,6 +37,7 @@ describe("CoreChart: utils", () => { ["triangle-down", { type: "scatter", symbol: "triangle-down", options: {} }], ["circle", { type: "scatter", symbol: "circle", options: {} }], ["circle", { type: "scatter", symbol: "unknown", options: {} }], + ["circle", { type: "bubble", options: {} }], ])('getSeriesMarkerType returns "%s" for series %j', (markerType, series) => { expect(getSeriesMarkerType(series as any)).toBe(markerType); }); diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index b2319ab0..1fe5f9b5 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -237,6 +237,11 @@ export interface ScatterSeriesOptions extends BaseCartesianSeriesOptions, Linkab marker?: PointMarkerOptions; } +export interface BubbleSeriesOptions extends BaseCartesianSeriesOptions, LinkableSeries { + type: "bubble"; + data: readonly ZPointDataItemOptions[]; +} + export interface ErrorBarSeriesOptions extends Omit { type: "errorbar"; name?: string; @@ -278,6 +283,12 @@ export interface PointDataItemOptions { y: number | null; } +export interface ZPointDataItemOptions { + x?: number; + y: number | null; + z: number | null; +} + export interface RangeDataItemOptions { x?: number; low: number; diff --git a/src/core/utils.ts b/src/core/utils.ts index d994f9ae..c33869b8 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -114,6 +114,8 @@ export function getSeriesMarkerType(series?: Highcharts.Series): ChartSeriesMark case "column": case "pie": return "large-square"; + case "bubble": + return "circle"; case "errorbar": default: return "large-square"; diff --git a/src/internal/components/series-marker/render-marker.tsx b/src/internal/components/series-marker/render-marker.tsx index 2eb95f44..8ce91a69 100644 --- a/src/internal/components/series-marker/render-marker.tsx +++ b/src/internal/components/series-marker/render-marker.tsx @@ -68,10 +68,10 @@ function getPointProps(point: Highcharts.Point, selected: boolean, className?: s function getBubblePointProps(point: Highcharts.Point, selected: boolean, className?: string) { const { pointStyle, haloStyle } = getDefaultPointProps(point, selected, className); - const size = Math.max(4, (point.graphic?.getBBox().width ?? 8) / 2 - 2); + const size = Math.max(4, (point.graphic?.getBBox().width ?? 8) / 2 - (selected ? 1 : 2)); pointStyle.fill = point.color; pointStyle.stroke = selected ? colorTextBodyDefault : colorBackgroundContainerContent; - haloStyle.r = size + 4; + haloStyle.r = size + 5; return { size, pointStyle, haloStyle }; } From 36021cf3c3607a21dd792821f980d63fe8645959 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Tue, 24 Mar 2026 16:57:31 +0100 Subject: [PATCH 02/11] Update tooltip presentation, add z axis title and formatter. --- .../01-cartesian-chart/bubble-chart.page.tsx | 1 + .../chart-cartesian-internal.tsx | 32 ++++++++++++++++--- src/cartesian-chart/interfaces.ts | 14 ++++++++ .../components/series-details/styles.scss | 2 +- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/pages/01-cartesian-chart/bubble-chart.page.tsx b/pages/01-cartesian-chart/bubble-chart.page.tsx index 674ca3eb..8116241e 100644 --- a/pages/01-cartesian-chart/bubble-chart.page.tsx +++ b/pages/01-cartesian-chart/bubble-chart.page.tsx @@ -50,6 +50,7 @@ export default function () { series={series} xAxis={{ title: "Time (UTC)", type: "datetime", valueFormatter: dateFormatter }} yAxis={{ title: "Events" }} + zAxis={{ title: "Average size", valueFormatter: (value) => `${value}kB` }} chartHeight={400} /> diff --git a/src/cartesian-chart/chart-cartesian-internal.tsx b/src/cartesian-chart/chart-cartesian-internal.tsx index 0c509acc..40f704a4 100644 --- a/src/cartesian-chart/chart-cartesian-internal.tsx +++ b/src/cartesian-chart/chart-cartesian-internal.tsx @@ -6,6 +6,7 @@ import { forwardRef, useImperativeHandle, useRef, useState } from "react"; import { useControllableState } from "@cloudscape-design/component-toolkit"; import { InternalCoreChart } from "../core/chart-core"; +import { getFormatter } from "../core/formatters"; import { CoreChartProps, ErrorBarSeriesOptions } from "../core/interfaces"; import { getOptionsId, isXThreshold } from "../core/utils"; import { InternalBaseComponentProps } from "../internal/base-component/use-base-component"; @@ -83,11 +84,34 @@ export const InternalCartesianChart = forwardRef( x: props.x, items: props.items.map(transformItem), }); + const defaultBubblePoint = ( + coreProps: CoreChartProps.TooltipPointProps, + ): CartesianChartProps.TooltipPointFormatted => { + if (coreProps.item.point.series.type !== "bubble") { + return {}; + } + const yAxisTitle = castArray(props.yAxis)?.[0]?.title; + const yFormatter = coreProps.item.point.series.yAxis + ? getFormatter(coreProps.item.point.series.yAxis) + : (v: unknown) => String(v); + const zValue = coreProps.item.point.options.z; + return { + // The y value will be explicitly listed as a sub-item so it shouldn't be listed here. + value: "", + subItems: [ + { key: yAxisTitle, value: yFormatter(coreProps.item.point.y) }, + { key: props.zAxis?.title, value: props.zAxis?.valueFormatter?.(zValue ?? null) ?? zValue }, + ], + }; + }; + return { - point: tooltip.point ? (props) => tooltip.point!(transformSeriesProps(props)) : undefined, - header: tooltip.header ? (props) => tooltip.header!(transformSlotProps(props)) : undefined, - body: tooltip.body ? (props) => tooltip.body!(transformSlotProps(props)) : undefined, - footer: tooltip.footer ? (props) => tooltip.footer!(transformSlotProps(props)) : undefined, + point: tooltip.point + ? (coreProps) => tooltip.point!(transformSeriesProps(coreProps)) + : (coreProps) => defaultBubblePoint(coreProps), + header: tooltip.header ? (coreProps) => tooltip.header!(transformSlotProps(coreProps)) : undefined, + body: tooltip.body ? (coreProps) => tooltip.body!(transformSlotProps(coreProps)) : undefined, + footer: tooltip.footer ? (coreProps) => tooltip.footer!(transformSlotProps(coreProps)) : undefined, }; }; diff --git a/src/cartesian-chart/interfaces.ts b/src/cartesian-chart/interfaces.ts index 2073140e..aad68bff 100644 --- a/src/cartesian-chart/interfaces.ts +++ b/src/cartesian-chart/interfaces.ts @@ -95,6 +95,15 @@ export interface CartesianChartProps */ yAxis?: CartesianChartProps.YAxisOptions; + /** + * Title for the Z axis, used as the label for the Z value in bubble chart tooltips. + * + * Supported options: + * * `title` (optional, string) - Axis title. + * * `valueFormatter` (optional, function) - Takes axis tick as input and returns a formatted string for tooltip points values. + */ + zAxis?: CartesianChartProps.ZAxisOptions; + /** * Specifies which series to show using their IDs. By default, all series are visible and managed by the component. * If a series doesn't have an ID, its name is used. When using this property, manage state updates with `onVisibleSeriesChange`. @@ -153,6 +162,11 @@ export namespace CartesianChartProps { valueFormatter?: (value: null | number) => string; } + export interface ZAxisOptions { + title?: string; + valueFormatter?: (value: null | number) => string; + } + export type XAxisOptions = AxisOptions; export interface YAxisOptions extends AxisOptions { diff --git a/src/internal/components/series-details/styles.scss b/src/internal/components/series-details/styles.scss index 17eb7186..b5f05902 100644 --- a/src/internal/components/series-details/styles.scss +++ b/src/internal/components/series-details/styles.scss @@ -46,7 +46,7 @@ $font-weight-bold: cs.$font-weight-heading-s; .sub-items { &:not(.expandable) { - padding-inline-start: calc(marker.$marker-size + marker.$marker-margin-right); + padding-inline-start: calc(marker.$marker-inline-size); } &.expandable { From d10e3810aef1ce938ac7441f891a1286904c3e30 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Wed, 25 Mar 2026 07:15:10 +0100 Subject: [PATCH 03/11] Fix failing tests. --- .../__snapshots__/documenter.test.ts.snap | 37 +++++++++++++++++++ .../cartesian-chart-tooltip.test.tsx | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index f8009163..0ba4fc8a 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -651,6 +651,43 @@ applies to the tooltip points values.", "optional": true, "type": "CartesianChartProps.YAxisOptions", }, + { + "description": "Title for the Z axis, used as the label for the Z value in bubble chart tooltips. + +Supported options: +* \`title\` (optional, string) - Axis title. +* \`valueFormatter\` (optional, function) - Takes axis tick as input and returns a formatted string for tooltip points values.", + "inlineType": { + "name": "CartesianChartProps.ZAxisOptions", + "properties": [ + { + "name": "title", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "(value: number | null) => string", + "parameters": [ + { + "name": "value", + "type": "number | null", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "valueFormatter", + "optional": true, + "type": "((value: number | null) => string)", + }, + ], + "type": "object", + }, + "name": "zAxis", + "optional": true, + "type": "CartesianChartProps.ZAxisOptions", + }, ], "regions": [ { diff --git a/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx b/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx index f0c40e08..854e68c7 100644 --- a/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx +++ b/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx @@ -80,7 +80,7 @@ describe("CartesianChart: tooltip", () => { expect(getTooltipHeader().getElement().textContent).toBe(x === 0.01 ? "0.01" : x.toString()); expect(getAllTooltipSeries()).toHaveLength(9); // Error bar is not counted as a series expect(getTooltipBody().getElement().textContent).toBe( - `Area1\nArea spline2\nColumn3\nError bar4 - 5\nLine6\nScatter7\nSpline8\nBubble9\nX threshold\nY threshold10`, + `Area1\nArea spline2\nColumn3\nError bar4 - 5\nLine6\nScatter7\nSpline8\nBubble95\nX threshold\nY threshold10`, ); }, ); From 26a71e4aed3e54884058aa265383fc96433e5cea Mon Sep 17 00:00:00 2001 From: Georgii Lobko <47106899+georgylobko@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:39:12 +0100 Subject: [PATCH 04/11] chore: Bubble charts size and sizeAxis refactoring * chore: Z axis for the bubble chart * chore: small fix * chore: Upd types and snapshots * chore: Additional tests * chore: Refactoring * refactor: Changes z-axis to bubble-axis (#195) * poc * z -> size * fix: tests * small fix * chore: Update snapshots * chore: Update snapshots * chore: Update snapshots --------- Co-authored-by: Georgii Lobko --------- Co-authored-by: Andrei Zhaleznichenka --- .../01-cartesian-chart/bubble-chart.page.tsx | 52 +++++----- .../__snapshots__/documenter.test.ts.snap | 79 +++++++-------- .../cartesian-chart-tooltip.test.tsx | 96 ++++++++++++++++++- .../chart-cartesian-internal.tsx | 26 +---- .../chart-series-cartesian.tsx | 7 +- src/cartesian-chart/index.tsx | 25 ++++- src/cartesian-chart/interfaces.ts | 12 ++- src/core/chart-core.tsx | 1 + src/core/components/core-tooltip.tsx | 35 ++++++- src/core/interfaces.ts | 17 +++- src/core/utils.ts | 11 +++ 11 files changed, 255 insertions(+), 106 deletions(-) diff --git a/pages/01-cartesian-chart/bubble-chart.page.tsx b/pages/01-cartesian-chart/bubble-chart.page.tsx index 8116241e..2216ecd0 100644 --- a/pages/01-cartesian-chart/bubble-chart.page.tsx +++ b/pages/01-cartesian-chart/bubble-chart.page.tsx @@ -2,42 +2,39 @@ // SPDX-License-Identifier: Apache-2.0 import { CartesianChart, CartesianChartProps } from "../../lib/components"; -import { dateFormatter } from "../common/formatters"; +import { dateFormatter, moneyFormatter } from "../common/formatters"; import { useChartSettings } from "../common/page-settings"; import { Page } from "../common/templates"; -import pseudoRandom from "../utils/pseudo-random"; -function randomInt(min: number, max: number) { - return min + Math.floor(pseudoRandom() * (max - min)); -} +// Note: in Highcharts, the scale can be configured explicitly with zMin/zMax on the bubble series, which is not currently exposed in the API. +const timeScale = 10; +const costScale = 1000; const baseline = [ - { x: 1600984800000, y: 58020 }, - { x: 1600985700000, y: 102402 }, - { x: 1600986600000, y: 104920 }, - { x: 1600987500000, y: 94031 }, - { x: 1600988400000, y: 125021 }, - { x: 1600989300000, y: 159219 }, - { x: 1600990200000, y: 193082 }, - { x: 1600991100000, y: 162592 }, - { x: 1600992000000, y: 274021 }, - { x: 1600992900000, y: 264286 }, + { x: 1600984800000, y: 5802, timeToFix: 50 / timeScale, costImpact: null }, + { x: 1600985700000, y: 10240, timeToFix: 234 / timeScale, costImpact: 30_000 / costScale }, + { x: 1600986600000, y: 10492, timeToFix: 553 / timeScale, costImpact: 50_000 / costScale }, + { x: 1600987500000, y: 9403, timeToFix: 33 / timeScale, costImpact: null }, + { x: 1600988400000, y: 12502, timeToFix: 44 / timeScale, costImpact: 100_000 / costScale }, + { x: 1600989300000, y: 15921, timeToFix: 22 / timeScale, costImpact: 10_000 / costScale }, + { x: 1600990200000, y: 19308, timeToFix: 111 / timeScale, costImpact: 20_000 / costScale }, + { x: 1600991100000, y: 16259, timeToFix: 343 / timeScale, costImpact: 20_000 / costScale }, + { x: 1600992000000, y: 27402, timeToFix: 11 / timeScale, costImpact: null }, + { x: 1600992900000, y: 2628, timeToFix: 3 / timeScale, costImpact: 80_000 / costScale }, ]; const series: CartesianChartProps.SeriesOptions[] = [ { - name: "Series A", + name: "Time to fix", + sizeAxis: "time-axis", type: "bubble", - data: baseline.map(({ x, y }) => ({ x, y, z: randomInt(100, 300) })), + data: baseline.map(({ x, y, timeToFix: size }) => ({ x, y, size })), }, { - name: "Series B", + name: "Cost impact", + sizeAxis: "cost-axis", type: "bubble", - data: baseline.map(({ x, y }) => ({ - x: x + (Math.random() > 0.2 ? randomInt(-5000000, 5000000) : 0), - y: y + randomInt(-50000, 50000), - z: randomInt(50, 400), - })), + data: baseline.map(({ x, y, costImpact: size }) => ({ x, y, size })), }, ]; @@ -50,7 +47,14 @@ export default function () { series={series} xAxis={{ title: "Time (UTC)", type: "datetime", valueFormatter: dateFormatter }} yAxis={{ title: "Events" }} - zAxis={{ title: "Average size", valueFormatter: (value) => `${value}kB` }} + sizeAxis={[ + { id: "time-axis", title: "Time to fix", valueFormatter: (value) => `${value! * timeScale} minutes` }, + { + id: "cost-axis", + title: "Cost impact", + valueFormatter: (value) => moneyFormatter(value! * costScale), + }, + ]} chartHeight={400} /> diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 0ba4fc8a..cc96d2f5 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -324,6 +324,25 @@ Supported types: "optional": false, "type": "ReadonlyArray", }, + { + "description": "Title for the size axis, used as the label for the size value in bubble chart tooltips. + +Supported options: +* \`id\` (optional, string) - Axis id. +* \`title\` (string) - Axis title. +* \`valueFormatter\` (optional, function) - Takes axis tick as input and returns a formatted string for tooltip points values.", + "inlineType": { + "name": "CartesianChartProps.SizeAxisOptions | ReadonlyArray", + "type": "union", + "values": [ + "CartesianChartProps.SizeAxisOptions", + "ReadonlyArray", + ], + }, + "name": "sizeAxis", + "optional": true, + "type": "CartesianChartProps.SizeAxisOptions | ReadonlyArray", + }, { "defaultValue": "undefined", "description": "Enables series stacking behavior. Use it for column- or area- series. @@ -651,43 +670,6 @@ applies to the tooltip points values.", "optional": true, "type": "CartesianChartProps.YAxisOptions", }, - { - "description": "Title for the Z axis, used as the label for the Z value in bubble chart tooltips. - -Supported options: -* \`title\` (optional, string) - Axis title. -* \`valueFormatter\` (optional, function) - Takes axis tick as input and returns a formatted string for tooltip points values.", - "inlineType": { - "name": "CartesianChartProps.ZAxisOptions", - "properties": [ - { - "name": "title", - "optional": true, - "type": "string", - }, - { - "inlineType": { - "name": "(value: number | null) => string", - "parameters": [ - { - "name": "value", - "type": "number | null", - }, - ], - "returnType": "string", - "type": "function", - }, - "name": "valueFormatter", - "optional": true, - "type": "((value: number | null) => string)", - }, - ], - "type": "object", - }, - "name": "zAxis", - "optional": true, - "type": "CartesianChartProps.ZAxisOptions", - }, ], "regions": [ { @@ -1807,7 +1789,7 @@ not overridden, but merged with Cloudscape event handlers so that both are getti "type": "union", "valueDescriptions": undefined, "values": [ - "Omit", + "Omit", "{ xAxis?: CoreChartProps.XAxisOptions | Array | undefined; yAxis?: CoreChartProps.YAxisOptions | Array | undefined; }", ], }, @@ -1817,6 +1799,27 @@ not overridden, but merged with Cloudscape event handlers so that both are getti "type": "CoreChartProps.ChartOptions", "visualRefreshTag": undefined, }, + { + "analyticsTag": undefined, + "defaultValue": undefined, + "deprecatedTag": undefined, + "description": undefined, + "i18nTag": undefined, + "inlineType": { + "name": "CoreChartProps.SizeAxisOptions | ReadonlyArray", + "type": "union", + "valueDescriptions": undefined, + "values": [ + "CoreChartProps.SizeAxisOptions", + "ReadonlyArray", + ], + }, + "name": "sizeAxis", + "optional": true, + "systemTags": undefined, + "type": "CoreChartProps.SizeAxisOptions | ReadonlyArray", + "visualRefreshTag": undefined, + }, { "analyticsTag": undefined, "defaultValue": undefined, diff --git a/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx b/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx index 854e68c7..af3cb702 100644 --- a/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx +++ b/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx @@ -80,7 +80,7 @@ describe("CartesianChart: tooltip", () => { expect(getTooltipHeader().getElement().textContent).toBe(x === 0.01 ? "0.01" : x.toString()); expect(getAllTooltipSeries()).toHaveLength(9); // Error bar is not counted as a series expect(getTooltipBody().getElement().textContent).toBe( - `Area1\nArea spline2\nColumn3\nError bar4 - 5\nLine6\nScatter7\nSpline8\nBubble95\nX threshold\nY threshold10`, + `Area1\nArea spline2\nColumn3\nError bar4 - 5\nLine6\nScatter7\nSpline8\nBubble9\nX threshold\nY threshold10`, ); }, ); @@ -298,3 +298,97 @@ describe("CartesianChart: tooltip", () => { }); }); }); + +describe("CartesianChart: bubble tooltip", () => { + const bubbleSeries: CartesianChartProps.SeriesOptions[] = [ + { + type: "bubble", + name: "Bubble", + data: [ + { x: 1, y: 9, size: 5 }, + { x: 2, y: 4, size: 3 }, + ], + }, + ]; + + test("renders bubble tooltip with y sub-item only when no sizeAxis and no yAxis title", async () => { + renderCartesianChart({ + highcharts, + series: bubbleSeries, + }); + + act(() => hc.highlightChartPoint(0, 0)); + + await waitFor(() => { + expect(getTooltip()).not.toBe(null); + const series = getTooltipSeries(0); + // Value is empty because y is shown as a sub-item instead + expect(series.findValue().getElement().textContent).toBe(""); + // Without sizeAxis, only y sub-item is shown + expect(series.findSubItems()).toHaveLength(1); + expect(series.findSubItems()[0].findKey().getElement().textContent).toBe(""); + expect(series.findSubItems()[0].findValue().getElement().textContent).toBe("9"); + }); + }); + + test("renders bubble tooltip with y sub-item only when sizeAxis is not provided", async () => { + renderCartesianChart({ + highcharts, + series: bubbleSeries, + yAxis: { title: "Events" }, + }); + + act(() => hc.highlightChartPoint(0, 0)); + + await waitFor(() => { + expect(getTooltip()).not.toBe(null); + const series = getTooltipSeries(0); + // Without sizeAxis, only y sub-item is shown + expect(series.findSubItems()).toHaveLength(1); + expect(series.findSubItems()[0].findKey().getElement().textContent).toBe("Events"); + expect(series.findSubItems()[0].findValue().getElement().textContent).toBe("9"); + }); + }); + + test("renders bubble tooltip with sizeAxis title and default formatter", async () => { + renderCartesianChart({ + highcharts, + series: bubbleSeries, + yAxis: { title: "Events" }, + sizeAxis: { title: "Size" }, + }); + + act(() => hc.highlightChartPoint(0, 0)); + + await waitFor(() => { + expect(getTooltip()).not.toBe(null); + const series = getTooltipSeries(0); + expect(series.findSubItems()).toHaveLength(2); + expect(series.findSubItems()[0].findKey().getElement().textContent).toBe("Events"); + expect(series.findSubItems()[1].findKey().getElement().textContent).toBe("Size"); + // Without valueFormatter, z value is shown as raw number + expect(series.findSubItems()[1].findValue().getElement().textContent).toBe("5"); + }); + }); + + test("renders bubble tooltip with custom sizeAxis valueFormatter", async () => { + renderCartesianChart({ + highcharts, + series: bubbleSeries, + yAxis: { title: "Events" }, + sizeAxis: { title: "Average size", valueFormatter: (value) => `${value}kB` }, + }); + + act(() => hc.highlightChartPoint(0, 0)); + + await waitFor(() => { + expect(getTooltip()).not.toBe(null); + const series = getTooltipSeries(0); + expect(series.findSubItems()).toHaveLength(2); + expect(series.findSubItems()[0].findKey().getElement().textContent).toBe("Events"); + expect(series.findSubItems()[0].findValue().getElement().textContent).toBe("9"); + expect(series.findSubItems()[1].findKey().getElement().textContent).toBe("Average size"); + expect(series.findSubItems()[1].findValue().getElement().textContent).toBe("5kB"); + }); + }); +}); diff --git a/src/cartesian-chart/chart-cartesian-internal.tsx b/src/cartesian-chart/chart-cartesian-internal.tsx index 40f704a4..9b171943 100644 --- a/src/cartesian-chart/chart-cartesian-internal.tsx +++ b/src/cartesian-chart/chart-cartesian-internal.tsx @@ -6,7 +6,6 @@ import { forwardRef, useImperativeHandle, useRef, useState } from "react"; import { useControllableState } from "@cloudscape-design/component-toolkit"; import { InternalCoreChart } from "../core/chart-core"; -import { getFormatter } from "../core/formatters"; import { CoreChartProps, ErrorBarSeriesOptions } from "../core/interfaces"; import { getOptionsId, isXThreshold } from "../core/utils"; import { InternalBaseComponentProps } from "../internal/base-component/use-base-component"; @@ -84,31 +83,9 @@ export const InternalCartesianChart = forwardRef( x: props.x, items: props.items.map(transformItem), }); - const defaultBubblePoint = ( - coreProps: CoreChartProps.TooltipPointProps, - ): CartesianChartProps.TooltipPointFormatted => { - if (coreProps.item.point.series.type !== "bubble") { - return {}; - } - const yAxisTitle = castArray(props.yAxis)?.[0]?.title; - const yFormatter = coreProps.item.point.series.yAxis - ? getFormatter(coreProps.item.point.series.yAxis) - : (v: unknown) => String(v); - const zValue = coreProps.item.point.options.z; - return { - // The y value will be explicitly listed as a sub-item so it shouldn't be listed here. - value: "", - subItems: [ - { key: yAxisTitle, value: yFormatter(coreProps.item.point.y) }, - { key: props.zAxis?.title, value: props.zAxis?.valueFormatter?.(zValue ?? null) ?? zValue }, - ], - }; - }; return { - point: tooltip.point - ? (coreProps) => tooltip.point!(transformSeriesProps(coreProps)) - : (coreProps) => defaultBubblePoint(coreProps), + point: tooltip.point ? (coreProps) => tooltip.point!(transformSeriesProps(coreProps)) : undefined, header: tooltip.header ? (coreProps) => tooltip.header!(transformSlotProps(coreProps)) : undefined, body: tooltip.body ? (coreProps) => tooltip.body!(transformSlotProps(coreProps)) : undefined, footer: tooltip.footer ? (coreProps) => tooltip.footer!(transformSlotProps(coreProps)) : undefined, @@ -147,6 +124,7 @@ export const InternalCartesianChart = forwardRef( plotLines: yPlotLines, })), }} + sizeAxis={props.sizeAxis} tooltip={tooltip} getTooltipContent={getTooltipContent} visibleItems={props.visibleSeries} diff --git a/src/cartesian-chart/chart-series-cartesian.tsx b/src/cartesian-chart/chart-series-cartesian.tsx index 4caee896..f68da55c 100644 --- a/src/cartesian-chart/chart-series-cartesian.tsx +++ b/src/cartesian-chart/chart-series-cartesian.tsx @@ -5,8 +5,8 @@ import type Highcharts from "highcharts"; import { colorChartsErrorBarMarker } from "@cloudscape-design/design-tokens"; -import { PointDataItemType, RangeDataItemOptions, ZPointDataItemOptions } from "../core/interfaces"; -import { createThresholdMetadata, getOptionsId } from "../core/utils"; +import { PointDataItemType, RangeDataItemOptions } from "../core/interfaces"; +import { createBubbleMetadata, createThresholdMetadata, getOptionsId } from "../core/utils"; import * as Styles from "../internal/chart-styles"; import { Writeable } from "../internal/utils/utils"; import { CartesianChartProps } from "./interfaces"; @@ -76,7 +76,8 @@ export const transformCartesianSeries = ( return { ...s, data: s.data as Writeable, ...colors }; } if (s.type === "bubble") { - return { ...s, data: s.data as Writeable, ...shared, ...getColorProps(s) }; + const { custom } = createBubbleMetadata(s); + return { ...s, data: s.data.map((p) => ({ x: p.x, y: p.y, z: p.size })), ...shared, custom, ...getColorProps(s) }; } return { ...s, data: s.data as Writeable, ...shared, ...getColorProps(s) }; } diff --git a/src/cartesian-chart/index.tsx b/src/cartesian-chart/index.tsx index a3e0190f..2755eef2 100644 --- a/src/cartesian-chart/index.tsx +++ b/src/cartesian-chart/index.tsx @@ -5,11 +5,11 @@ import { forwardRef } from "react"; import { warnOnce } from "@cloudscape-design/component-toolkit/internal"; -import { PointDataItemType, RangeDataItemOptions, ZPointDataItemOptions } from "../core/interfaces"; +import { PointDataItemType, RangeDataItemOptions, SizePointDataItemOptions } from "../core/interfaces"; import { getDataAttributes } from "../internal/base-component/get-data-attributes"; import useBaseComponent from "../internal/base-component/use-base-component"; import { applyDisplayName } from "../internal/utils/apply-display-name"; -import { SomeRequired } from "../internal/utils/utils"; +import { castArray, SomeRequired } from "../internal/utils/utils"; import { InternalCartesianChart } from "./chart-cartesian-internal"; import { CartesianChartProps } from "./interfaces"; import { getMasterSeries, isErrorBar, isThreshold } from "./utils"; @@ -34,6 +34,7 @@ const CartesianChart = forwardRef( const series = transformSeries(props.series, stacking); const xAxis = transformXAxisOptions(props.xAxis); const yAxis = transformYAxisOptions(props.yAxis); + const sizeAxis = transformSizeAxisOptions(props.sizeAxis); const tooltip = transformTooltip(props.tooltip); const legend = transformLegend(props.legend); @@ -54,6 +55,7 @@ const CartesianChart = forwardRef( series={series} xAxis={xAxis} yAxis={yAxis} + sizeAxis={sizeAxis} tooltip={tooltip} legend={legend} {...getDataAttributes(props)} @@ -173,7 +175,7 @@ function transformBubbleSeries (d && typeof d === "object" ? { x: d.x, y: d.y } : d)); } -function transformBubbleData(data: readonly ZPointDataItemOptions[]): readonly ZPointDataItemOptions[] { - return data.map((d) => ({ x: d.x, y: d.y, z: d.z })); +function transformBubbleData(data: readonly SizePointDataItemOptions[]): readonly SizePointDataItemOptions[] { + return data.map((d) => ({ x: d.x, y: d.y, size: d.size })); } function transformRangeData(data: readonly RangeDataItemOptions[]): readonly RangeDataItemOptions[] { @@ -228,6 +230,19 @@ function transformYAxisOptions(axis?: CartesianChartProps.YAxisOptions): Cartesi return { ...transformAxisOptions(axis), reversedStacks: axis?.reversedStacks }; } +function transformSizeAxisOptions( + axis?: CartesianChartProps.SizeAxisOptions | readonly CartesianChartProps.SizeAxisOptions[], +): undefined | CartesianChartProps.SizeAxisOptions[] { + if (!axis) { + return undefined; + } + return castArray(axis as CartesianChartProps.SizeAxisOptions[])?.map((a) => ({ + id: a.id, + title: a.title, + valueFormatter: a.valueFormatter, + })); +} + function transformAxisOptions( axis?: O, ): O { diff --git a/src/cartesian-chart/interfaces.ts b/src/cartesian-chart/interfaces.ts index aad68bff..2b4c0b60 100644 --- a/src/cartesian-chart/interfaces.ts +++ b/src/cartesian-chart/interfaces.ts @@ -96,13 +96,14 @@ export interface CartesianChartProps yAxis?: CartesianChartProps.YAxisOptions; /** - * Title for the Z axis, used as the label for the Z value in bubble chart tooltips. + * Title for the size axis, used as the label for the size value in bubble chart tooltips. * * Supported options: - * * `title` (optional, string) - Axis title. + * * `id` (optional, string) - Axis id. + * * `title` (string) - Axis title. * * `valueFormatter` (optional, function) - Takes axis tick as input and returns a formatted string for tooltip points values. */ - zAxis?: CartesianChartProps.ZAxisOptions; + sizeAxis?: CartesianChartProps.SizeAxisOptions | readonly CartesianChartProps.SizeAxisOptions[]; /** * Specifies which series to show using their IDs. By default, all series are visible and managed by the component. @@ -162,8 +163,9 @@ export namespace CartesianChartProps { valueFormatter?: (value: null | number) => string; } - export interface ZAxisOptions { - title?: string; + export interface SizeAxisOptions { + id?: string; + title: string; valueFormatter?: (value: null | number) => string; } diff --git a/src/core/chart-core.tsx b/src/core/chart-core.tsx index 0e4f0547..45d0ba67 100644 --- a/src/core/chart-core.tsx +++ b/src/core/chart-core.tsx @@ -393,6 +393,7 @@ export function InternalCoreChart({ {...tooltipOptions} i18nStrings={i18nStrings} getTooltipContent={rest.getTooltipContent} + sizeAxis={castArray(rest.sizeAxis as CoreChartProps.SizeAxisOptions[])} api={api} /> )} diff --git a/src/core/components/core-tooltip.tsx b/src/core/components/core-tooltip.tsx index 735e5663..5898c43b 100644 --- a/src/core/components/core-tooltip.tsx +++ b/src/core/components/core-tooltip.tsx @@ -16,6 +16,7 @@ import { ChartAPI } from "../chart-api"; import { getFormatter } from "../formatters"; import { BaseI18nStrings, CoreChartProps } from "../interfaces"; import { + BubbleOptions, getPointColor, getPointId, getSeriesColor, @@ -56,9 +57,11 @@ export function ChartTooltip({ i18nStrings, debounce = false, seriesSorting = "as-added", + sizeAxis, }: CoreChartProps.TooltipOptions & { i18nStrings?: BaseI18nStrings; getTooltipContent?: CoreChartProps.GetTooltipContent; + sizeAxis?: readonly CoreChartProps.SizeAxisOptions[]; api: ChartAPI; }) { const [expandedSeries, setExpandedSeries] = useState({}); @@ -91,6 +94,7 @@ export function ChartTooltip({ expandedSeries, setExpandedSeries, seriesSorting, + sizeAxis, hideTooltip: () => { api.hideTooltip(); }, @@ -143,6 +147,7 @@ function getTooltipContent( renderers?: CoreChartProps.TooltipContentRenderer; hideTooltip: () => void; seriesSorting: NonNullable; + sizeAxis?: readonly CoreChartProps.SizeAxisOptions[]; } & ExpandedSeriesStateProps, ): null | RenderedTooltipContent { if (props.point && props.point.series.type === "pie") { @@ -164,10 +169,12 @@ function getTooltipContentCartesian( setExpandedSeries, hideTooltip, seriesSorting, + sizeAxis, }: CoreChartProps.GetTooltipContentProps & { renderers?: CoreChartProps.TooltipContentRenderer; hideTooltip: () => void; seriesSorting: NonNullable; + sizeAxis?: readonly CoreChartProps.SizeAxisOptions[]; } & ExpandedSeriesStateProps, ): RenderedTooltipContent { // The cartesian tooltip might or might not have a selected point, but it always has a non-empty group. @@ -188,17 +195,19 @@ function getTooltipContentCartesian( const detailItems: ChartSeriesDetailItem[] = matchedItems.map((item) => { const valueFormatter = getFormatter(item.point.series.yAxis); const itemY = isXThreshold(item.point.series) ? null : (item.point.y ?? null); + const bubbleSubItems = item.point.series.type === "bubble" ? getBubblePointDetails(item, sizeAxis) : undefined; const customContent = renderers.point ? renderers.point({ item, hideTooltip, }) : undefined; + const defaultValue = bubbleSubItems ? null : valueFormatter(itemY); return { key: customContent?.key ?? item.point.series.name, - value: customContent?.value ?? valueFormatter(itemY), + value: customContent?.value ?? defaultValue, marker: getSeriesMarker(item.point.series), - subItems: customContent?.subItems, + subItems: customContent?.subItems ?? bubbleSubItems, expandableId: customContent?.expandable ? item.point.series.name : undefined, highlighted: item.point.x === point?.x && item.point.y === point?.y, description: @@ -299,6 +308,28 @@ function getTooltipContentPie( }; } +function getBubblePointDetails(item: MatchedItem, sizeAxis?: readonly CoreChartProps.SizeAxisOptions[]) { + const details: { key: React.ReactNode; value: React.ReactNode }[] = []; + + const y = item.point.y; + const yAxisTitle = item.point.series.yAxis?.options?.title?.text; + const yFormatter = item.point.series.yAxis ? getFormatter(item.point.series.yAxis) : (v: unknown) => String(v); + details.push({ key: yAxisTitle, value: yFormatter(y) }); + + const z = item.point.options.z ?? null; + const zAxis = + sizeAxis?.find((a) => { + const custom = item.point.series.options.custom as BubbleOptions["custom"]; + return a.id === custom?.awsui?.sizeAxis; + }) ?? sizeAxis?.[0]; + if (zAxis) { + const zFormatter = zAxis.valueFormatter ?? ((v: unknown) => String(v)); + details.push({ key: zAxis.title, value: zFormatter(z) }); + } + + return details; +} + function findTooltipSeriesItems( series: readonly Highcharts.Series[], group: readonly Highcharts.Point[], diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index 1fe5f9b5..e10f9fa3 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -239,7 +239,8 @@ export interface ScatterSeriesOptions extends BaseCartesianSeriesOptions, Linkab export interface BubbleSeriesOptions extends BaseCartesianSeriesOptions, LinkableSeries { type: "bubble"; - data: readonly ZPointDataItemOptions[]; + sizeAxis?: string; + data: readonly SizePointDataItemOptions[]; } export interface ErrorBarSeriesOptions extends Omit { @@ -283,10 +284,10 @@ export interface PointDataItemOptions { y: number | null; } -export interface ZPointDataItemOptions { +export interface SizePointDataItemOptions { x?: number; y: number | null; - z: number | null; + size: number | null; } export interface RangeDataItemOptions { @@ -431,6 +432,8 @@ export interface CoreChartProps * Specifies the options for each item in the chart. */ getItemOptions?: CoreChartProps.GetItemOptions; + + sizeAxis?: CoreChartProps.SizeAxisOptions | readonly CoreChartProps.SizeAxisOptions[]; } export namespace CoreChartProps { @@ -453,13 +456,19 @@ export namespace CoreChartProps { // The extended version of Highcharts.Options. The axes types are extended with Cloudscape value formatter. // We use a custom formatter because we cannot use the built-in Highcharts formatter for our tooltip. - export type ChartOptions = Omit & { + export type ChartOptions = Omit & { xAxis?: XAxisOptions | XAxisOptions[]; yAxis?: YAxisOptions | YAxisOptions[]; }; export type XAxisOptions = Highcharts.XAxisOptions & { valueFormatter?: (value: null | number) => string }; export type YAxisOptions = Highcharts.YAxisOptions & { valueFormatter?: (value: null | number) => string }; + export interface SizeAxisOptions { + id?: string; + title: string; + valueFormatter?: (value: null | number) => string; + } + export interface ChartItemOptions { /** * If specified, specifies the status of an item. diff --git a/src/core/utils.ts b/src/core/utils.ts index c33869b8..1b4696c6 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -73,6 +73,17 @@ export function isYThreshold( return typeof s.options.custom === "object" && s.options.custom.awsui?.type === "y-threshold"; } +export interface BubbleOptions { + custom: { + awsui: { + sizeAxis?: string; + }; + }; +} +export function createBubbleMetadata(options: { sizeAxis?: string }): BubbleOptions { + return { custom: { awsui: options } }; +} + // We check point.series explicitly because Highcharts can destroy point objects, replacing the // contents with { destroyed: true }, violating the point's TS contract. // See: https://github.com/highcharts/highcharts/issues/23175. From cdcc6d11834d8fd86a15ebba1080d494f597b570 Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Fri, 27 Mar 2026 14:31:19 +0100 Subject: [PATCH 05/11] chore: Review comments --- src/__tests__/__snapshots__/documenter.test.ts.snap | 8 ++++---- src/cartesian-chart/chart-cartesian-internal.tsx | 2 +- src/cartesian-chart/index.tsx | 3 --- src/cartesian-chart/interfaces.ts | 10 +++++----- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index cc96d2f5..d416e074 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -325,12 +325,12 @@ Supported types: "type": "ReadonlyArray", }, { - "description": "Title for the size axis, used as the label for the size value in bubble chart tooltips. + "description": "One or multiple axes to provide size details formatting for bubble series. Supported options: -* \`id\` (optional, string) - Axis id. -* \`title\` (string) - Axis title. -* \`valueFormatter\` (optional, function) - Takes axis tick as input and returns a formatted string for tooltip points values.", +* \`id\` (optional, string) - Use it to connect axes with respective series when multiple size axes are present. The axis id must correspond \`sizeAxis\` property of the bubble series. +* \`title\` (string) - Axis title, shown in the tooltip details for bubble series. +* \`valueFormatter\` (optional, function) - Value formatter for \`size\` values of the bubble series data.", "inlineType": { "name": "CartesianChartProps.SizeAxisOptions | ReadonlyArray", "type": "union", diff --git a/src/cartesian-chart/chart-cartesian-internal.tsx b/src/cartesian-chart/chart-cartesian-internal.tsx index 9b171943..5fd2b284 100644 --- a/src/cartesian-chart/chart-cartesian-internal.tsx +++ b/src/cartesian-chart/chart-cartesian-internal.tsx @@ -63,7 +63,7 @@ export const InternalCartesianChart = forwardRef( return { x: item.point.x, y: isXThreshold(item.point.series) ? null : (item.point.y ?? null), - z: item.point.options.z ?? undefined, + size: item.point.options.z ?? undefined, series, errorRanges: item.errorRanges.map((point) => ({ low: point.options.low ?? 0, diff --git a/src/cartesian-chart/index.tsx b/src/cartesian-chart/index.tsx index 2755eef2..fa97de67 100644 --- a/src/cartesian-chart/index.tsx +++ b/src/cartesian-chart/index.tsx @@ -233,9 +233,6 @@ function transformYAxisOptions(axis?: CartesianChartProps.YAxisOptions): Cartesi function transformSizeAxisOptions( axis?: CartesianChartProps.SizeAxisOptions | readonly CartesianChartProps.SizeAxisOptions[], ): undefined | CartesianChartProps.SizeAxisOptions[] { - if (!axis) { - return undefined; - } return castArray(axis as CartesianChartProps.SizeAxisOptions[])?.map((a) => ({ id: a.id, title: a.title, diff --git a/src/cartesian-chart/interfaces.ts b/src/cartesian-chart/interfaces.ts index 2b4c0b60..3db7bff5 100644 --- a/src/cartesian-chart/interfaces.ts +++ b/src/cartesian-chart/interfaces.ts @@ -96,12 +96,12 @@ export interface CartesianChartProps yAxis?: CartesianChartProps.YAxisOptions; /** - * Title for the size axis, used as the label for the size value in bubble chart tooltips. + * One or multiple axes to provide size details formatting for bubble series. * * Supported options: - * * `id` (optional, string) - Axis id. - * * `title` (string) - Axis title. - * * `valueFormatter` (optional, function) - Takes axis tick as input and returns a formatted string for tooltip points values. + * * `id` (optional, string) - Use it to connect axes with respective series when multiple size axes are present. The axis id must correspond `sizeAxis` property of the bubble series. + * * `title` (string) - Axis title, shown in the tooltip details for bubble series. + * * `valueFormatter` (optional, function) - Value formatter for `size` values of the bubble series data. */ sizeAxis?: CartesianChartProps.SizeAxisOptions | readonly CartesianChartProps.SizeAxisOptions[]; @@ -197,7 +197,7 @@ export namespace CartesianChartProps { export interface TooltipPointItem { x: number; y: number | null; - z?: number | null; + size?: number | null; errorRanges: { low: number; high: number; series: CartesianChartProps.ErrorBarSeriesOptions }[]; series: NonErrorBarSeriesOptions; } From 7a24a5a402fd7ac5417707fbfb3e7f19baa0268b Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Fri, 27 Mar 2026 14:47:30 +0100 Subject: [PATCH 06/11] chore: Review comments --- pages/01-cartesian-chart/bubble-chart.page.tsx | 6 +++--- .../__snapshots__/documenter.test.ts.snap | 2 +- src/core/components/core-tooltip.tsx | 16 +++++++++------- src/core/interfaces.ts | 4 ++-- src/core/utils.ts | 1 + 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/pages/01-cartesian-chart/bubble-chart.page.tsx b/pages/01-cartesian-chart/bubble-chart.page.tsx index 2216ecd0..79b90aa8 100644 --- a/pages/01-cartesian-chart/bubble-chart.page.tsx +++ b/pages/01-cartesian-chart/bubble-chart.page.tsx @@ -11,15 +11,15 @@ const timeScale = 10; const costScale = 1000; const baseline = [ - { x: 1600984800000, y: 5802, timeToFix: 50 / timeScale, costImpact: null }, + { x: 1600984800000, y: 5802, timeToFix: 50 / timeScale, costImpact: 0 }, { x: 1600985700000, y: 10240, timeToFix: 234 / timeScale, costImpact: 30_000 / costScale }, { x: 1600986600000, y: 10492, timeToFix: 553 / timeScale, costImpact: 50_000 / costScale }, - { x: 1600987500000, y: 9403, timeToFix: 33 / timeScale, costImpact: null }, + { x: 1600987500000, y: 9403, timeToFix: 33 / timeScale, costImpact: 0 }, { x: 1600988400000, y: 12502, timeToFix: 44 / timeScale, costImpact: 100_000 / costScale }, { x: 1600989300000, y: 15921, timeToFix: 22 / timeScale, costImpact: 10_000 / costScale }, { x: 1600990200000, y: 19308, timeToFix: 111 / timeScale, costImpact: 20_000 / costScale }, { x: 1600991100000, y: 16259, timeToFix: 343 / timeScale, costImpact: 20_000 / costScale }, - { x: 1600992000000, y: 27402, timeToFix: 11 / timeScale, costImpact: null }, + { x: 1600992000000, y: 27402, timeToFix: 11 / timeScale, costImpact: 0 }, { x: 1600992900000, y: 2628, timeToFix: 3 / timeScale, costImpact: 80_000 / costScale }, ]; diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index d416e074..4e39b559 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -1789,7 +1789,7 @@ not overridden, but merged with Cloudscape event handlers so that both are getti "type": "union", "valueDescriptions": undefined, "values": [ - "Omit", + "Omit", "{ xAxis?: CoreChartProps.XAxisOptions | Array | undefined; yAxis?: CoreChartProps.YAxisOptions | Array | undefined; }", ], }, diff --git a/src/core/components/core-tooltip.tsx b/src/core/components/core-tooltip.tsx index 5898c43b..44fc54ed 100644 --- a/src/core/components/core-tooltip.tsx +++ b/src/core/components/core-tooltip.tsx @@ -309,25 +309,27 @@ function getTooltipContentPie( } function getBubblePointDetails(item: MatchedItem, sizeAxis?: readonly CoreChartProps.SizeAxisOptions[]) { - const details: { key: React.ReactNode; value: React.ReactNode }[] = []; + const subItems: { key: React.ReactNode; value: React.ReactNode }[] = []; const y = item.point.y; const yAxisTitle = item.point.series.yAxis?.options?.title?.text; const yFormatter = item.point.series.yAxis ? getFormatter(item.point.series.yAxis) : (v: unknown) => String(v); - details.push({ key: yAxisTitle, value: yFormatter(y) }); + subItems.push({ key: yAxisTitle, value: yFormatter(y) }); + // maps Highcharts' `z` prop (bubble size) to our custom size axis for title/formatter support, + // which Highcharts doesn't provide for bubble series by default. const z = item.point.options.z ?? null; - const zAxis = + const matchedSizeAxis = sizeAxis?.find((a) => { const custom = item.point.series.options.custom as BubbleOptions["custom"]; return a.id === custom?.awsui?.sizeAxis; }) ?? sizeAxis?.[0]; - if (zAxis) { - const zFormatter = zAxis.valueFormatter ?? ((v: unknown) => String(v)); - details.push({ key: zAxis.title, value: zFormatter(z) }); + if (matchedSizeAxis) { + const sizeValue = item.point.options.z; + subItems.push({ key: matchedSizeAxis.title, value: matchedSizeAxis.valueFormatter?.(z) ?? sizeValue }); } - return details; + return subItems; } function findTooltipSeriesItems( diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index e10f9fa3..48aada74 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -287,7 +287,7 @@ export interface PointDataItemOptions { export interface SizePointDataItemOptions { x?: number; y: number | null; - size: number | null; + size: number; } export interface RangeDataItemOptions { @@ -456,7 +456,7 @@ export namespace CoreChartProps { // The extended version of Highcharts.Options. The axes types are extended with Cloudscape value formatter. // We use a custom formatter because we cannot use the built-in Highcharts formatter for our tooltip. - export type ChartOptions = Omit & { + export type ChartOptions = Omit & { xAxis?: XAxisOptions | XAxisOptions[]; yAxis?: YAxisOptions | YAxisOptions[]; }; diff --git a/src/core/utils.ts b/src/core/utils.ts index 1b4696c6..a0e01b39 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -81,6 +81,7 @@ export interface BubbleOptions { }; } export function createBubbleMetadata(options: { sizeAxis?: string }): BubbleOptions { + // The custom.awsui.sizeAxis is our way of extending Highcharts' bubble series options type in order to propagate an extra prop that connects bubble series with size axes. return { custom: { awsui: options } }; } From 8465969b43d35c45301d20b64139ae42a65bb243 Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Fri, 27 Mar 2026 15:50:27 +0100 Subject: [PATCH 07/11] chore: Review comments --- .../01-cartesian-chart/bubble-chart.page.tsx | 62 ------------------- .../cartesian-chart-tooltip.test.tsx | 37 +++++++++++ 2 files changed, 37 insertions(+), 62 deletions(-) delete mode 100644 pages/01-cartesian-chart/bubble-chart.page.tsx diff --git a/pages/01-cartesian-chart/bubble-chart.page.tsx b/pages/01-cartesian-chart/bubble-chart.page.tsx deleted file mode 100644 index 79b90aa8..00000000 --- a/pages/01-cartesian-chart/bubble-chart.page.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { CartesianChart, CartesianChartProps } from "../../lib/components"; -import { dateFormatter, moneyFormatter } from "../common/formatters"; -import { useChartSettings } from "../common/page-settings"; -import { Page } from "../common/templates"; - -// Note: in Highcharts, the scale can be configured explicitly with zMin/zMax on the bubble series, which is not currently exposed in the API. -const timeScale = 10; -const costScale = 1000; - -const baseline = [ - { x: 1600984800000, y: 5802, timeToFix: 50 / timeScale, costImpact: 0 }, - { x: 1600985700000, y: 10240, timeToFix: 234 / timeScale, costImpact: 30_000 / costScale }, - { x: 1600986600000, y: 10492, timeToFix: 553 / timeScale, costImpact: 50_000 / costScale }, - { x: 1600987500000, y: 9403, timeToFix: 33 / timeScale, costImpact: 0 }, - { x: 1600988400000, y: 12502, timeToFix: 44 / timeScale, costImpact: 100_000 / costScale }, - { x: 1600989300000, y: 15921, timeToFix: 22 / timeScale, costImpact: 10_000 / costScale }, - { x: 1600990200000, y: 19308, timeToFix: 111 / timeScale, costImpact: 20_000 / costScale }, - { x: 1600991100000, y: 16259, timeToFix: 343 / timeScale, costImpact: 20_000 / costScale }, - { x: 1600992000000, y: 27402, timeToFix: 11 / timeScale, costImpact: 0 }, - { x: 1600992900000, y: 2628, timeToFix: 3 / timeScale, costImpact: 80_000 / costScale }, -]; - -const series: CartesianChartProps.SeriesOptions[] = [ - { - name: "Time to fix", - sizeAxis: "time-axis", - type: "bubble", - data: baseline.map(({ x, y, timeToFix: size }) => ({ x, y, size })), - }, - { - name: "Cost impact", - sizeAxis: "cost-axis", - type: "bubble", - data: baseline.map(({ x, y, costImpact: size }) => ({ x, y, size })), - }, -]; - -export default function () { - const { chartProps } = useChartSettings({ more: true }); - return ( - - `${value! * timeScale} minutes` }, - { - id: "cost-axis", - title: "Cost impact", - valueFormatter: (value) => moneyFormatter(value! * costScale), - }, - ]} - chartHeight={400} - /> - - ); -} diff --git a/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx b/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx index af3cb702..2496c121 100644 --- a/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx +++ b/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx @@ -391,4 +391,41 @@ describe("CartesianChart: bubble tooltip", () => { expect(series.findSubItems()[1].findValue().getElement().textContent).toBe("5kB"); }); }); + + test("renders bubble tooltip with custom expandable sub-items", async () => { + renderCartesianChart({ + highcharts, + series: bubbleSeries, + yAxis: { title: "Events" }, + tooltip: { + point: ({ item }) => ({ + key: item.series.name, + value: String(item.y ?? 0), + expandable: true, + subItems: [ + { key: "Chrome", value: "60%" }, + { key: "Safari", value: "25%" }, + { key: "Others", value: "15%" }, + ], + }), + }, + }); + + act(() => hc.highlightChartPoint(0, 0)); + + await waitFor(() => { + expect(getTooltip()).not.toBe(null); + const series = getTooltipSeries(0); + expect(series.findKey().getElement().textContent).toBe("Bubble"); + expect(series.findValue().getElement().textContent).toBe("9"); + + expect(series.findSubItems()).toHaveLength(3); + expect(series.findSubItems()[0].findKey().getElement().textContent).toBe("Chrome"); + expect(series.findSubItems()[0].findValue().getElement().textContent).toBe("60%"); + expect(series.findSubItems()[1].findKey().getElement().textContent).toBe("Safari"); + expect(series.findSubItems()[1].findValue().getElement().textContent).toBe("25%"); + expect(series.findSubItems()[2].findKey().getElement().textContent).toBe("Others"); + expect(series.findSubItems()[2].findValue().getElement().textContent).toBe("15%"); + }); + }); }); From 251d790f77fcaac3599c167d68be0b26eadf8840 Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Fri, 27 Mar 2026 16:44:42 +0100 Subject: [PATCH 08/11] small fix --- src/core/components/core-tooltip.tsx | 7 +++++-- src/core/formatters.tsx | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/core/components/core-tooltip.tsx b/src/core/components/core-tooltip.tsx index 44fc54ed..f0966754 100644 --- a/src/core/components/core-tooltip.tsx +++ b/src/core/components/core-tooltip.tsx @@ -13,7 +13,7 @@ import { useSelector } from "../../internal/utils/async-store"; import { getChartSeries } from "../../internal/utils/chart-series"; import { useDebouncedValue } from "../../internal/utils/use-debounced-value"; import { ChartAPI } from "../chart-api"; -import { getFormatter } from "../formatters"; +import { getFormatter, numberFormatter } from "../formatters"; import { BaseI18nStrings, CoreChartProps } from "../interfaces"; import { BubbleOptions, @@ -326,7 +326,10 @@ function getBubblePointDetails(item: MatchedItem, sizeAxis?: readonly CoreChartP }) ?? sizeAxis?.[0]; if (matchedSizeAxis) { const sizeValue = item.point.options.z; - subItems.push({ key: matchedSizeAxis.title, value: matchedSizeAxis.valueFormatter?.(z) ?? sizeValue }); + subItems.push({ + key: matchedSizeAxis.title, + value: matchedSizeAxis.valueFormatter?.(z) ?? numberFormatter(sizeValue ?? 0), + }); } return subItems; diff --git a/src/core/formatters.tsx b/src/core/formatters.tsx index 91656d2e..08704ff4 100644 --- a/src/core/formatters.tsx +++ b/src/core/formatters.tsx @@ -102,7 +102,7 @@ function secondFormatter(value: number) { }); } -function numberFormatter(value: number): string { +export function numberFormatter(value: number): string { const format = (num: number) => parseFloat(num.toFixed(2)).toString(); // trims unnecessary decimals const absValue = Math.abs(value); From a66940056216792eb04f97467d1c0089e24289c7 Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Fri, 27 Mar 2026 17:11:53 +0100 Subject: [PATCH 09/11] remove core bubble chart page and restore cartesian one --- .../01-cartesian-chart/bubble-chart.page.tsx | 62 ++++++++++ pages/03-core/core-bubble-chart.page.tsx | 109 ------------------ 2 files changed, 62 insertions(+), 109 deletions(-) create mode 100644 pages/01-cartesian-chart/bubble-chart.page.tsx delete mode 100644 pages/03-core/core-bubble-chart.page.tsx diff --git a/pages/01-cartesian-chart/bubble-chart.page.tsx b/pages/01-cartesian-chart/bubble-chart.page.tsx new file mode 100644 index 00000000..79b90aa8 --- /dev/null +++ b/pages/01-cartesian-chart/bubble-chart.page.tsx @@ -0,0 +1,62 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CartesianChart, CartesianChartProps } from "../../lib/components"; +import { dateFormatter, moneyFormatter } from "../common/formatters"; +import { useChartSettings } from "../common/page-settings"; +import { Page } from "../common/templates"; + +// Note: in Highcharts, the scale can be configured explicitly with zMin/zMax on the bubble series, which is not currently exposed in the API. +const timeScale = 10; +const costScale = 1000; + +const baseline = [ + { x: 1600984800000, y: 5802, timeToFix: 50 / timeScale, costImpact: 0 }, + { x: 1600985700000, y: 10240, timeToFix: 234 / timeScale, costImpact: 30_000 / costScale }, + { x: 1600986600000, y: 10492, timeToFix: 553 / timeScale, costImpact: 50_000 / costScale }, + { x: 1600987500000, y: 9403, timeToFix: 33 / timeScale, costImpact: 0 }, + { x: 1600988400000, y: 12502, timeToFix: 44 / timeScale, costImpact: 100_000 / costScale }, + { x: 1600989300000, y: 15921, timeToFix: 22 / timeScale, costImpact: 10_000 / costScale }, + { x: 1600990200000, y: 19308, timeToFix: 111 / timeScale, costImpact: 20_000 / costScale }, + { x: 1600991100000, y: 16259, timeToFix: 343 / timeScale, costImpact: 20_000 / costScale }, + { x: 1600992000000, y: 27402, timeToFix: 11 / timeScale, costImpact: 0 }, + { x: 1600992900000, y: 2628, timeToFix: 3 / timeScale, costImpact: 80_000 / costScale }, +]; + +const series: CartesianChartProps.SeriesOptions[] = [ + { + name: "Time to fix", + sizeAxis: "time-axis", + type: "bubble", + data: baseline.map(({ x, y, timeToFix: size }) => ({ x, y, size })), + }, + { + name: "Cost impact", + sizeAxis: "cost-axis", + type: "bubble", + data: baseline.map(({ x, y, costImpact: size }) => ({ x, y, size })), + }, +]; + +export default function () { + const { chartProps } = useChartSettings({ more: true }); + return ( + + `${value! * timeScale} minutes` }, + { + id: "cost-axis", + title: "Cost impact", + valueFormatter: (value) => moneyFormatter(value! * costScale), + }, + ]} + chartHeight={400} + /> + + ); +} diff --git a/pages/03-core/core-bubble-chart.page.tsx b/pages/03-core/core-bubble-chart.page.tsx deleted file mode 100644 index 13fd46d8..00000000 --- a/pages/03-core/core-bubble-chart.page.tsx +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import Highcharts from "highcharts"; - -import CoreChart from "../../lib/components/internal-do-not-use/core-chart"; -import { dateFormatter } from "../common/formatters"; -import { useChartSettings } from "../common/page-settings"; -import { Page } from "../common/templates"; -import pseudoRandom from "../utils/pseudo-random"; - -function randomInt(min: number, max: number) { - return min + Math.floor(pseudoRandom() * (max - min)); -} - -const baseline = [ - { x: 1600984800000, y: 58020 }, - { x: 1600985700000, y: 102402 }, - { x: 1600986600000, y: 104920 }, - { x: 1600987500000, y: 94031 }, - { x: 1600988400000, y: 125021 }, - { x: 1600989300000, y: 159219 }, - { x: 1600990200000, y: 193082 }, - { x: 1600991100000, y: 162592 }, - { x: 1600992000000, y: 274021 }, - { x: 1600992900000, y: 264286 }, - { x: 1600993800000, y: 289210 }, - { x: 1600994700000, y: 256362 }, - { x: 1600995600000, y: 257306 }, - { x: 1600996500000, y: 186776 }, - { x: 1600997400000, y: 294020 }, - { x: 1600998300000, y: 385975 }, - { x: 1600999200000, y: 486039 }, - { x: 1601000100000, y: 490447 }, - { x: 1601001000000, y: 361845 }, - { x: 1601001900000, y: 339058 }, - { x: 1601002800000, y: 298028 }, - { x: 1601003400000, y: 255555 }, - { x: 1601003700000, y: 231902 }, - { x: 1601004600000, y: 224558 }, - { x: 1601005500000, y: 253901 }, - { x: 1601006400000, y: 102839 }, - { x: 1601007300000, y: 234943 }, - { x: 1601008200000, y: 204405 }, - { x: 1601009100000, y: 190391 }, - { x: 1601010000000, y: 183570 }, - { x: 1601010900000, y: 162592 }, - { x: 1601011800000, y: 148910 }, - { x: 1601012700000, y: null }, - { x: 1601013600000, y: 293910 }, -]; - -const dataA = baseline.map(({ x, y }) => ({ x, y, z: 100 + randomInt(100, 200) })); -const dataB = baseline.map(({ x, y }) => ({ - x: x + randomInt(-10000000, 10000000), - y: y === null ? null : y + randomInt(-100000, 100000), - z: 100 + randomInt(-50, 300), -})); -const dataC = baseline.map(({ x, y }) => ({ - x: x + randomInt(-10000000, 10000000), - y: y === null ? null : y + randomInt(-150000, 50000), - z: 100 + randomInt(-50, 500), -})); - -const series: Highcharts.SeriesOptionsType[] = [ - { - name: "Series A", - type: "bubble", - data: dataA, - }, - { - name: "Series B", - type: "bubble", - data: dataB, - }, - { - name: "Series C", - type: "bubble", - data: dataC, - }, -]; - -export default function () { - const { chartProps } = useChartSettings({ more: true }); - return ( - - - - ); -} From b28b234900d0dedabdcdd99f948f9687cd7809af Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Fri, 27 Mar 2026 17:31:50 +0100 Subject: [PATCH 10/11] refactoring and fix for default formatter logic --- .../cartesian-chart-tooltip.test.tsx | 1 + src/core/components/core-tooltip.tsx | 27 +++++++++---------- src/core/utils.ts | 8 +++++- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx b/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx index 2496c121..8b96bd4f 100644 --- a/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx +++ b/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx @@ -384,6 +384,7 @@ describe("CartesianChart: bubble tooltip", () => { await waitFor(() => { expect(getTooltip()).not.toBe(null); const series = getTooltipSeries(0); + expect(series.findValue().getElement().textContent).toBe(""); expect(series.findSubItems()).toHaveLength(2); expect(series.findSubItems()[0].findKey().getElement().textContent).toBe("Events"); expect(series.findSubItems()[0].findValue().getElement().textContent).toBe("9"); diff --git a/src/core/components/core-tooltip.tsx b/src/core/components/core-tooltip.tsx index f0966754..88e5a9a7 100644 --- a/src/core/components/core-tooltip.tsx +++ b/src/core/components/core-tooltip.tsx @@ -16,7 +16,7 @@ import { ChartAPI } from "../chart-api"; import { getFormatter, numberFormatter } from "../formatters"; import { BaseI18nStrings, CoreChartProps } from "../interfaces"; import { - BubbleOptions, + getBubbleSeriesSizeAxis, getPointColor, getPointId, getSeriesColor, @@ -308,7 +308,7 @@ function getTooltipContentPie( }; } -function getBubblePointDetails(item: MatchedItem, sizeAxis?: readonly CoreChartProps.SizeAxisOptions[]) { +function getBubblePointDetails(item: MatchedItem, sizeAxis: readonly CoreChartProps.SizeAxisOptions[] = []) { const subItems: { key: React.ReactNode; value: React.ReactNode }[] = []; const y = item.point.y; @@ -316,20 +316,17 @@ function getBubblePointDetails(item: MatchedItem, sizeAxis?: readonly CoreChartP const yFormatter = item.point.series.yAxis ? getFormatter(item.point.series.yAxis) : (v: unknown) => String(v); subItems.push({ key: yAxisTitle, value: yFormatter(y) }); - // maps Highcharts' `z` prop (bubble size) to our custom size axis for title/formatter support, - // which Highcharts doesn't provide for bubble series by default. - const z = item.point.options.z ?? null; - const matchedSizeAxis = - sizeAxis?.find((a) => { - const custom = item.point.series.options.custom as BubbleOptions["custom"]; - return a.id === custom?.awsui?.sizeAxis; - }) ?? sizeAxis?.[0]; + // Size axis is a custom abstraction built to support bubble series title and formatter for z (size) values. + // We match size axes by ID if provided, or take the first defined axis instead. + const matchedSizeAxis = sizeAxis.find((a) => a.id === getBubbleSeriesSizeAxis(item.point.series)) ?? sizeAxis[0]; if (matchedSizeAxis) { - const sizeValue = item.point.options.z; - subItems.push({ - key: matchedSizeAxis.title, - value: matchedSizeAxis.valueFormatter?.(z) ?? numberFormatter(sizeValue ?? 0), - }); + // Highcharts bubble size is represented by point.z value, which is however not present in the internal point type - + // so we take it from point's options instead. + const size = item.point.options.z ?? null; + const sizeAxisTitle = matchedSizeAxis.title; + const defaultFormatter = (value: null | number) => (typeof value === "number" ? numberFormatter(value) : ""); + const sizeFormatter = matchedSizeAxis.valueFormatter ?? defaultFormatter; + subItems.push({ key: sizeAxisTitle, value: sizeFormatter(size) }); } return subItems; diff --git a/src/core/utils.ts b/src/core/utils.ts index a0e01b39..910b64cb 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -73,6 +73,9 @@ export function isYThreshold( return typeof s.options.custom === "object" && s.options.custom.awsui?.type === "y-threshold"; } +// We extend bubble series by introducing sizeAxis - which is used to define title and formatter for bubble series size values. +// In case there are multiple such axes - the bubble series can target a specific one by setting sizeAxis to the respective size +// axis id. As there is no such prop in Highcharts series type - we pass it using the custom options object. export interface BubbleOptions { custom: { awsui: { @@ -81,9 +84,12 @@ export interface BubbleOptions { }; } export function createBubbleMetadata(options: { sizeAxis?: string }): BubbleOptions { - // The custom.awsui.sizeAxis is our way of extending Highcharts' bubble series options type in order to propagate an extra prop that connects bubble series with size axes. return { custom: { awsui: options } }; } +export function getBubbleSeriesSizeAxis(series: Highcharts.Series): undefined | string { + const { custom } = series.options as Highcharts.SeriesOptionsType & BubbleOptions; + return custom?.awsui?.sizeAxis; +} // We check point.series explicitly because Highcharts can destroy point objects, replacing the // contents with { destroyed: true }, violating the point's TS contract. From 66f9f98522f9dc4beda6a69a1dff519d766f975d Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Fri, 27 Mar 2026 17:45:04 +0100 Subject: [PATCH 11/11] more refactoring --- pages/01-cartesian-chart/bubble-chart.page.tsx | 6 +----- .../__tests__/cartesian-chart-tooltip.test.tsx | 3 +-- src/core/components/core-tooltip.tsx | 5 ++--- src/core/formatters.tsx | 8 ++++++-- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/pages/01-cartesian-chart/bubble-chart.page.tsx b/pages/01-cartesian-chart/bubble-chart.page.tsx index 79b90aa8..79cce74a 100644 --- a/pages/01-cartesian-chart/bubble-chart.page.tsx +++ b/pages/01-cartesian-chart/bubble-chart.page.tsx @@ -49,11 +49,7 @@ export default function () { yAxis={{ title: "Events" }} sizeAxis={[ { id: "time-axis", title: "Time to fix", valueFormatter: (value) => `${value! * timeScale} minutes` }, - { - id: "cost-axis", - title: "Cost impact", - valueFormatter: (value) => moneyFormatter(value! * costScale), - }, + { id: "cost-axis", title: "Cost impact", valueFormatter: (value) => moneyFormatter(value! * costScale) }, ]} chartHeight={400} /> diff --git a/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx b/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx index 8b96bd4f..3a5f5e0d 100644 --- a/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx +++ b/src/cartesian-chart/__tests__/cartesian-chart-tooltip.test.tsx @@ -67,7 +67,7 @@ describe("CartesianChart: tooltip", () => { { type: "line", name: "\nLine", data: [{ x, y: 6 }] }, { type: "scatter", name: "\nScatter", data: [{ x, y: 7 }] }, { type: "spline", name: "\nSpline", data: [{ x, y: 8 }] }, - { type: "bubble", name: "\nBubble", data: [{ x, y: 9, z: 5 }] }, + { type: "bubble", name: "\nBubble", data: [{ x, y: 9, size: 5 }] }, { type: "x-threshold", name: "\nX threshold", value: x }, { type: "y-threshold", name: "\nY threshold", value: 10 }, ], @@ -384,7 +384,6 @@ describe("CartesianChart: bubble tooltip", () => { await waitFor(() => { expect(getTooltip()).not.toBe(null); const series = getTooltipSeries(0); - expect(series.findValue().getElement().textContent).toBe(""); expect(series.findSubItems()).toHaveLength(2); expect(series.findSubItems()[0].findKey().getElement().textContent).toBe("Events"); expect(series.findSubItems()[0].findValue().getElement().textContent).toBe("9"); diff --git a/src/core/components/core-tooltip.tsx b/src/core/components/core-tooltip.tsx index 88e5a9a7..f2df00fe 100644 --- a/src/core/components/core-tooltip.tsx +++ b/src/core/components/core-tooltip.tsx @@ -13,7 +13,7 @@ import { useSelector } from "../../internal/utils/async-store"; import { getChartSeries } from "../../internal/utils/chart-series"; import { useDebouncedValue } from "../../internal/utils/use-debounced-value"; import { ChartAPI } from "../chart-api"; -import { getFormatter, numberFormatter } from "../formatters"; +import { getFormatter } from "../formatters"; import { BaseI18nStrings, CoreChartProps } from "../interfaces"; import { getBubbleSeriesSizeAxis, @@ -324,8 +324,7 @@ function getBubblePointDetails(item: MatchedItem, sizeAxis: readonly CoreChartPr // so we take it from point's options instead. const size = item.point.options.z ?? null; const sizeAxisTitle = matchedSizeAxis.title; - const defaultFormatter = (value: null | number) => (typeof value === "number" ? numberFormatter(value) : ""); - const sizeFormatter = matchedSizeAxis.valueFormatter ?? defaultFormatter; + const sizeFormatter = getFormatter(matchedSizeAxis); subItems.push({ key: sizeAxisTitle, value: sizeFormatter(size) }); } diff --git a/src/core/formatters.tsx b/src/core/formatters.tsx index 08704ff4..318c0e9e 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(axis?: Highcharts.Axis | CoreChartProps.SizeAxisOptions) { return (value: unknown): string => { if (typeof value === "string") { return value; @@ -18,6 +18,10 @@ export function getFormatter(axis?: Highcharts.Axis) { if (!axis) { return `${value}`; } + // Handle non-Highcharts size axes. + if (!("options" in axis)) { + return (axis.valueFormatter ?? numberFormatter)(value); + } if (axis.options.type === "category") { return axis.categories?.[value] ?? value.toString(); } @@ -102,7 +106,7 @@ function secondFormatter(value: number) { }); } -export function numberFormatter(value: number): string { +function numberFormatter(value: number): string { const format = (num: number) => parseFloat(num.toFixed(2)).toString(); // trims unnecessary decimals const absValue = Math.abs(value);