diff --git a/.gitignore b/.gitignore index 88d9d0ec..2fee74d9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ dist /src/test-utils/selectors/**/*.ts !/src/test-utils/selectors/index.ts .DS_STORE +.idea +.vscode diff --git a/pages/01-cartesian-chart/axes-and-thresholds.page.tsx b/pages/01-cartesian-chart/axes-and-thresholds.page.tsx index a2532ef2..e393f255 100644 --- a/pages/01-cartesian-chart/axes-and-thresholds.page.tsx +++ b/pages/01-cartesian-chart/axes-and-thresholds.page.tsx @@ -1,8 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { useState } from "react"; import { range } from "lodash"; +import Button from "@cloudscape-design/components/button"; +import SpaceBetween from "@cloudscape-design/components/space-between"; + const addDays = (date: Date, days: number) => { const result = new Date(date); result.setDate(result.getDate() + days); @@ -15,7 +19,7 @@ const subYears = (date: Date, years: number) => { return result; }; -import { CartesianChart } from "../../lib/components"; +import { CartesianChart, CartesianChartProps } from "../../lib/components"; import { dateFormatter } from "../common/formatters"; import { useChartSettings } from "../common/page-settings"; import { Page, PageSection } from "../common/templates"; @@ -77,6 +81,12 @@ export default function () { + + + + + + ); } @@ -688,3 +698,73 @@ function CategoryDatetime() { /> ); } + +const zoomSeriesData = range(0, 100).map((i) => ({ + x: addDays(new Date(), i).getTime(), + y: Math.floor((pseudoRandom() + i / 50) * 100), +})); + +const zoomSeries: CartesianChartProps.SeriesOptions[] = [ + { type: "area", name: "Requests", data: zoomSeriesData }, + { + type: "spline", + name: "Avg latency", + data: zoomSeriesData.map((d) => ({ x: d.x, y: d.y * 0.6 + Math.floor(pseudoRandom() * 20) })), + }, + { type: "y-threshold", name: "SLA limit", value: 150 }, +]; + +// Uncontrolled zoom: two accessible interaction methods are available out of the box — +// (1) drag-to-zoom with the mouse, and (2) the "Zoom" button that enters a unified zoom mode. +// In zoom mode a vertical cursor is moved with the mouse OR the Left/Right arrow keys (and UAP +// direction buttons for touch); a single click / Enter / Space sets the start point, and a second +// click / Enter / Space sets the end point to zoom — a single-pointer, no-modifier interaction that +// satisfies WCAG 2.5.7 (Dragging Movements). Escape or "Exit zoom" cancels. The cursor position is +// announced to screen readers as it moves. Zoom state is managed internally; a "Reset" button +// appears once zoomed. While zoomed, a persistent affordance marks the active range: a vertical +// boundary line at each edge with a subtle band tint between them. The "Zoom" button stays visible +// alongside "Reset" while zoomed, so a narrower range can be selected without resetting first. +function DatetimeLinearWithZoom() { + const { chartProps } = useChartSettings(); + return ( + + ); +} + +// Controlled zoom: the consumer owns the zoom range via `zoomRange` + `onZoomRangeChange`. +// Here we hide the built-in buttons and drive zoom entirely through custom UI and ref methods. +function DatetimeLinearWithControlledZoom() { + const { chartProps } = useChartSettings(); + const [zoomRange, setZoomRange] = useState(null); + const start = zoomSeriesData[20].x; + const end = zoomSeriesData[60].x; + return ( + + + + + + setZoomRange(detail.zoomRange)} + series={zoomSeries} + xAxis={{ title: "Time", type: "datetime", valueFormatter: dateFormatter }} + yAxis={{ title: "Count", type: "linear" }} + /> + + ); +} diff --git a/pages/common/use-highcharts.ts b/pages/common/use-highcharts.ts index 2f0bb0dd..cfc16bdd 100644 --- a/pages/common/use-highcharts.ts +++ b/pages/common/use-highcharts.ts @@ -12,7 +12,13 @@ export function useHighcharts({ solidgauge = false, boost = false, heatmap = false, -}: { more?: boolean; xrange?: boolean; solidgauge?: boolean; boost?: boolean; heatmap?: boolean } = {}) { +}: { + more?: boolean; + xrange?: boolean; + solidgauge?: boolean; + boost?: boolean; + heatmap?: boolean; +} = {}) { const [highcharts, setHighcharts] = useState(null); useEffect(() => { diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 4b6863fc..36f812eb 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -21,8 +21,71 @@ exports[`definition for cartesian-chart matches the snapshot > cartesian-chart 1 "detailType": "{ visibleSeries: Array; }", "name": "onVisibleSeriesChange", }, + { + "cancelable": false, + "description": "Called when the zoom range changes due to user interaction. +In controlled mode, update \`zoomRange\` in this handler.", + "detailInlineType": { + "name": "CartesianChartProps.ZoomChangeDetail", + "properties": [ + { + "inlineType": { + "name": "CartesianChartProps.ZoomRange", + "properties": [ + { + "inlineType": { + "name": "{ startValue: number; endValue: number; }", + "properties": [ + { + "name": "endValue", + "optional": false, + "type": "number", + }, + { + "name": "startValue", + "optional": false, + "type": "number", + }, + ], + "type": "object", + }, + "name": "x", + "optional": true, + "type": "{ startValue: number; endValue: number; }", + }, + ], + "type": "object", + }, + "name": "zoomRange", + "optional": false, + "type": "CartesianChartProps.ZoomRange | null", + }, + ], + "type": "object", + }, + "detailType": "CartesianChartProps.ZoomChangeDetail", + "name": "onZoomRangeChange", + }, ], "functions": [ + { + "description": "Enters zoom mode. In zoom mode, popovers are disabled and clicks on the chart set zoom range points.", + "name": "enterZoomMode", + "parameters": [], + "returnType": "void", + }, + { + "description": "Exits zoom mode without applying zoom. Returns to the idle state.", + "name": "exitZoomMode", + "parameters": [], + "returnType": "void", + }, + { + "description": "Resets the zoom to show the full data range.", + "name": "resetZoom", + "parameters": [], + "returnType": "void", + }, { "description": "Controls series visibility and works with both controlled and uncontrolled visibility modes.", "name": "setVisibleSeries", @@ -142,11 +205,31 @@ Supported Highcharts versions: 12.", "optional": true, "type": "string", }, + { + "name": "enterZoomModeButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "enterZoomModeButtonText", + "optional": true, + "type": "string", + }, { "name": "errorText", "optional": true, "type": "string", }, + { + "name": "exitZoomModeButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "exitZoomModeButtonText", + "optional": true, + "type": "string", + }, { "name": "legendAriaLabel", "optional": true, @@ -162,6 +245,16 @@ Supported Highcharts versions: 12.", "optional": true, "type": "string", }, + { + "name": "resetZoomButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "resetZoomButtonText", + "optional": true, + "type": "string", + }, { "name": "seriesFilterLabel", "optional": true, @@ -187,6 +280,109 @@ Supported Highcharts versions: 12.", "optional": true, "type": "string", }, + { + "name": "zoomControlsAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "zoomCursorNextButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "(value: string) => string", + "parameters": [ + { + "name": "value", + "type": "string", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "zoomCursorPositionAnnouncementText", + "optional": true, + "type": "((value: string) => string)", + }, + { + "name": "zoomCursorPreviousButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "(value: string) => string", + "parameters": [ + { + "name": "value", + "type": "string", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "zoomModeEnteredAnnouncementText", + "optional": true, + "type": "((value: string) => string)", + }, + { + "inlineType": { + "name": "(startValue: string, endValue: string) => string", + "parameters": [ + { + "name": "startValue", + "type": "string", + }, + { + "name": "endValue", + "type": "string", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "zoomRangeChangeAnnouncementText", + "optional": true, + "type": "((startValue: string, endValue: string) => string)", + }, + { + "inlineType": { + "name": "(startValue: string, endValue: string) => string", + "parameters": [ + { + "name": "startValue", + "type": "string", + }, + { + "name": "endValue", + "type": "string", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "zoomSelectionAnnouncementText", + "optional": true, + "type": "((startValue: string, endValue: string) => string)", + }, + { + "inlineType": { + "name": "(value: string) => string", + "parameters": [ + { + "name": "value", + "type": "string", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "zoomStartPointAnnouncementText", + "optional": true, + "type": "((value: string) => string)", + }, ], "type": "object", }, @@ -620,6 +816,78 @@ applies to the tooltip points values.", "optional": true, "type": "CartesianChartProps.YAxisOptions | [CartesianChartProps.YAxisWithId, CartesianChartProps.YAxisWithId]", }, + { + "description": "Enables zoom functionality on the chart. When enabled, users can zoom into a range +on the x-axis using three interaction methods: + +1. **Drag-to-zoom** — Click and drag on the chart plot area to select a range. +2. **Zoom mode (click-based)** — A "Zoom" button appears in the top-right corner of the chart. + Clicking it enters zoom mode where popovers are disabled. The first click on the chart sets + the start point, and the second click sets the end point, zooming to that range. Direction + buttons appear at the selection line for fine-tuning. Press Escape or click "Exit zoom" to cancel. +3. **Keyboard zoom** — While focused on a data point, hold Shift and press Left/Right arrow keys + to extend a selection range. Release Shift or press Enter to zoom. Press Escape to cancel. + +After zooming, a "Reset" button appears to restore the full data range. + +Supported options: +* \`enabled\` (optional, boolean) — Enables zoom functionality. +* \`hideButtons\` (optional, boolean) — Hides the built-in zoom control buttons (Zoom, Exit zoom, Reset). + Use this when providing custom zoom UI via ref methods (\`enterZoomMode\`, \`exitZoomMode\`, \`resetZoom\`).", + "inlineType": { + "name": "CartesianChartProps.ZoomOptions", + "properties": [ + { + "name": "enabled", + "optional": true, + "type": "boolean", + }, + { + "name": "hideButtons", + "optional": true, + "type": "boolean", + }, + ], + "type": "object", + }, + "name": "zoom", + "optional": true, + "type": "CartesianChartProps.ZoomOptions", + }, + { + "description": "The current zoom range. When provided, the component operates in controlled mode. +Pass \`null\` to reset to full range. When omitted, zoom state is managed internally.", + "inlineType": { + "name": "CartesianChartProps.ZoomRange", + "properties": [ + { + "inlineType": { + "name": "{ startValue: number; endValue: number; }", + "properties": [ + { + "name": "endValue", + "optional": false, + "type": "number", + }, + { + "name": "startValue", + "optional": false, + "type": "number", + }, + ], + "type": "object", + }, + "name": "x", + "optional": true, + "type": "{ startValue: number; endValue: number; }", + }, + ], + "type": "object", + }, + "name": "zoomRange", + "optional": true, + "type": "CartesianChartProps.ZoomRange | null", + }, ], "regions": [ { diff --git a/src/cartesian-chart/__tests__/cartesian-chart-zoom.test.tsx b/src/cartesian-chart/__tests__/cartesian-chart-zoom.test.tsx new file mode 100644 index 00000000..f9c1cb8a --- /dev/null +++ b/src/cartesian-chart/__tests__/cartesian-chart-zoom.test.tsx @@ -0,0 +1,425 @@ +// 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 { afterEach, describe, expect, test, vi } from "vitest"; + +import "@cloudscape-design/components/test-utils/dom"; +import { CartesianChartProps } from "../../../lib/components/cartesian-chart"; +import { getChart, ref, renderCartesianChart } from "./common"; + +const series: CartesianChartProps.SeriesOptions[] = [ + { + type: "line", + name: "Requests", + data: [ + { x: 0, y: 10 }, + { x: 1, y: 20 }, + { x: 2, y: 30 }, + { x: 3, y: 25 }, + { x: 4, y: 40 }, + ], + }, +]; + +const defaultProps = { + highcharts, + series, + xAxis: { title: "X", type: "linear" as const, min: 0, max: 4 }, + yAxis: { title: "Y", type: "linear" as const }, +}; + +function getCurrentChart() { + // Target the most recently rendered chart: highcharts.charts accumulates entries across tests + // (disposed charts remain as holes), so the last defined entry is the one under test. + return [...highcharts.charts].reverse().find((c) => c)!; +} + +function getXExtremes() { + const { min, max } = getCurrentChart().xAxis[0].getExtremes(); + return { min, max }; +} + +// Reads the persistent zoom-range affordance drawn on the x-axis: the two boundary plot lines and +// the band tint between them. Returns their ids and the band's from/to so tests can assert on them. +function getZoomRangeOverlays() { + const xAxis = getCurrentChart().xAxis[0] as unknown as { + plotLinesAndBands: { id?: string; options?: { from?: number; to?: number } }[]; + }; + const items = xAxis.plotLinesAndBands ?? []; + const startLine = items.find((i) => i.id === "awsui-zoom-range-start"); + const endLine = items.find((i) => i.id === "awsui-zoom-range-end"); + const band = items.find((i) => i.id === "awsui-zoom-range"); + return { startLine, endLine, band }; +} + +const onZoomRangeChange = vi.fn(); + +afterEach(() => { + onZoomRangeChange.mockReset(); +}); + +describe("CartesianChart: zoom", () => { + test("does not render zoom controls when zoom is not enabled", () => { + renderCartesianChart(defaultProps); + expect(getChart().findZoomButton()).toBe(null); + expect(getChart().findExitZoomButton()).toBe(null); + expect(getChart().findResetZoomButton()).toBe(null); + }); + + test("renders the Zoom button in idle state when zoom is enabled", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + expect(getChart().findZoomButton()).not.toBe(null); + expect(getChart().findExitZoomButton()).toBe(null); + expect(getChart().findResetZoomButton()).toBe(null); + }); + + test("does not render built-in buttons when hideButtons is set", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true, hideButtons: true } }); + expect(getChart().findZoomButton()).toBe(null); + expect(getChart().findExitZoomButton()).toBe(null); + expect(getChart().findResetZoomButton()).toBe(null); + }); + + test("clicking Zoom enters zoom mode and shows the Exit zoom button", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + act(() => getChart().findZoomButton()!.click()); + expect(getChart().findZoomButton()).toBe(null); + expect(getChart().findExitZoomButton()).not.toBe(null); + }); + + test("clicking Exit zoom returns to idle state", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + act(() => getChart().findZoomButton()!.click()); + act(() => getChart().findExitZoomButton()!.click()); + expect(getChart().findExitZoomButton()).toBe(null); + expect(getChart().findZoomButton()).not.toBe(null); + }); + + test("ref.enterZoomMode / exitZoomMode toggle zoom mode", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + act(() => ref.current!.enterZoomMode()); + expect(getChart().findExitZoomButton()).not.toBe(null); + act(() => ref.current!.exitZoomMode()); + expect(getChart().findZoomButton()).not.toBe(null); + }); + + test("ref.resetZoom clears the extremes and fires onZoomRangeChange with null", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + // Programmatically zoom via the chart, then reset. + act(() => { + const chart = highcharts.charts.find((c) => c)!; + chart.xAxis[0].setExtremes(1, 3); + }); + act(() => ref.current!.resetZoom()); + const { min, max } = getXExtremes(); + expect(min).toBe(0); + expect(max).toBe(4); + expect(onZoomRangeChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { zoomRange: null } })); + }); + + test("controlled zoomRange applies extremes to the chart", () => { + const { rerender } = renderCartesianChart({ + ...defaultProps, + zoom: { enabled: true }, + zoomRange: null, + onZoomRangeChange, + }); + rerender({ + ...defaultProps, + zoom: { enabled: true }, + zoomRange: { x: { startValue: 1, endValue: 3 } }, + onZoomRangeChange, + }); + const { min, max } = getXExtremes(); + expect(min).toBe(1); + expect(max).toBe(3); + // In zoomed state the Reset button is shown. + expect(getChart().findResetZoomButton()).not.toBe(null); + }); + + // Dispatches a keydown from a real element inside the chart (the core handler calls + // target.closest, so a Document target would be rejected). + function pressChartKey(key: string) { + const chartEl = getChart().getElement(); + const target = chartEl.querySelector('[role="application"]') ?? chartEl; + act(() => target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }))); + } + + // Drives a full keyboard zoom to (startValue, endValue) over the default data points (x=0..4), + // reaching the "zoomed" state through the real interaction (which sets extremes + the affordance). + function keyboardZoomTo(startValue: number, endValue: number) { + act(() => getChart().findZoomButton()!.click()); + // Cursor starts at the first visible data point (x=0 initially). Step to the start point. + for (let i = 0; i < startValue; i++) { + pressChartKey("ArrowRight"); + } + pressChartKey("Enter"); + for (let i = 0; i < endValue - startValue; i++) { + pressChartKey("ArrowRight"); + } + pressChartKey("Enter"); + } + + test("keeps the Zoom button visible alongside Reset while zoomed", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + keyboardZoomTo(1, 3); + // Both controls are available in the zoomed state. + expect(getChart().findResetZoomButton()).not.toBe(null); + expect(getChart().findZoomButton()).not.toBe(null); + expect(getChart().findExitZoomButton()).toBe(null); + }); + + test("clicking Zoom while zoomed re-enters zoom mode and suppresses the range affordance", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + keyboardZoomTo(1, 3); + // Affordance present in the settled zoomed state. + expect(getZoomRangeOverlays().band).toBeDefined(); + // Re-enter zoom mode: Zoom is replaced by Exit zoom, and the persistent affordance is hidden + // so the new selection reads like a first-time zoom. + act(() => getChart().findZoomButton()!.click()); + expect(getChart().findExitZoomButton()).not.toBe(null); + expect(getChart().findZoomButton()).toBe(null); + expect(getChart().findResetZoomButton()).toBe(null); + expect(getZoomRangeOverlays().band).toBeUndefined(); + }); + + test("exiting a re-zoom returns to the zoomed state with the range intact", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + keyboardZoomTo(1, 3); + act(() => getChart().findZoomButton()!.click()); + act(() => getChart().findExitZoomButton()!.click()); + // Back to zoomed (not idle): Reset and Zoom shown, extremes preserved, affordance restored. + expect(getChart().findResetZoomButton()).not.toBe(null); + expect(getChart().findZoomButton()).not.toBe(null); + const { min, max } = getXExtremes(); + expect(min).toBe(1); + expect(max).toBe(3); + expect(getZoomRangeOverlays().band?.options).toMatchObject({ from: 1, to: 3 }); + }); + + test("re-zooming to a narrower range updates the extremes and affordance", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + // First zoom to the range (1, 4) so the visible window contains points 1..4. + keyboardZoomTo(1, 4); + expect(getXExtremes()).toEqual({ min: 1, max: 4 }); + // Re-enter zoom mode: the cursor starts at the first visible point (x=1) thanks to the + // in-view initial cursor. Select a narrower range (2, 3) within the current window. + act(() => getChart().findZoomButton()!.click()); + pressChartKey("ArrowRight"); // x=2 + pressChartKey("Enter"); // start + pressChartKey("ArrowRight"); // x=3 + pressChartKey("Enter"); // end → zoom + expect(getXExtremes()).toEqual({ min: 2, max: 3 }); + expect(getZoomRangeOverlays().band?.options).toMatchObject({ from: 2, to: 3 }); + expect(getChart().findResetZoomButton()).not.toBe(null); + expect(getChart().findZoomButton()).not.toBe(null); + }); + + test("Escape during a re-zoom returns to the zoomed state without changing the range", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + keyboardZoomTo(1, 3); + onZoomRangeChange.mockClear(); + act(() => getChart().findZoomButton()!.click()); + pressChartKey("ArrowRight"); + pressChartKey("Escape"); + // Range unchanged and back in the zoomed state; no new zoom event fired by the cancel. + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + expect(getChart().findResetZoomButton()).not.toBe(null); + expect(getChart().findZoomButton()).not.toBe(null); + expect(getZoomRangeOverlays().band?.options).toMatchObject({ from: 1, to: 3 }); + expect(onZoomRangeChange).not.toHaveBeenCalled(); + }); + + test("controlled zoomRange=null resets the extremes", () => { + const { rerender } = renderCartesianChart({ + ...defaultProps, + zoom: { enabled: true }, + zoomRange: { x: { startValue: 1, endValue: 3 } }, + onZoomRangeChange, + }); + rerender({ ...defaultProps, zoom: { enabled: true }, zoomRange: null, onZoomRangeChange }); + const { min, max } = getXExtremes(); + expect(min).toBe(0); + expect(max).toBe(4); + }); + + test("Escape exits zoom mode", () => { + const { wrapper } = renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + act(() => getChart().findZoomButton()!.click()); + expect(getChart().findExitZoomButton()).not.toBe(null); + // Dispatch from a real element in the chart so the core keydown handler (which calls + // target.closest) receives a valid Element target. + const target = wrapper.getElement().querySelector('[role="application"]') ?? wrapper.getElement(); + act(() => { + target.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + expect(getChart().findZoomButton()).not.toBe(null); + }); + + test("applies i18nStrings overrides to the zoom controls", () => { + renderCartesianChart({ + ...defaultProps, + zoom: { enabled: true }, + i18nStrings: { + enterZoomModeButtonText: "Vergrößern", + enterZoomModeButtonAriaLabel: "Zoom-Modus aktivieren", + }, + }); + const button = getChart().findZoomButton()!; + expect(button.getElement().textContent).toContain("Vergrößern"); + expect(button.getElement()).toHaveAttribute("aria-label", "Zoom-Modus aktivieren"); + }); + + test("draws the zoom-range boundary lines and band when zoomed via ref", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + // Not zoomed yet — no affordance. + expect(getZoomRangeOverlays().band).toBeUndefined(); + act(() => ref.current!.enterZoomMode()); + // Keyboard-select a range: x=1 to x=3. + const chartEl = getChart().getElement(); + const target = chartEl.querySelector('[role="application"]') ?? chartEl; + const press = (key: string) => + act(() => target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }))); + press("ArrowRight"); + press("Enter"); + press("ArrowRight"); + press("ArrowRight"); + press("Enter"); + + const { startLine, endLine, band } = getZoomRangeOverlays(); + expect(startLine).toBeDefined(); + expect(endLine).toBeDefined(); + expect(band?.options).toMatchObject({ from: 1, to: 3 }); + }); + + test("clears the zoom-range affordance when zoom is reset", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + act(() => { + const chart = highcharts.charts.find((c) => c)!; + chart.xAxis[0].setExtremes(1, 3); + }); + act(() => ref.current!.resetZoom()); + const { startLine, endLine, band } = getZoomRangeOverlays(); + expect(startLine).toBeUndefined(); + expect(endLine).toBeUndefined(); + expect(band).toBeUndefined(); + }); + + test("controlled zoomRange draws the boundary affordance", () => { + const { rerender } = renderCartesianChart({ + ...defaultProps, + zoom: { enabled: true }, + zoomRange: null, + onZoomRangeChange, + }); + expect(getZoomRangeOverlays().band).toBeUndefined(); + rerender({ + ...defaultProps, + zoom: { enabled: true }, + zoomRange: { x: { startValue: 1, endValue: 3 } }, + onZoomRangeChange, + }); + expect(getZoomRangeOverlays().band?.options).toMatchObject({ from: 1, to: 3 }); + // Resetting via controlled null clears the affordance. + rerender({ ...defaultProps, zoom: { enabled: true }, zoomRange: null, onZoomRangeChange }); + expect(getZoomRangeOverlays().band).toBeUndefined(); + }); + + test("exposes a labelled zoom controls region", () => { + const { wrapper } = renderCartesianChart({ + ...defaultProps, + zoom: { enabled: true }, + i18nStrings: { zoomControlsAriaLabel: "Custom zoom region" }, + }); + const el = wrapper.getElement().querySelector('[role="region"][aria-label="Custom zoom region"]'); + expect(el).not.toBe(null); + }); +}); + +describe("CartesianChart: zoom keyboard", () => { + // Dispatch keydown from a real element inside the chart so the core keydown handler (which calls + // target.closest) receives a valid Element target instead of the Document. + function pressKey(key: string) { + const chartEl = getChart().getElement(); + const target = chartEl.querySelector('[role="application"]') ?? chartEl; + act(() => { + target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + }); + } + + test("arrow keys move the cursor and Enter sets start then end to zoom", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + // Enter zoom mode — cursor starts at the first data point (x=0). + act(() => getChart().findZoomButton()!.click()); + + // Move the cursor right to x=1 and set the start point. + pressKey("ArrowRight"); + pressKey("Enter"); + // Move the cursor right to x=3 and set the end point → zoom applies. + pressKey("ArrowRight"); + pressKey("ArrowRight"); + pressKey("Enter"); + + const { min, max } = getXExtremes(); + expect(min).toBe(1); + expect(max).toBe(3); + expect(onZoomRangeChange).toHaveBeenLastCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 1, endValue: 3 } } } }), + ); + // After zooming, the Reset button is shown. + expect(getChart().findResetZoomButton()).not.toBe(null); + }); + + test("Space also sets zoom points", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + act(() => getChart().findZoomButton()!.click()); + pressKey("ArrowRight"); // cursor at x=1 + pressKey(" "); // set start + pressKey("ArrowRight"); // cursor at x=2 + pressKey(" "); // set end → zoom + const { min, max } = getXExtremes(); + expect(min).toBe(1); + expect(max).toBe(2); + }); + + test("Escape cancels an in-progress keyboard selection without zooming", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + act(() => getChart().findZoomButton()!.click()); + pressKey("ArrowRight"); + pressKey("Enter"); // start point set, now selecting + pressKey("Escape"); // cancel + const { min, max } = getXExtremes(); + expect(min).toBe(0); + expect(max).toBe(4); + // Back to idle: the Zoom button is shown again. + expect(getChart().findZoomButton()).not.toBe(null); + expect(onZoomRangeChange).not.toHaveBeenCalled(); + }); + + test("renders UAP direction buttons in zoom mode", () => { + const { wrapper } = renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + act(() => getChart().findZoomButton()!.click()); + const prev = document.querySelector('button[aria-label="Move zoom cursor left"]'); + const next = document.querySelector('button[aria-label="Move zoom cursor right"]'); + expect(prev).not.toBe(null); + expect(next).not.toBe(null); + void wrapper; + }); + + test("direction buttons move the cursor", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + act(() => getChart().findZoomButton()!.click()); + const next = document.querySelector('button[aria-label="Move zoom cursor right"]') as HTMLButtonElement; + // Move cursor to x=1 via the button, set start via keyboard. + act(() => next.click()); + pressKey("Enter"); + // Move cursor to x=2 via the button, set end → zoom. + act(() => next.click()); + pressKey("Enter"); + const { min, max } = getXExtremes(); + expect(min).toBe(1); + expect(max).toBe(2); + }); +}); diff --git a/src/cartesian-chart/chart-cartesian-internal.tsx b/src/cartesian-chart/chart-cartesian-internal.tsx index aaa50670..73f72d8c 100644 --- a/src/cartesian-chart/chart-cartesian-internal.tsx +++ b/src/cartesian-chart/chart-cartesian-internal.tsx @@ -1,14 +1,25 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { forwardRef, useImperativeHandle, useRef, useState } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import { useControllableState } from "@cloudscape-design/component-toolkit"; +import Button from "@cloudscape-design/components/button"; +import LiveRegion from "@cloudscape-design/components/live-region"; +import SpaceBetween from "@cloudscape-design/components/space-between"; +import { + colorBackgroundItemSelected, + colorBorderItemFocused, + colorBorderItemSelected, + colorChartsLineTick, +} from "@cloudscape-design/design-tokens"; import { InternalCoreChart } from "../core/chart-core"; import { CoreChartProps, ErrorBarSeriesOptions } from "../core/interfaces"; import { getOptionsId, isXThreshold } from "../core/utils"; import { InternalBaseComponentProps } from "../internal/base-component/use-base-component"; +import DirectionButton from "../internal/components/drag-handle-wrapper/direction-button"; +import PortalOverlay from "../internal/components/drag-handle-wrapper/portal-overlay"; import { fireNonCancelableEvent } from "../internal/events"; import { castArray, SomeRequired } from "../internal/utils/utils"; import { transformCartesianSeries } from "./chart-series-cartesian"; @@ -21,19 +32,192 @@ interface InternalCartesianChartProps extends InternalBaseComponentProps, Cartes legend: SomeRequired; } +const ZOOM_SELECTION_BAND_ID = "awsui-zoom-selection"; +const ZOOM_ANCHOR_LINE_ID = "awsui-zoom-anchor"; +const ZOOM_CURSOR_LINE_ID = "awsui-zoom-cursor"; +// Overlays that mark the boundaries of the currently zoomed range: two vertical lines at the +// zoomed min/max plus a subtle band tint between them, shown while the chart is zoomed. +const ZOOM_RANGE_BAND_ID = "awsui-zoom-range"; +const ZOOM_RANGE_START_LINE_ID = "awsui-zoom-range-start"; +const ZOOM_RANGE_END_LINE_ID = "awsui-zoom-range-end"; + +// Zoom mode state machine: +// "idle" — normal (unzoomed) chart, "Zoom" button visible. +// "zoomMode" — zoom mode entered, "Exit zoom" button visible, popovers disabled. A vertical cursor +// follows the mouse or arrow keys, waiting for the first point (click / Enter / Space). +// "selecting" — first point set, highlight band spans from the anchor to the cursor, waiting for the +// second point (click / Enter / Space) to complete the zoom. +// "zoomed" — chart is zoomed, "Zoom" and "Reset" buttons visible. The "Zoom" button stays available +// so users can refine the current zoom by selecting a new range without resetting first; doing so +// re-enters "zoomMode" and exiting (Escape / "Exit zoom") returns to "zoomed" with the range intact. +// Both the mouse and the keyboard drive the same cursor, so the two interaction methods are unified +// (matching the approved design) and each is a WCAG-compliant single-pointer / keyboard equivalent. +// Whether the chart is actually zoomed is tracked separately by `zoomedExtremes`, since zoom mode can +// now be entered on top of an existing zoom. +type ZoomModeState = "idle" | "zoomMode" | "selecting" | "zoomed"; + +// Fill for the keyboard/click zoom selection band. This matches the fill Highcharts applies to its +// native drag-to-zoom selection marker (highlightColor80 at 0.25 opacity), so keyboard and +// click-based selection look identical to standard mouse dragging. +const ZOOM_SELECTION_FILL = "rgba(51, 78, 255, 0.25)"; + +// Draws (or redraws) the zoom selection highlight band between two x-axis values. It intentionally +// mirrors the native Highcharts drag-selection marker: a translucent fill with no border. +function drawSelectionBand( + xAxis: { removePlotBand(id: string): void; addPlotBand(options: object): void }, + from: number, + to: number, +): void { + xAxis.removePlotBand(ZOOM_SELECTION_BAND_ID); + xAxis.addPlotBand({ + id: ZOOM_SELECTION_BAND_ID, + from: Math.min(from, to), + to: Math.max(from, to), + color: ZOOM_SELECTION_FILL, + zIndex: 4, + }); +} + +// Draws (or redraws) the vertical zoom cursor line at the given x-axis value. The cursor is the +// shared focus indicator that both the mouse and the keyboard move around while in zoom mode. It +// reuses the same look as the standard chart hover cursor (see Styles.cursorLine). +function drawCursorLine( + xAxis: { removePlotLine(id: string): void; addPlotLine(options: object): void }, + value: number, +): void { + xAxis.removePlotLine(ZOOM_CURSOR_LINE_ID); + xAxis.addPlotLine({ + id: ZOOM_CURSOR_LINE_ID, + value, + color: colorChartsLineTick, + width: 1, + zIndex: 6, + }); +} + +// Type covering the subset of the Highcharts x-axis used to draw the zoom overlays. +interface ZoomOverlayAxis { + removePlotBand(id: string): void; + addPlotBand(options: object): void; + removePlotLine(id: string): void; + addPlotLine(options: object): void; +} + +// Fill for the zoom-range affordance band. This is the subtle selected-item background tint, laid +// under the plot content between the two boundary lines so the zoomed region reads as "selected". +const ZOOM_RANGE_FILL = colorBackgroundItemSelected; + +// Draws (or redraws) the persistent zoom-range affordance: a subtle band tint between the zoomed +// min/max plus a vertical boundary line at each edge. Shown while the chart is zoomed to make the +// active range explicit. Boundary lines reuse the selected-item border token to match the tint. +function drawZoomRangeBoundaries(xAxis: ZoomOverlayAxis, min: number, max: number): void { + clearZoomRangeBoundaries(xAxis); + const from = Math.min(min, max); + const to = Math.max(min, max); + xAxis.addPlotBand({ id: ZOOM_RANGE_BAND_ID, from, to, color: ZOOM_RANGE_FILL, zIndex: 0 }); + for (const [id, value] of [ + [ZOOM_RANGE_START_LINE_ID, from], + [ZOOM_RANGE_END_LINE_ID, to], + ] as const) { + xAxis.addPlotLine({ id, value, color: colorBorderItemSelected, width: 1, zIndex: 3 }); + } +} + +// Removes the zoom-range affordance overlays. Safe to call when they are not present. +function clearZoomRangeBoundaries(xAxis: ZoomOverlayAxis): void { + xAxis.removePlotBand(ZOOM_RANGE_BAND_ID); + xAxis.removePlotLine(ZOOM_RANGE_START_LINE_ID); + xAxis.removePlotLine(ZOOM_RANGE_END_LINE_ID); +} + +// Returns the x value from `values` nearest to `target`, moved one step in `direction` when a step +// is requested. Used to move the zoom cursor between real data points via arrow keys / UAP buttons. +function stepXValue(values: number[], target: number, direction: -1 | 1): number { + if (values.length === 0) { + return target; + } + // Index of the value at or just past the target. + let idx = values.findIndex((x) => x >= target); + if (idx === -1) { + idx = values.length - 1; + } + // If the target sits exactly on a data point, step to the neighbour; otherwise snap to the value + // on the side we are moving toward. + if (values[idx] === target) { + idx += direction; + } else if (direction === -1) { + idx -= 1; + } + return values[Math.max(0, Math.min(values.length - 1, idx))]; +} + +// Highcharts' Pointer.normalize maps a DOM mouse event to chart-relative coordinates. It is not +// part of the public typings, so we access it through a narrow structural type. +interface PointerWithNormalize { + normalize?: (e: MouseEvent) => { chartX: number; chartY: number }; +} + +function normalizePointerEvent( + chart: { pointer: unknown }, + e: MouseEvent, +): { chartX: number; chartY: number } | undefined { + return (chart.pointer as PointerWithNormalize).normalize?.(e); +} + +function getChartXValues(chart: { + series: readonly { visible: boolean; points?: readonly { x: number }[] }[]; +}): number[] { + const xSet = new Set(); + for (const s of chart.series) { + if (!s.visible) { + continue; + } + for (const p of s.points ?? []) { + xSet.add(p.x); + } + } + return Array.from(xSet).sort((a, b) => a - b); +} + export const InternalCartesianChart = forwardRef( ({ tooltip, ...props }: InternalCartesianChartProps, ref: React.Ref) => { const apiRef = useRef(null); + const [chartReady, setChartReady] = useState(false); + const [zoomMode, setZoomMode] = useState("idle"); + const [zoomAnchor, setZoomAnchor] = useState(null); + // Extremes of the currently applied zoom, used to draw the persistent boundary affordance + // (two vertical lines + band tint). Null whenever the chart is not zoomed. Mirrored to a ref so + // event handlers and callbacks can read the latest value without being re-created each change. + const [zoomedExtremes, setZoomedExtremes] = useState<{ min: number; max: number } | null>(null); + const zoomedExtremesRef = useRef<{ min: number; max: number } | null>(null); + zoomedExtremesRef.current = zoomedExtremes; + const zoomAnchorRef = useRef(null); + zoomAnchorRef.current = zoomAnchor; + + const [liveAnnouncement, setLiveAnnouncement] = useState(""); + + // Ref to the reset button so we can move focus to it after a zoom is applied, keeping + // keyboard users oriented on the newly available control. + const resetButtonRef = useRef<{ focus(): void } | null>(null); + // Tracks the previous zoom-mode state so focus is only moved on the transition into "zoomed". + const prevZoomModeRef = useRef("idle"); + + // Position of the vertical zoom cursor (x-axis value). Shared by mouse and keyboard. Rendered as + // React state so the UAP direction buttons re-position, and mirrored to a ref for event handlers. + const [cursorX, setCursorX] = useState(null); + const cursorXRef = useRef(null); + cursorXRef.current = cursorX; + // Invisible element the UAP direction buttons anchor to; positioned at the cursor line. + const cursorTrackRef = useRef(null); + // The direction buttons are shown whenever we are in zoom mode with a placed cursor. + const inZoomSelection = (zoomMode === "zoomMode" || zoomMode === "selecting") && cursorX !== null; - // When visibleSeries and onVisibleSeriesChange are provided - the series visibility can be controlled from the outside. - // Otherwise - the component handles series visibility using its internal state. useControllableState(props.visibleSeries, props.onVisibleSeriesChange, undefined, { componentName: "CartesianChart", propertyName: "visibleSeries", changeHandlerName: "onVisibleSeriesChange", }); const allSeriesIds = props.series.map((s) => getOptionsId(s)); - // We keep local visible series state to compute threshold series data, that depends on series visibility. const [visibleSeriesLocal, setVisibleSeriesLocal] = useState(props.visibleSeries ?? allSeriesIds); const visibleSeriesState = props.visibleSeries ?? visibleSeriesLocal; const onVisibleSeriesChange: CoreChartProps["onVisibleItemsChange"] = ({ detail: { items } }) => { @@ -45,17 +229,352 @@ export const InternalCartesianChart = forwardRef( } }; - // We convert cartesian tooltip options to the core chart's getTooltipContent callback, - // ensuring no internal types are exposed to the consumer-defined render functions. + // i18n with defaults. Memoized so its identity is stable across renders, keeping the zoom + // callbacks (which depend on the announcement formatters) from being recreated each render. + const i18nStrings = props.i18nStrings; + const i18n = useMemo( + () => ({ + enterZoomModeButtonText: i18nStrings?.enterZoomModeButtonText ?? "Zoom", + enterZoomModeButtonAriaLabel: i18nStrings?.enterZoomModeButtonAriaLabel ?? "Enter zoom mode", + exitZoomModeButtonText: i18nStrings?.exitZoomModeButtonText ?? "Exit zoom", + exitZoomModeButtonAriaLabel: i18nStrings?.exitZoomModeButtonAriaLabel ?? "Exit zoom mode", + resetZoomButtonText: i18nStrings?.resetZoomButtonText ?? "Reset", + resetZoomButtonAriaLabel: i18nStrings?.resetZoomButtonAriaLabel ?? "Reset zoom to show full data range", + zoomControlsAriaLabel: i18nStrings?.zoomControlsAriaLabel ?? "Chart zoom controls", + zoomCursorPreviousButtonAriaLabel: i18nStrings?.zoomCursorPreviousButtonAriaLabel ?? "Move zoom cursor left", + zoomCursorNextButtonAriaLabel: i18nStrings?.zoomCursorNextButtonAriaLabel ?? "Move zoom cursor right", + zoomModeEnteredAnnouncementText: + i18nStrings?.zoomModeEnteredAnnouncementText ?? + ((value: string) => `Zoom mode. Cursor at ${value}. Use arrow keys to move, Enter to set the start point.`), + zoomCursorPositionAnnouncementText: + i18nStrings?.zoomCursorPositionAnnouncementText ?? ((value: string) => value), + zoomStartPointAnnouncementText: + i18nStrings?.zoomStartPointAnnouncementText ?? + ((value: string) => `Start point set at ${value}. Move the cursor and set the end point to zoom.`), + zoomRangeChangeAnnouncementText: + i18nStrings?.zoomRangeChangeAnnouncementText ?? + ((startValue: string, endValue: string) => `Zoomed from ${startValue} to ${endValue}`), + zoomSelectionAnnouncementText: + i18nStrings?.zoomSelectionAnnouncementText ?? + ((startValue: string, endValue: string) => `Selecting zoom range from ${startValue} to ${endValue}`), + }), + [i18nStrings], + ); + + const zoomEnabled = !!props.zoom?.enabled; + + // Formats an x-axis value for screen reader announcements, using the axis value formatter when provided, + // then falling back to locale-aware datetime formatting, and finally to the raw string value. + const formatXValue = useCallback( + (value: number) => { + const xAxisOptions = castArray(props.xAxis)?.[0]; + if (xAxisOptions?.valueFormatter) { + return xAxisOptions.valueFormatter(value); + } + if (xAxisOptions?.type === "datetime") { + return new Date(value).toLocaleString(); + } + return String(value); + }, + [props.xAxis], + ); + + const applyZoom = useCallback( + (startValue: number, endValue: number) => { + const min = Math.min(startValue, endValue); + const max = Math.max(startValue, endValue); + // In controlled mode the consumer owns the extremes: emit the event and let the + // zoomRange prop drive the chart. In uncontrolled mode we apply the extremes directly. + const xAxis = apiRef.current?.chart.xAxis[0]; + // Remove the zoom-mode overlays before applying the zoom. + if (xAxis) { + xAxis.removePlotBand(ZOOM_SELECTION_BAND_ID); + xAxis.removePlotLine(ZOOM_ANCHOR_LINE_ID); + xAxis.removePlotLine(ZOOM_CURSOR_LINE_ID); + } + if (props.zoomRange === undefined) { + xAxis?.setExtremes(min, max); + setZoomMode("zoomed"); + setZoomedExtremes({ min, max }); + } + setZoomAnchor(null); + setCursorX(null); + setLiveAnnouncement(i18n.zoomRangeChangeAnnouncementText(formatXValue(min), formatXValue(max))); + fireNonCancelableEvent(props.onZoomRangeChange, { zoomRange: { x: { startValue: min, endValue: max } } }); + }, + [formatXValue, i18n, props.onZoomRangeChange, props.zoomRange], + ); + + const resetZoom = useCallback(() => { + if (props.zoomRange === undefined) { + apiRef.current?.chart.xAxis[0].setExtremes(undefined, undefined); + setZoomMode("idle"); + setZoomedExtremes(null); + } + setZoomAnchor(null); + fireNonCancelableEvent(props.onZoomRangeChange, { zoomRange: null }); + }, [props.onZoomRangeChange, props.zoomRange]); + + // Focus management: when the chart transitions into the zoomed state, move focus to the + // "Reset" button so keyboard and screen reader users land on the control that just appeared. + useEffect(() => { + if (prevZoomModeRef.current !== "zoomed" && zoomMode === "zoomed" && !props.zoom?.hideButtons) { + resetButtonRef.current?.focus(); + } + prevZoomModeRef.current = zoomMode; + }, [zoomMode, props.zoom?.hideButtons]); + + // Controlled zoom range: apply external zoomRange prop to the chart. + useEffect(() => { + if (!chartReady || !apiRef.current) { + return; + } + const xAxis = apiRef.current.chart.xAxis[0]; + if (props.zoomRange === undefined) { + // Uncontrolled mode — do nothing. + return; + } + if (props.zoomRange === null) { + // Reset to full range. + xAxis.setExtremes(undefined, undefined); + setZoomMode("idle"); + setZoomedExtremes(null); + } else if (props.zoomRange.x) { + // Apply the controlled range. + const { startValue, endValue } = props.zoomRange.x; + xAxis.setExtremes(startValue, endValue); + setZoomMode("zoomed"); + setZoomedExtremes({ min: Math.min(startValue, endValue), max: Math.max(startValue, endValue) }); + } + }, [chartReady, props.zoomRange]); + + // Keep the cursor-tracking element (and therefore the UAP direction buttons) aligned with the + // cursor line at the bottom of the plot area. + useEffect(() => { + const chart = apiRef.current?.chart; + if (!chart || !cursorTrackRef.current || cursorX === null || !inZoomSelection) { + return; + } + const pixelX = chart.xAxis[0].toPixels(cursorX, false); + cursorTrackRef.current.style.insetInlineStart = `${pixelX}px`; + cursorTrackRef.current.style.insetBlockStart = `${chart.plotTop + chart.plotHeight}px`; + }, [cursorX, inZoomSelection]); + + const enterZoomMode = useCallback(() => { + const chart = apiRef.current?.chart; + // Start the cursor at the first data point so keyboard users have an immediate, visible anchor + // without needing to hover first. When re-entering zoom mode on an already-zoomed chart, only + // consider points within the current visible extremes so the cursor starts on-screen. + const xValues = chart ? getChartXValues(chart) : []; + const extremes = chart?.xAxis[0].getExtremes(); + const visibleXValues = + extremes && extremes.min !== undefined && extremes.max !== undefined + ? xValues.filter((x) => x >= extremes.min && x <= extremes.max) + : xValues; + const initialX = visibleXValues[0] ?? extremes?.min ?? null; + setZoomMode("zoomMode"); + setZoomAnchor(null); + setCursorX(initialX); + setLiveAnnouncement(initialX !== null ? i18n.zoomModeEnteredAnnouncementText(formatXValue(initialX)) : ""); + }, [formatXValue, i18n]); + + const exitZoomMode = useCallback(() => { + // Exiting zoom mode cancels the in-progress selection but preserves any zoom already applied: + // return to "zoomed" when extremes are still in effect, otherwise back to "idle". + setZoomMode(zoomedExtremesRef.current ? "zoomed" : "idle"); + setZoomAnchor(null); + setCursorX(null); + setLiveAnnouncement(""); + }, []); + + // Moves the zoom cursor to an absolute x value (shared by mouse hover, arrow keys, and UAP + // buttons). Only updates state — the cursor line and selection band are drawn declaratively by an + // effect, so they survive Highcharts re-renders (e.g. when zoom mode toggles the tooltip). + const moveCursorTo = useCallback( + (value: number, options: { announce?: boolean } = {}) => { + setCursorX(value); + if (options.announce) { + setLiveAnnouncement( + zoomAnchorRef.current !== null + ? i18n.zoomSelectionAnnouncementText( + formatXValue(Math.min(zoomAnchorRef.current, value)), + formatXValue(Math.max(zoomAnchorRef.current, value)), + ) + : i18n.zoomCursorPositionAnnouncementText(formatXValue(value)), + ); + } + }, + [formatXValue, i18n], + ); + + // Steps the zoom cursor one data point left (-1) or right (+1) via arrow keys / UAP buttons. + const stepCursor = useCallback( + (direction: -1 | 1) => { + const chart = apiRef.current?.chart; + if (!chart || cursorXRef.current === null) { + return; + } + const xValues = getChartXValues(chart); + const next = stepXValue(xValues, cursorXRef.current, direction); + moveCursorTo(next, { announce: true }); + }, + [moveCursorTo], + ); + + // Sets the current zoom point: the first call sets the anchor, the second applies the zoom. + // An explicit value can be passed (e.g. from a click) since state updates are not yet flushed. + const commitPoint = useCallback( + (explicitValue?: number) => { + const value = explicitValue ?? cursorXRef.current; + if (value === null) { + return; + } + if (zoomAnchorRef.current === null) { + // First point → set the anchor. + setZoomAnchor(value); + setZoomMode("selecting"); + setLiveAnnouncement(i18n.zoomStartPointAnnouncementText(formatXValue(value))); + } else if (value !== zoomAnchorRef.current) { + // Second point → apply the zoom. + applyZoom(zoomAnchorRef.current, value); + } + }, + [applyZoom, formatXValue, i18n], + ); + + // Declaratively draw the zoom-mode overlays (cursor line, anchor line, selection band) from + // state. Runs after every render so the overlays are re-applied whenever Highcharts updates the + // chart (which clears imperatively-added plot lines/bands). + useEffect(() => { + const xAxis = apiRef.current?.chart.xAxis[0]; + if (!xAxis) { + return; + } + xAxis.removePlotLine(ZOOM_CURSOR_LINE_ID); + xAxis.removePlotLine(ZOOM_ANCHOR_LINE_ID); + xAxis.removePlotBand(ZOOM_SELECTION_BAND_ID); + if (zoomMode !== "zoomMode" && zoomMode !== "selecting") { + return; + } + if (zoomAnchor !== null) { + xAxis.addPlotLine({ + id: ZOOM_ANCHOR_LINE_ID, + value: zoomAnchor, + color: colorBorderItemFocused, + width: 2, + dashStyle: "Dash", + zIndex: 5, + }); + } + if (cursorX !== null) { + drawCursorLine(xAxis, cursorX); + if (zoomAnchor !== null) { + drawSelectionBand(xAxis, zoomAnchor, cursorX); + } + } + }, [zoomMode, cursorX, zoomAnchor, chartReady]); + + // Declaratively draw the persistent zoom-range affordance (boundary lines + band tint) from + // state. Like the cursor-overlay effect above, it runs after every render so the overlays are + // re-applied whenever Highcharts updates the chart (which clears imperatively-added lines/bands). + // While a new selection is in progress (zoom mode re-entered on top of an existing zoom) the + // affordance is suppressed so the selection cursor/band read the same as a first-time zoom; it is + // restored when the interaction ends (see exitZoomMode returning to "zoomed"). + const isSelectingZoom = zoomMode === "zoomMode" || zoomMode === "selecting"; + useEffect(() => { + const xAxis = apiRef.current?.chart.xAxis[0]; + if (!xAxis) { + return; + } + if (zoomedExtremes && !isSelectingZoom) { + drawZoomRangeBoundaries(xAxis, zoomedExtremes.min, zoomedExtremes.max); + } else { + clearZoomRangeBoundaries(xAxis); + } + }, [zoomedExtremes, isSelectingZoom, chartReady]); + + // Zoom mode: click handling and mouse tracking on the chart container. + useEffect(() => { + if (!zoomEnabled || !chartReady || !apiRef.current) { + return; + } + if (zoomMode !== "zoomMode" && zoomMode !== "selecting") { + return; + } + + const chart = apiRef.current.chart; + const xAxis = chart.xAxis[0]; + const container = chart.container; + + const isInsidePlotX = (chartX: number) => chartX >= chart.plotLeft && chartX <= chart.plotLeft + chart.plotWidth; + + // Mouse move: the cursor (and, while selecting, the highlight band) follows the pointer. + const onMouseMove = (e: MouseEvent) => { + const normalized = normalizePointerEvent(chart, e); + if (!normalized || !isInsidePlotX(normalized.chartX)) { + return; + } + moveCursorTo(xAxis.toValue(normalized.chartX, false)); + }; + + // Click: set the start or end point at the pointer position. + const onClick = (e: MouseEvent) => { + const normalized = normalizePointerEvent(chart, e); + if (!normalized) { + return; + } + const { chartX, chartY } = normalized; + const insideY = chartY >= chart.plotTop && chartY <= chart.plotTop + chart.plotHeight; + if (!isInsidePlotX(chartX) || !insideY) { + return; + } + const value = xAxis.toValue(chartX, false); + moveCursorTo(value); + commitPoint(value); + }; + + // Keyboard: arrows move the cursor, Enter/Space set a point, Escape cancels zoom mode. + const onKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case "ArrowRight": + e.preventDefault(); + stepCursor(1); + break; + case "ArrowLeft": + e.preventDefault(); + stepCursor(-1); + break; + case "Enter": + case " ": + e.preventDefault(); + commitPoint(); + break; + case "Escape": + e.preventDefault(); + exitZoomMode(); + break; + } + }; + + // Delay the click listener so the button click that entered zoom mode doesn't set a point. + const timeoutId = setTimeout(() => { + container.addEventListener("click", onClick); + }, 0); + container.addEventListener("mousemove", onMouseMove); + document.addEventListener("keydown", onKeyDown); + + return () => { + clearTimeout(timeoutId); + container.removeEventListener("click", onClick); + container.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("keydown", onKeyDown); + }; + }, [zoomEnabled, chartReady, zoomMode, moveCursorTo, stepCursor, commitPoint, exitZoomMode]); + + // Tooltip content transformation. const getTooltipContent: CoreChartProps["getTooltipContent"] = () => { - // We use point.series.userOptions to get the series options that were passed down to Highcharts, - // assuming Highcharts makes no modifications for those. These options are not referentially equal - // to the ones we get from the consumer due to the internal validation/transformation we run on them. - // See: https://api.highcharts.com/class-reference/Highcharts.Chart#userOptions. const transformItem = (item: CoreChartProps.TooltipContentItem): CartesianChartProps.TooltipPointItem => { const userOptions = item.point.series.userOptions as NonErrorBarSeriesOptions; - // Restore original threshold type from custom metadata, since transformCartesianSeries - // replaces "x-threshold" and "y-threshold" with "line" for Highcharts compatibility. const originalType = item.point.series.userOptions.custom?.awsui?.type; const series = originalType ? ({ ...userOptions, type: originalType } as NonErrorBarSeriesOptions) @@ -74,15 +593,10 @@ export const InternalCartesianChart = forwardRef( }; const transformSeriesProps = ( props: CoreChartProps.TooltipPointProps, - ): CartesianChartProps.TooltipPointRenderProps => ({ - item: transformItem(props.item), - }); + ): CartesianChartProps.TooltipPointRenderProps => ({ item: transformItem(props.item) }); const transformSlotProps = ( props: CoreChartProps.TooltipSlotProps, - ): CartesianChartProps.TooltipSlotRenderProps => ({ - x: props.x, - items: props.items.map(transformItem), - }); + ): CartesianChartProps.TooltipSlotRenderProps => ({ x: props.x, items: props.items.map(transformItem) }); return { point: tooltip.point ? (coreProps) => tooltip.point!(transformSeriesProps(coreProps)) : undefined, @@ -92,46 +606,183 @@ export const InternalCartesianChart = forwardRef( }; }; - // Converting x-, and y-threshold series to Highcharts series and plot lines. const { series, xPlotLines, yPlotLines } = transformCartesianSeries(props.series, visibleSeriesState); - // Cartesian chart imperative API. useImperativeHandle(ref, () => ({ setVisibleSeries: (visibleSeriesIds) => apiRef.current?.setItemsVisible(visibleSeriesIds), showAllSeries: () => apiRef.current?.setItemsVisible(allSeriesIds), + enterZoomMode, + exitZoomMode, + resetZoom, })); + // The zoom mode button rendered inside the chart plot area (top-right corner). + const zoomModeButton = + zoomEnabled && !props.zoom?.hideButtons ? ( +
+ + {/* Reset (shown while zoomed) and Zoom sit side by side; Reset leads so the Zoom button + keeps its position whether or not the chart is zoomed. During an active selection only + the "Exit zoom" button is shown. */} + + {zoomMode === "zoomed" && ( + + + + )} + {(zoomMode === "idle" || zoomMode === "zoomed") && ( + + + + )} + {(zoomMode === "zoomMode" || zoomMode === "selecting") && ( + + + + )} + +
+ ) : null; + + // Disable tooltip in zoom mode (popovers disabled). + const effectiveTooltip = + zoomMode === "zoomMode" || zoomMode === "selecting" ? { ...tooltip, enabled: false } : tooltip; + return ( - (apiRef.current = api)} - options={{ - chart: { - inverted: props.inverted, - }, - plotOptions: { - series: { stacking: props.stacking }, - }, - series, - xAxis: castArray(props.xAxis)?.map((xAxisProps) => ({ - ...xAxisProps, - title: { text: xAxisProps.title }, - plotLines: xPlotLines, - })), - yAxis: castArray(props.yAxis)?.map((yAxisProps, index) => ({ - ...yAxisProps, - title: { text: yAxisProps.title }, - plotLines: yPlotLines, - ...(index === 1 ? { opposite: true } : {}), - })), - }} - sizeAxis={props.sizeAxis} - tooltip={tooltip} - getTooltipContent={getTooltipContent} - visibleItems={props.visibleSeries} - onVisibleItemsChange={onVisibleSeriesChange} - className={testClasses.root} - /> + <> + { + apiRef.current = api; + setChartReady(true); + // Move the cursor-tracking element into the chart container so the UAP direction buttons + // can be positioned relative to the plot via the portal overlay. + if (cursorTrackRef.current && !api.chart.container.contains(cursorTrackRef.current)) { + api.chart.container.style.position = "relative"; + api.chart.container.appendChild(cursorTrackRef.current); + } + }} + options={{ + chart: { + inverted: props.inverted, + ...(zoomEnabled + ? { + zooming: { type: "x" }, + resetZoomButton: { theme: { style: { display: "none" } } }, + } + : {}), + }, + plotOptions: { series: { stacking: props.stacking } }, + accessibility: { enabled: true, keyboardNavigation: { enabled: true } }, + series, + xAxis: castArray(props.xAxis)?.map((xAxisProps) => ({ + ...xAxisProps, + title: { text: xAxisProps.title }, + plotLines: xPlotLines, + ...(zoomEnabled + ? { + events: { + afterSetExtremes(e: { + min: number; + max: number; + trigger?: string; + userMin?: number; + userMax?: number; + }) { + if (e.trigger === "zoom") { + const zoomed = !!(e.userMin || e.userMax); + if (zoomed) { + setZoomMode("zoomed"); + setZoomedExtremes({ min: e.min, max: e.max }); + setLiveAnnouncement( + i18n.zoomRangeChangeAnnouncementText(formatXValue(e.min), formatXValue(e.max)), + ); + fireNonCancelableEvent(props.onZoomRangeChange, { + zoomRange: { x: { startValue: e.min, endValue: e.max } }, + }); + } else { + setZoomedExtremes(null); + fireNonCancelableEvent(props.onZoomRangeChange, { zoomRange: null }); + } + } + }, + }, + } + : {}), + })), + yAxis: castArray(props.yAxis)?.map((yAxisProps, index) => ({ + ...yAxisProps, + title: { text: yAxisProps.title }, + plotLines: yPlotLines, + ...(index === 1 ? { opposite: true } : {}), + })), + }} + sizeAxis={props.sizeAxis} + tooltip={effectiveTooltip} + getTooltipContent={getTooltipContent} + visibleItems={props.visibleSeries} + onVisibleItemsChange={onVisibleSeriesChange} + className={testClasses.root} + /> + {/* Invisible element tracked by the UAP direction buttons overlay, positioned at the cursor. */} +