From 3b066deee1632141d1bed8aeaa87a19010186425 Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Fri, 15 May 2026 10:22:04 +0200 Subject: [PATCH] feat: Zoned series in cartesian chart --- .../01-cartesian-chart/linked-series.page.tsx | 148 +++++------------- .../01-cartesian-chart/zoned-series.page.tsx | 90 +++++++++++ .../chart-series-cartesian.tsx | 4 +- src/cartesian-chart/index.tsx | 20 ++- src/cartesian-chart/interfaces.ts | 2 + .../chart-core-linked-series.test.tsx | 59 +------ .../chart-core-zoned-series.test.tsx | 128 +++++++++++++++ src/core/chart-api/chart-extra-context.tsx | 22 +-- src/core/components/core-tooltip.tsx | 12 +- src/core/interfaces.ts | 21 ++- src/core/utils.ts | 48 ++++-- src/internal/utils/highcharts.ts | 24 ++- 12 files changed, 372 insertions(+), 206 deletions(-) create mode 100644 pages/01-cartesian-chart/zoned-series.page.tsx create mode 100644 src/core/__tests__/chart-core-zoned-series.test.tsx diff --git a/pages/01-cartesian-chart/linked-series.page.tsx b/pages/01-cartesian-chart/linked-series.page.tsx index d4fd3484..e1cd768a 100644 --- a/pages/01-cartesian-chart/linked-series.page.tsx +++ b/pages/01-cartesian-chart/linked-series.page.tsx @@ -1,129 +1,63 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { colorChartsPaletteCategorical5 } from "@cloudscape-design/design-tokens"; - import { CartesianChart } from "../../lib/components"; import { useChartSettings } from "../common/page-settings"; import { Page } from "../common/templates"; +import pseudoRandom from "../utils/pseudo-random"; + +const data = [ + { 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: 1601003700000, y: 231902 }, + { x: 1601004600000, y: 224558 }, +]; + +const dataProjected = data.map(({ x, y }) => ({ x, y: y + pseudoRandom() * 50000 })); export default function () { const { chartProps } = useChartSettings(); return ( { + const seriesName = item.series.name; + return { key: seriesName === "Site 1" ? "Site 1 avg" : seriesName }; + }, + }} /> ); diff --git a/pages/01-cartesian-chart/zoned-series.page.tsx b/pages/01-cartesian-chart/zoned-series.page.tsx new file mode 100644 index 00000000..676dd6b8 --- /dev/null +++ b/pages/01-cartesian-chart/zoned-series.page.tsx @@ -0,0 +1,90 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import SpaceBetween from "@cloudscape-design/components/space-between"; +import { colorChartsThresholdNegative, colorChartsThresholdNeutral } from "@cloudscape-design/design-tokens"; + +import { CartesianChart, CartesianChartProps } from "../../lib/components"; +import { useChartSettings } from "../common/page-settings"; +import { Page } from "../common/templates"; +import pseudoRandom from "../utils/pseudo-random"; + +const data1 = [ + { 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: 1601003700000, y: 231902 }, + { x: 1601004600000, y: 364558 }, + { x: 1601005500000, y: 413901 }, + { x: 1601006400000, y: 432839 }, + { x: 1601007300000, y: 464943 }, + { x: 1601008200000, y: 464405 }, + { x: 1601009100000, y: 490391 }, + { x: 1601010000000, y: 513570 }, + { x: 1601010900000, y: 552592 }, + { x: 1601011800000, y: 538910 }, + { x: 1601012700000, y: 599492 }, + { x: 1601013600000, y: 643910 }, +]; +const data2 = data1.map(({ x, y }) => ({ x, y: y + 10_000 + pseudoRandom() * 100_000 })); + +export default function () { + const { chartProps } = useChartSettings(); + + const xyzZones: CartesianChartProps.SeriesZone[] = [ + { value: 1601004100000, dashStyle: "Solid" }, + { value: undefined, dashStyle: "Dash" }, + ]; + + const performanceZones: CartesianChartProps.SeriesZone[] = [ + { value: 250000, color: colorChartsThresholdNegative, dashStyle: "Dash" }, + { value: undefined, dashStyle: "Solid" }, + ]; + + return ( + + + + + + + ); +} diff --git a/src/cartesian-chart/chart-series-cartesian.tsx b/src/cartesian-chart/chart-series-cartesian.tsx index dc064596..e53dd16a 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 { RangeDataItemOptions } from "../core/interfaces"; import { createBubbleMetadata, createThresholdMetadata, getOptionsId } from "../core/utils"; import * as Styles from "../internal/chart-styles"; import { Writeable } from "../internal/utils/utils"; @@ -90,7 +90,7 @@ export const transformCartesianSeries = ( 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) }; + return { ...s, data: s.data, ...shared, ...getColorProps(s) } as Highcharts.SeriesOptionsType; } const series = originalSeries.map(transformSeriesToHighcharts); // We inject a fake empty series so that the empty state still shows axes, if defined. diff --git a/src/cartesian-chart/index.tsx b/src/cartesian-chart/index.tsx index 6b7b52d7..cb20b301 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, SizePointDataItemOptions } from "../core/interfaces"; +import { PointDataItemType, RangeDataItemOptions, SizePointDataItemOptions, WithZones } 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"; @@ -141,6 +141,7 @@ function transformLineLikeSeries< dashStyle: s.dashStyle, yAxis: s.yAxis, linkedTo: s.linkedTo, + ...transformZones(s), data, } as S; } @@ -153,7 +154,16 @@ function transformColumnSeries( @@ -251,6 +261,12 @@ function transformRangeData(data: readonly RangeDataItemOptions[]): readonly Ran return data.map((d) => ({ x: d.x, low: d.low, high: d.high })); } +function transformZones(series: WithZones) { + const zoneAxis = series.zoneAxis === "x" || series.zoneAxis === "y" ? series.zoneAxis : undefined; + const zones = series.zones?.map(({ value, color, dashStyle }) => ({ value, color, dashStyle })); + return { zoneAxis, zones }; +} + function transformXAxisOptions(axis?: CartesianChartProps.XAxisOptions): CartesianChartProps.XAxisOptions { return transformAxisOptions(axis); } diff --git a/src/cartesian-chart/interfaces.ts b/src/cartesian-chart/interfaces.ts index 9a0d5cfa..e5203545 100644 --- a/src/cartesian-chart/interfaces.ts +++ b/src/cartesian-chart/interfaces.ts @@ -212,6 +212,8 @@ export namespace CartesianChartProps { } export type TooltipPointFormatted = CoreTypes.BaseTooltipPointFormatted; + export type SeriesZone = CoreTypes.SeriesZone; + export type LegendOptions = CoreTypes.BaseLegendOptions; export type FilterOptions = CoreTypes.BaseFilterOptions; diff --git a/src/core/__tests__/chart-core-linked-series.test.tsx b/src/core/__tests__/chart-core-linked-series.test.tsx index 31ed4ef4..e6f9309f 100644 --- a/src/core/__tests__/chart-core-linked-series.test.tsx +++ b/src/core/__tests__/chart-core-linked-series.test.tsx @@ -199,51 +199,7 @@ describe("CoreChart: linked series — keyboard navigation", () => { expect(describeFocusedElement()).toContain("Primary"); }); - // Series layout for partial-overlap test: - // Primary: x=1,2 | Projected (linked): x=2,3 — x=2 is shared (primary wins), x=3 is linked-only - const seriesPartialOverlap: Highcharts.SeriesOptionsType[] = [ - { - type: "line", - id: "p", - name: "Primary", - data: [ - { x: 1, y: 10 }, - { x: 2, y: 20 }, - ], - }, - { - type: "line", - id: "proj", - name: "Projected", - data: [ - { x: 2, y: 25 }, - { x: 3, y: 35 }, - ], - linkedTo: "p", - showInLegend: false, - }, - ]; - - test("primary wins at shared X; linked-only X values remain navigable", () => { - renderChart({ highcharts, options: { series: seriesPartialOverlap }, ariaLabel: "Test chart" }); - - focusApplication(); - keyDown(KeyCode.home); // group at x=1 - keyDown(KeyCode.down); // enter point — Primary x=1 - - expect(describeFocusedElement()).toContain("Primary"); - - keyDown(KeyCode.right); // Primary x=2 (wins over Projected x=2) - expect(describeFocusedElement()).toContain("Primary"); - - keyDown(KeyCode.right); // Projected x=3 (linked-only, no primary point here) - expect(describeFocusedElement()).toContain("Projected"); - - keyDown(KeyCode.right); // wraps back to Primary x=1 - expect(describeFocusedElement()).toContain("Primary"); - }); - - test("linked series point is reachable from group navigation and can navigate back to master", () => { + test("linked series are navigable independently from master", () => { renderChart({ highcharts, options: { series }, ariaLabel: "Test chart" }); focusApplication(); @@ -252,14 +208,15 @@ describe("CoreChart: linked series — keyboard navigation", () => { expect(describeFocusedElement()).toContain("Other"); - // Navigate up within group to reach Projected, then Primary + // Navigate up within group to reach Projected keyDown(KeyCode.up); // Projected x=1 expect(describeFocusedElement()).toContain("Projected"); - // From Projected, navigate backwards in series — should reach Primary - keyDown(KeyCode.left); // wraps to last in family: Projected x=2 - keyDown(KeyCode.left); // Projected x=1 - keyDown(KeyCode.left); // Primary x=2 - expect(describeFocusedElement()).toContain("Primary"); + // Linked series are navigated separately — left/right stays within Projected + keyDown(KeyCode.right); // Projected x=2 + expect(describeFocusedElement()).toContain("Projected"); + + keyDown(KeyCode.right); // wraps back to Projected x=1 + expect(describeFocusedElement()).toContain("Projected"); }); }); diff --git a/src/core/__tests__/chart-core-zoned-series.test.tsx b/src/core/__tests__/chart-core-zoned-series.test.tsx new file mode 100644 index 00000000..4d63c57f --- /dev/null +++ b/src/core/__tests__/chart-core-zoned-series.test.tsx @@ -0,0 +1,128 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { act } from "react"; +import highcharts from "highcharts"; +import { vi } from "vitest"; + +import "highcharts/highcharts-more"; +import "highcharts/modules/accessibility"; +import markerStyles from "../../../lib/components/internal/components/series-marker/styles.selectors"; +import { createChartWrapper, renderChart } from "./common"; +import { HighchartsTestHelper } from "./highcharts-utils"; + +const hc = new HighchartsTestHelper(highcharts); + +beforeAll(() => { + vi.useFakeTimers(); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +function getTooltipMarker(itemIndex: number) { + const wrapper = createChartWrapper(); + const body = wrapper.findTooltip()!.findBody()!.getElement(); + const markers = body.querySelectorAll(`.${markerStyles.marker}`); + return markers[itemIndex] as HTMLElement; +} + +function getMarkerFillColor(marker: HTMLElement): string { + const svg = marker.querySelector("svg")!; + const filled = svg.querySelector("rect[fill]:not([fill='white']):not([fill='black'])") as SVGElement; + return filled?.getAttribute("fill") ?? ""; +} + +function isDashedMarker(marker: HTMLElement): boolean { + const svg = marker.querySelector("svg")!; + // Dashed marker has two rects (excluding mask rects) + const rects = svg.querySelectorAll("rect:not([fill='white']):not([fill='black'])"); + return rects.length === 2; +} + +describe("CoreChart: zoned series — tooltip marker", () => { + const seriesWithZones: Highcharts.SeriesOptionsType[] = [ + { + type: "line", + id: "s1", + name: "Series 1", + color: "blue", + dashStyle: "Solid", + data: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + { x: 3, y: 30 }, + ], + zoneAxis: "x", + zones: [ + { value: 2, color: "red", dashStyle: "Dash" }, + { color: "green" }, // no dashStyle — should fallback to series dashStyle (Solid) + ], + }, + ]; + + test("tooltip marker uses zone color when point is in a zone", () => { + renderChart({ highcharts, options: { series: seriesWithZones } }); + + // Point at x=1 is in the first zone (value < 2), zone color is red + act(() => hc.highlightChartPoint(0, 0)); + + const marker = getTooltipMarker(0); + expect(getMarkerFillColor(marker)).toBe("red"); + }); + + test("tooltip marker uses zone dashStyle when zone defines dashStyle", () => { + renderChart({ highcharts, options: { series: seriesWithZones } }); + + // Point at x=1 is in the first zone which has dashStyle: "Dash" + act(() => hc.highlightChartPoint(0, 0)); + + const marker = getTooltipMarker(0); + expect(isDashedMarker(marker)).toBe(true); + }); + + test("tooltip marker falls back to series color when zone has no color", () => { + const seriesNoZoneColor: Highcharts.SeriesOptionsType[] = [ + { + type: "line", + id: "s1", + name: "Series 1", + color: "blue", + data: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + ], + zoneAxis: "x", + zones: [{ value: 2 }], // no color, no dashStyle + }, + ]; + + renderChart({ highcharts, options: { series: seriesNoZoneColor } }); + + act(() => hc.highlightChartPoint(0, 0)); + + const marker = getTooltipMarker(0); + expect(getMarkerFillColor(marker)).toBe("blue"); + }); + + test("tooltip marker falls back to series dashStyle when zone has no dashStyle", () => { + renderChart({ highcharts, options: { series: seriesWithZones } }); + + // Point at x=3 is in the second zone which has no dashStyle — series dashStyle is "Solid" + act(() => hc.highlightChartPoint(0, 2)); + + const marker = getTooltipMarker(0); + expect(isDashedMarker(marker)).toBe(false); + }); + + test("tooltip marker uses zone color for second zone", () => { + renderChart({ highcharts, options: { series: seriesWithZones } }); + + // Point at x=3 is in the second zone, zone color is green + act(() => hc.highlightChartPoint(0, 2)); + + const marker = getTooltipMarker(0); + expect(getMarkerFillColor(marker)).toBe("green"); + }); +}); diff --git a/src/core/chart-api/chart-extra-context.tsx b/src/core/chart-api/chart-extra-context.tsx index b0da6267..de1a10c5 100644 --- a/src/core/chart-api/chart-extra-context.tsx +++ b/src/core/chart-api/chart-extra-context.tsx @@ -120,30 +120,18 @@ function computeDerivedState(chart: SafeChart): ChartExtraContext.DerivedState { } const allX = Array.from(allXSet).sort(compareX); - // For each series, compute the ordered list of points to navigate through. For a linked family - // (primary + its linked children), all members share the same flat list: points sorted by X, - // with family members interleaved in series order within the same X value. const seriesPointsMap = new WeakMap(); for (const s of getChartSeries(chart)) { - if (seriesPointsMap.has(s)) { - continue; // Already computed as part of a family. - } - const master = s.linkedParent ?? s; - const family = [master, ...master.linkedSeries]; - const allFamilyPoints = family - .flatMap((m) => allPointsInSeries.get(m) ?? []) - .sort(({ x: x1 }, { x: x2 }) => compareX(x1, x2)); + const allSeriesPoints = (allPointsInSeries.get(s) ?? []).sort(({ x: x1 }, { x: x2 }) => compareX(x1, x2)); const usedXSet = new Set(); - const familyPoints = new Array(); - for (const point of allFamilyPoints) { + const navigableSeriesPoints = new Array(); + for (const point of allSeriesPoints) { if (!usedXSet.has(point.x)) { - familyPoints.push(point); + navigableSeriesPoints.push(point); usedXSet.add(point.x); // We ignore points that share X coordinate. } } - for (const member of family) { - seriesPointsMap.set(member, familyPoints); - } + seriesPointsMap.set(s, navigableSeriesPoints); } return { allX, diff --git a/src/core/components/core-tooltip.tsx b/src/core/components/core-tooltip.tsx index 14bcf6fc..8b2de2d8 100644 --- a/src/core/components/core-tooltip.tsx +++ b/src/core/components/core-tooltip.tsx @@ -18,7 +18,7 @@ import { BaseI18nStrings, CoreChartProps } from "../interfaces"; import { getPointColor, getPointId, - getSeriesColor, + getPointMarkerType, getSeriesId, getSeriesMarkerType, isXThreshold, @@ -180,11 +180,11 @@ function getTooltipContentCartesian( // By design, every point of the group has the same x value. const x = group[0].x; const chart = group[0].series.chart; - const getSeriesMarker = (series: SafeSeries) => { - const itemOptions = api.context.settings.getItemOptions?.(getSeriesId(series)); + const getPointMarker = (point: Highcharts.Point) => { + const itemOptions = api.context.settings.getItemOptions?.(getSeriesId(point.series)); return api.renderMarker({ - type: getSeriesMarkerType(series), - color: getSeriesColor(series), + type: getPointMarkerType(point), + color: getPointColor(point), visible: true, status: itemOptions?.status, ariaLabel: api.context.settings.labels.itemMarkerLabel?.(itemOptions?.status), @@ -205,7 +205,7 @@ function getTooltipContentCartesian( return { key: customContent?.key ?? item.point.series.name, value: customContent?.value ?? defaultValue, - marker: getSeriesMarker(item.point.series), + marker: getPointMarker(item.point), subItems: customContent?.subItems ?? bubbleSubItems, expandableId: customContent?.expandable ? item.point.series.name : undefined, highlighted: item.point.x === point?.x && item.point.y === point?.y, diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index df13ce71..729c011c 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -206,27 +206,27 @@ export interface BaseTooltipPointFormatted { subItems?: ReadonlyArray<{ key: React.ReactNode; value: React.ReactNode }>; } -export interface AreaSeriesOptions extends BaseCartesianLineLikeOptions, LinkableSeries { +export interface AreaSeriesOptions extends BaseCartesianLineLikeOptions, LinkableSeries, WithZones { type: "area"; data: readonly PointDataItemType[]; } -export interface AreaSplineSeriesOptions extends BaseCartesianLineLikeOptions, LinkableSeries { +export interface AreaSplineSeriesOptions extends BaseCartesianLineLikeOptions, LinkableSeries, WithZones { type: "areaspline"; data: readonly PointDataItemType[]; } -export interface ColumnSeriesOptions extends BaseCartesianSeriesOptions, LinkableSeries { +export interface ColumnSeriesOptions extends BaseCartesianSeriesOptions, LinkableSeries, WithZones { type: "column"; data: readonly PointDataItemType[]; } -export interface LineSeriesOptions extends BaseCartesianLineLikeOptions, LinkableSeries { +export interface LineSeriesOptions extends BaseCartesianLineLikeOptions, LinkableSeries, WithZones { type: "line"; data: readonly PointDataItemType[]; } -export interface SplineSeriesOptions extends BaseCartesianLineLikeOptions, LinkableSeries { +export interface SplineSeriesOptions extends BaseCartesianLineLikeOptions, LinkableSeries, WithZones { type: "spline"; data: readonly PointDataItemType[]; } @@ -319,6 +319,17 @@ export interface LinkableSeries { linkedTo?: string; } +export interface WithZones { + zoneAxis?: "x" | "y"; + zones?: readonly SeriesZone[]; +} + +export interface SeriesZone { + value?: number; + color?: string; + dashStyle?: Highcharts.DashStyleValue; +} + interface BaseCartesianLineLikeOptions extends BaseCartesianSeriesOptions { dashStyle?: Highcharts.DashStyleValue; } diff --git a/src/core/utils.ts b/src/core/utils.ts index 6fcc7358..4c10b447 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -5,7 +5,14 @@ import type Highcharts from "highcharts"; import { ChartSeriesMarkerType } from "../internal/components/series-marker"; import { ChartSeriesMarkerStatus } from "../internal/components/series-marker/interfaces"; -import { getChartSeries, getSeriesData, SafeChart, SafeSeries } from "../internal/utils/highcharts"; +import { + getChartSeries, + getPointZone, + getSeriesData, + SafeChart, + SafePoint, + SafeSeries, +} from "../internal/utils/highcharts"; import { castArray } from "../internal/utils/utils"; import { getFormatter } from "./formatters"; import { ChartLabels } from "./i18n-utils"; @@ -28,7 +35,7 @@ export interface LegendItemSpec { export function getSeriesId(series: SafeSeries): string { return getOptionsId(series.options); } -export function getPointId(point: Highcharts.Point): string { +export function getPointId(point: SafePoint): string { return getOptionsId(point.options); } export function getOptionsId(options: { id?: string; name?: string }): string { @@ -87,14 +94,22 @@ export function getBubbleSeriesSizeAxis(series: SafeSeries): undefined | string return custom?.awsui?.sizeAxis; } -export function getSeriesMarkerType(series?: SafeSeries): ChartSeriesMarkerType { +export function getPointMarkerType(point?: SafePoint): ChartSeriesMarkerType { + const pointZone = point && getPointZone(point); + return getSeriesMarkerType(point?.series, pointZone?.dashStyle); +} + +export function getSeriesMarkerType(series?: SafeSeries, dashStyle?: Highcharts.DashStyleValue): ChartSeriesMarkerType { if (!series) { return "large-square"; } const seriesSymbol = "symbol" in series && typeof series.symbol === "string" ? series.symbol : "circle"; // In Highcharts, dashStyle supports different types of dashes: https://jsfiddle.net/gh/get/library/pure/highcharts/highcharts/tree/master/samples/highcharts/plotoptions/series-dashstyle-all/ // Return a dashed legend symbol for all of these dashes, excluding the default "Solid" option - if ("dashStyle" in series.options && series.options.dashStyle && series.options.dashStyle !== "Solid") { + if ( + (dashStyle && dashStyle !== "Solid") || + ("dashStyle" in series.options && series.options.dashStyle && series.options.dashStyle !== "Solid") + ) { return "dashed"; } switch (series.type) { @@ -134,8 +149,11 @@ export function getSeriesMarkerType(series?: SafeSeries): ChartSeriesMarkerType export function getSeriesColor(series?: SafeSeries): string { return typeof series?.color === "string" ? series.color : "black"; } -export function getPointColor(point?: Highcharts.Point): string { - return typeof point?.color === "string" ? point.color : "black"; +export function getPointColor(point?: SafePoint): string { + const pointZone = point && getPointZone(point); + const zoneColor = typeof pointZone?.color === "string" ? pointZone.color : undefined; + const pointColor = typeof point?.color === "string" ? point.color : undefined; + return zoneColor ?? pointColor ?? "black"; } // The custom legend implementation does not rely on the Highcharts legend. When Highcharts legend is disabled, @@ -183,7 +201,7 @@ export function getChartLegendItems({ }); } }; - const addPointItem = (point: Highcharts.Point, isSecondary: boolean) => { + const addPointItem = (point: SafePoint, isSecondary: boolean) => { if (point?.series?.type === "pie") { const pointId = getPointId(point); legendItems.push({ @@ -306,7 +324,7 @@ export function getLegendsProps( // This function returns coordinates of a rectangle, including the target point. // There are differences in how the rectangle is computed, but in all cases it is supposed to // enclose the point's visual representation in the chart, with no extra offsets. -export function getPointRect(point: Highcharts.Point): Rect { +export function getPointRect(point: SafePoint): Rect { if (!point.series) { return { x: 0, y: 0, width: 0, height: 0 }; } @@ -324,7 +342,7 @@ export function getPointRect(point: Highcharts.Point): Rect { // The group rect is only used for cartesian charts. It returns coordinates of a rectangle, // which includes all given points, but also stretched vertically or horizontally (in inverted charts) // to the entire chart's height or width. -export function getGroupRect(points: readonly Highcharts.Point[]): Rect { +export function getGroupRect(points: readonly SafePoint[]): Rect { if (points.length === 0 || !points[0].series) { return { x: 0, y: 0, width: 0, height: 0 }; } @@ -362,7 +380,7 @@ export function matchSizeAxis(sizeAxis: readonly CoreChartProps.SizeAxisOptions[ } export function getPointAccessibleDescription( - point: Highcharts.Point, + point: SafePoint, labels: ChartLabels, additionalProps?: { sizeAxis?: readonly CoreChartProps.SizeAxisOptions[]; @@ -387,14 +405,14 @@ export function getPointAccessibleDescription( } } -export function getGroupAccessibleDescription(group: readonly Highcharts.Point[]) { +export function getGroupAccessibleDescription(group: readonly SafePoint[]) { const firstPoint = group[0]; return getFormatter(firstPoint.series.xAxis)(firstPoint.x); } // The area-, line-, or scatter series markers are rendered as single graphic elements, // and we can use their respective b-boxes to compute rects. -function getDefaultPointRect(point: Highcharts.Point): Rect { +function getDefaultPointRect(point: SafePoint): Rect { const chart = point.series.chart; if (point.graphic) { const box = point.graphic.getBBox(); @@ -405,7 +423,7 @@ function getDefaultPointRect(point: Highcharts.Point): Rect { // The column series graphic elements are rectangles, and they are inverted if the chart is inverted, // so that rect's width becomes height and vice-versa. -function getColumnPointRect(point: Highcharts.Point): Rect { +function getColumnPointRect(point: SafePoint): Rect { const chart = point.series.chart; if (point.graphic) { return getChartRect(point.graphic.getBBox(), chart, true); @@ -415,7 +433,7 @@ function getColumnPointRect(point: Highcharts.Point): Rect { // The errorbar series point rect cannot be computed from the respective graphic element (it gives wrong position). // Instead, we have to rely on the internal "whiskers" element b-box, which can be inverted, too. -function getErrorBarPointRect(point: Highcharts.Point): Rect { +function getErrorBarPointRect(point: SafePoint): Rect { const chart = point.series.chart; if ("whiskers" in point) { return getChartRect((point.whiskers as Highcharts.SVGElement).getBBox(), chart, true); @@ -428,7 +446,7 @@ function getErrorBarPointRect(point: Highcharts.Point): Rect { // size is small, and thereby give it 0 width and height, which is alright as there is a // small offset that we use for focus outline anyways. // See: https://www.highcharts.com/blog/news/175-highcharts-performance-boost/ -function getPointRectFromCoordinates(point: Highcharts.Point) { +function getPointRectFromCoordinates(point: SafePoint) { const chart = point.series.chart; const plotX = point.plotX ?? 0; const plotY = point.plotY ?? 0; diff --git a/src/internal/utils/highcharts.ts b/src/internal/utils/highcharts.ts index af39ec15..661fa3fd 100644 --- a/src/internal/utils/highcharts.ts +++ b/src/internal/utils/highcharts.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import type { Chart, Point, Series } from "highcharts"; +import type { Chart, Point, Series, SeriesZonesOptionsObject } from "highcharts"; // The chart.series array can include internal series, that are unwanted. // Use getChartSeries to access chart series instead. @@ -11,6 +11,10 @@ export type SafeChart = Omit; // Use getSeriesData to access series data points instead. export type SafeSeries = Omit; +// This point object without getZone API. +// Use getPointZone to get point's zone instead. +export type SafePoint = Omit; + // isInternal is currently not a publicly supported prop // https://github.com/highcharts/highcharts/issues/23278 interface InternalSeries extends Series { @@ -76,3 +80,21 @@ export const getSeriesData = (series: SafeSeries, options: { includeHiddenPoints // eslint-disable-next-line no-restricted-syntax -- This is the safe wrapper itself. return (series as Series).data.filter((p) => (options.includeHiddenPoints ? isPointValid(p) : isPointVisible(p))); }; + +/** + * Returns point's zone safely. The Point.getZone() API can crash on series with no zones, + * see: https://github.com/highcharts/highcharts/issues/24633. + * + * It is not clear how to check the presence of zones safely, as zones are not represented in the public Series API. + */ +export function getPointZone(point: SafePoint): null | SeriesZonesOptionsObject { + try { + const seriesZones = "zones" in point.series ? point.series.zones : undefined; + if (seriesZones && Array.isArray(seriesZones) && seriesZones.length > 0) { + return (point as Point).getZone(); + } + } catch { + // no-op + } + return null; +}