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 }; }