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..79cce74a
--- /dev/null
+++ b/pages/01-cartesian-chart/bubble-chart.page.tsx
@@ -0,0 +1,58 @@
+// 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 (
-
-
-
- );
-}
diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap
index 7d572bdd..4e39b559 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\`).
@@ -323,6 +324,25 @@ Supported types:
"optional": false,
"type": "ReadonlyArray",
},
+ {
+ "description": "One or multiple axes to provide size details formatting for bubble series.
+
+Supported options:
+* \`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",
+ "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.
@@ -1779,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-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..3a5f5e0d 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, size: 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`,
);
},
);
@@ -297,3 +298,134 @@ 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");
+ });
+ });
+
+ 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%");
+ });
+ });
+});
diff --git a/src/cartesian-chart/chart-cartesian-internal.tsx b/src/cartesian-chart/chart-cartesian-internal.tsx
index be4ff121..5fd2b284 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),
+ size: item.point.options.z ?? undefined,
series,
errorRanges: item.errorRanges.map((point) => ({
low: point.options.low ?? 0,
@@ -82,11 +83,12 @@ export const InternalCartesianChart = forwardRef(
x: props.x,
items: props.items.map(transformItem),
});
+
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)) : 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,
};
};
@@ -122,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 a7ccd09e..f68da55c 100644
--- a/src/cartesian-chart/chart-series-cartesian.tsx
+++ b/src/cartesian-chart/chart-series-cartesian.tsx
@@ -6,7 +6,7 @@ import type Highcharts from "highcharts";
import { colorChartsErrorBarMarker } from "@cloudscape-design/design-tokens";
import { PointDataItemType, RangeDataItemOptions } from "../core/interfaces";
-import { createThresholdMetadata, getOptionsId } from "../core/utils";
+import { createBubbleMetadata, createThresholdMetadata, getOptionsId } from "../core/utils";
import * as Styles from "../internal/chart-styles";
import { Writeable } from "../internal/utils/utils";
import { CartesianChartProps } from "./interfaces";
@@ -75,6 +75,10 @@ export const transformCartesianSeries = (
const colors = { stemColor: color, whiskerColor: color };
return { ...s, data: s.data as Writeable, ...colors };
}
+ if (s.type === "bubble") {
+ 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) };
}
const series = originalSeries.map(transformSeriesToHighcharts);
diff --git a/src/cartesian-chart/index.tsx b/src/cartesian-chart/index.tsx
index 011ff3ef..fa97de67 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 } 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)}
@@ -83,6 +85,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 +167,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, sizeAxis: s.sizeAxis } as S;
+}
+
function transformThresholdSeries<
S extends CartesianChartProps.XThresholdSeriesOptions | CartesianChartProps.YThresholdSeriesOptions,
>(s: S): null | S {
@@ -199,6 +214,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 SizePointDataItemOptions[]): readonly SizePointDataItemOptions[] {
+ return data.map((d) => ({ x: d.x, y: d.y, size: d.size }));
+}
+
function transformRangeData(data: readonly RangeDataItemOptions[]): readonly RangeDataItemOptions[] {
return data.map((d) => ({ x: d.x, low: d.low, high: d.high }));
}
@@ -211,6 +230,16 @@ function transformYAxisOptions(axis?: CartesianChartProps.YAxisOptions): Cartesi
return { ...transformAxisOptions(axis), reversedStacks: axis?.reversedStacks };
}
+function transformSizeAxisOptions(
+ axis?: CartesianChartProps.SizeAxisOptions | readonly CartesianChartProps.SizeAxisOptions[],
+): undefined | CartesianChartProps.SizeAxisOptions[] {
+ 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 79b32313..3db7bff5 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`).
@@ -94,6 +95,16 @@ export interface CartesianChartProps
*/
yAxis?: CartesianChartProps.YAxisOptions;
+ /**
+ * One or multiple axes to provide size details formatting for bubble series.
+ *
+ * Supported options:
+ * * `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[];
+
/**
* 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`.
@@ -122,6 +133,7 @@ export namespace CartesianChartProps {
export type SeriesOptions =
| AreaSeriesOptions
| AreaSplineSeriesOptions
+ | BubbleSeriesOptions
| ColumnSeriesOptions
| ErrorBarSeriesOptions
| LineSeriesOptions
@@ -132,6 +144,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;
@@ -150,6 +163,12 @@ export namespace CartesianChartProps {
valueFormatter?: (value: null | number) => string;
}
+ export interface SizeAxisOptions {
+ id?: string;
+ title: string;
+ valueFormatter?: (value: null | number) => string;
+ }
+
export type XAxisOptions = AxisOptions;
export interface YAxisOptions extends AxisOptions {
@@ -178,6 +197,7 @@ export namespace CartesianChartProps {
export interface TooltipPointItem {
x: number;
y: number | null;
+ size?: 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/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..f2df00fe 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 {
+ getBubbleSeriesSizeAxis,
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,29 @@ function getTooltipContentPie(
};
}
+function getBubblePointDetails(item: MatchedItem, sizeAxis: readonly CoreChartProps.SizeAxisOptions[] = []) {
+ 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);
+ subItems.push({ key: yAxisTitle, value: yFormatter(y) });
+
+ // 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) {
+ // 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 sizeFormatter = getFormatter(matchedSizeAxis);
+ subItems.push({ key: sizeAxisTitle, value: sizeFormatter(size) });
+ }
+
+ return subItems;
+}
+
function findTooltipSeriesItems(
series: readonly Highcharts.Series[],
group: readonly Highcharts.Point[],
diff --git a/src/core/formatters.tsx b/src/core/formatters.tsx
index 91656d2e..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();
}
diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts
index b2319ab0..48aada74 100644
--- a/src/core/interfaces.ts
+++ b/src/core/interfaces.ts
@@ -237,6 +237,12 @@ export interface ScatterSeriesOptions extends BaseCartesianSeriesOptions, Linkab
marker?: PointMarkerOptions;
}
+export interface BubbleSeriesOptions extends BaseCartesianSeriesOptions, LinkableSeries {
+ type: "bubble";
+ sizeAxis?: string;
+ data: readonly SizePointDataItemOptions[];
+}
+
export interface ErrorBarSeriesOptions extends Omit {
type: "errorbar";
name?: string;
@@ -278,6 +284,12 @@ export interface PointDataItemOptions {
y: number | null;
}
+export interface SizePointDataItemOptions {
+ x?: number;
+ y: number | null;
+ size: number;
+}
+
export interface RangeDataItemOptions {
x?: number;
low: number;
@@ -420,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 {
@@ -449,6 +463,12 @@ export namespace CoreChartProps {
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 d994f9ae..910b64cb 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -73,6 +73,24 @@ 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: {
+ sizeAxis?: string;
+ };
+ };
+}
+export function createBubbleMetadata(options: { sizeAxis?: string }): BubbleOptions {
+ 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.
// See: https://github.com/highcharts/highcharts/issues/23175.
@@ -114,6 +132,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-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 {
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 };
}