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..08e0dd74 100644 --- a/pages/01-cartesian-chart/axes-and-thresholds.page.tsx +++ b/pages/01-cartesian-chart/axes-and-thresholds.page.tsx @@ -3,6 +3,12 @@ import { range } from "lodash"; +import { CartesianChart } from "../../lib/components"; +import { dateFormatter } from "../common/formatters"; +import { useChartSettings } from "../common/page-settings"; +import { Page, PageSection } from "../common/templates"; +import pseudoRandom from "../utils/pseudo-random"; + const addDays = (date: Date, days: number) => { const result = new Date(date); result.setDate(result.getDate() + days); @@ -15,12 +21,6 @@ const subYears = (date: Date, years: number) => { return result; }; -import { CartesianChart } from "../../lib/components"; -import { dateFormatter } from "../common/formatters"; -import { useChartSettings } from "../common/page-settings"; -import { Page, PageSection } from "../common/templates"; -import pseudoRandom from "../utils/pseudo-random"; - export default function () { return ( { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +}; + +// A fixed start date keeps the rendered chart, and the visual regression snapshots, stable. +const seriesStart = new Date("2025-01-01T00:00:00Z"); + +const zoomSeriesData = range(0, 100).map((i) => ({ + x: addDays(seriesStart, 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 }, +]; + +// Pin the axis to the data range, as the other cartesian pages do. Without explicit bounds Highcharts +// derives the range from the data and pads it by 1% at each end, leaving a visible gap between the +// plot edges and the start and end of the series. +const zoomXAxis = { + title: "Time", + type: "datetime", + valueFormatter: dateFormatter, + min: zoomSeriesData[0].x, + max: zoomSeriesData[zoomSeriesData.length - 1].x, +} as const; + +const zoomYAxis = { title: "Count", type: "linear" } as const; + +const SCENARIO_HEIGHT = 300; + +export default function () { + return ( + + {/* The main demo comes first, and directly under the page title, so that a functional test can drag + across its plot without scrolling. Its documentation therefore follows it, rather than preceding it. */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function UncontrolledZoom() { + const { chartProps } = useChartSettings(); + return ( + + ); +} + +// Three points: enough for a single zoom, after which two are left and there is nothing to zoom into. +const fewPointsData = zoomSeriesData.slice(0, 3); + +function FewPointsZoom() { + const { chartProps } = useChartSettings(); + return ( + + ); +} + +function NoDataZoom({ statusType }: { statusType: "finished" | "loading" | "error" }) { + const { chartProps } = useChartSettings(); + return ( + + No data available + + ), + loading: Loading data..., + error: An error occurred, + }} + /> + ); +} + +const filteredSeriesNames = ["Requests", "Avg latency", "SLA limit"]; + +function FilteredZoom() { + const { chartProps } = useChartSettings(); + const [visibleSeries, setVisibleSeries] = useState(filteredSeriesNames); + return ( + + + setVisibleSeries(detail.visibleSeries)} + xAxis={zoomXAxis} + yAxis={zoomYAxis} + /> + + ); +} + +function DualAxisZoom() { + const { chartProps } = useChartSettings(); + return ( + ({ x: d.x, y: d.y / 20 })), + }, + ]} + xAxis={zoomXAxis} + yAxis={[ + { id: "count", title: "Count" }, + { id: "percentage", title: "Error rate (%)" }, + ]} + /> + ); +} + +function SmallChartZoom() { + const { chartProps } = useChartSettings(); + return ( + + + + ); +} + +// The legend position is not part of the cartesian chart's public API, so it is declared through the core +// chart's options, as the page settings do. +const sideLegend: CoreChartProps.LegendOptions = { enabled: true, position: "side" }; + +function SideLegendZoom() { + const { chartProps } = useChartSettings(); + return ( + + ); +} + +// Enough series to fill the legend, over the same x values, so the cursor still steps a point at a time. +const manySeries: CartesianChartProps.SeriesOptions[] = range(0, 12).map((index) => ({ + type: "areaspline", + name: `Availability zone ${index + 1}`, + data: zoomSeriesData.slice(0, 40).map((d) => ({ x: d.x, y: d.y + Math.floor(pseudoRandom() * 50 * (index + 1)) })), +})); + +function ManySeriesZoom() { + const { chartProps } = useChartSettings(); + return ( + // The frame clips whatever leaves it, which is what would cut off a focus outline drawn at the edge of + // the plot. Its size is not annotated, so that nothing is laid over the controls being verified. + +
+ +
+
+ ); +} + +// Five-minute intervals over roughly seventeen days: more points than the plot has pixels, so the cursor +// cannot be stepped through them one by one in reasonable time. +const largeSeriesData = range(0, 5000).map((i) => ({ + x: seriesStart.getTime() + i * 5 * 60 * 1000, + y: Math.floor((pseudoRandom() + i / 2500) * 100), +})); + +function LargeSeriesZoom() { + const { chartProps } = useChartSettings(); + return ( + + ); +} + +function InvertedZoom() { + const { chartProps } = useChartSettings(); + return ( + + ); +} + +const columnCategories = ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6", "Q7", "Q8"]; +const columnSeries: CartesianChartProps.SeriesOptions[] = ["Compute", "Storage", "Network"].map((name, index) => ({ + type: "column", + name, + data: columnCategories.map(() => Math.floor(1000 + pseudoRandom() * 5000 * (index + 1))), +})); + +function ColumnsZoom({ stacked = false }: { stacked?: boolean }) { + const { chartProps } = useChartSettings(); + return ( + + ); +} + +const errorBarsData = zoomSeriesData.slice(0, 20); + +function ErrorBarsZoom() { + // Error bars come from the highcharts-more module, which this page only needs for this one chart. + const { chartProps } = useChartSettings({ more: true }); + return ( + ({ x: d.x, low: d.y * 0.8, high: d.y * 1.2 })), + }, + ]} + xAxis={{ ...zoomXAxis, min: errorBarsData[0].x, max: errorBarsData[errorBarsData.length - 1].x }} + yAxis={zoomYAxis} + /> + ); +} + +function CoreChartZoom() { + const { chartProps } = useChartSettings(); + return ( + + ); +} + +// A range in the middle of the data, to apply from outside the chart. +const fixedZoomRange: CartesianChartProps.ZoomRange = { + x: { startValue: zoomSeriesData[40].x, endValue: zoomSeriesData[60].x }, +}; + +function ControlledZoom() { + const { chartProps } = useChartSettings(); + const chartRef = useRef(null); + // The page settings own a ref of their own, which the "Clear filter" action of the no-match state uses. + const ref = useMergeRefs(chartRef, chartProps.cartesian.ref); + const [zoomRange, setZoomRange] = useState(null); + return ( + + setZoomRange(detail.zoomRange)} + ariaLabel="Chart with custom zoom controls" + series={zoomSeries} + xAxis={zoomXAxis} + yAxis={zoomYAxis} + /> + + + + + + + + + + {zoomRange?.x + ? `Zoomed from ${dateFormatter(zoomRange.x.startValue)} to ${dateFormatter(zoomRange.x.endValue)}` + : "Showing the full data range"} + + + ); +} diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index c4af9741..cfa7b14f 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -21,8 +21,72 @@ exports[`definition for cartesian-chart matches the snapshot > cartesian-chart 1 "detailType": "{ visibleSeries: Array; }", "name": "onVisibleSeriesChange", }, + { + "cancelable": false, + "description": "A callback function, triggered when the zoomed range changes as a result of user interaction with the chart +or the zoom controls. The detail's \`zoomRange\` is \`null\` when the zoom is reset to the full data range.", + "detailInlineType": { + "name": "ZoomChangeDetail", + "properties": [ + { + "inlineType": { + "name": "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": "ZoomRange | null", + }, + ], + "type": "object", + }, + "detailType": "ZoomChangeDetail", + "name": "onZoomRangeChange", + }, ], "functions": [ + { + "description": "Enters zoom mode, in which the tooltip is suppressed and clicks on the chart set the start and end of +the range to zoom into. Requires zooming to be enabled with the \`zoom\` property.", + "name": "enterZoomMode", + "parameters": [], + "returnType": "void", + }, + { + "description": "Exits zoom mode, discarding the range being selected. Any range the chart is already zoomed into is kept.", + "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 +206,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 +246,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 +281,124 @@ Supported Highcharts versions: 12.", "optional": true, "type": "string", }, + { + "name": "zoomControlsAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "zoomCursorAriaLabel", + "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)", + }, + { + "name": "zoomModeExitedAnnouncementText", + "optional": true, + "type": "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)", + }, + { + "name": "zoomResetAnnouncementText", + "optional": true, + "type": "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 +832,74 @@ applies to the tooltip points values.", "optional": true, "type": "CartesianChartProps.YAxisOptions | [CartesianChartProps.YAxisWithId, CartesianChartProps.YAxisWithId]", }, + { + "description": "Zoom settings, allowing the users to zoom into a range of the x-axis. Zooming is possible by dragging +across the chart plot, or by entering zoom mode with the "Zoom" button and selecting the range start and +end with a click, Enter, or Space. In zoom mode the tooltip is suppressed, Escape or the "Exit zoom" +button cancels the selection, and the "Reset" button restores the full data range once zoomed. + +Supported options: +* \`enabled\` (optional, boolean) - Enables zooming. Defaults to \`false\`. +* \`hideButtons\` (optional, boolean) - Hides the built-in zoom buttons. Use it when providing custom +controls, that use the \`enterZoomMode\`, \`exitZoomMode\`, and \`resetZoom\` methods of the component's ref.", + "inlineType": { + "name": "ZoomOptions", + "properties": [ + { + "name": "enabled", + "optional": true, + "type": "boolean", + }, + { + "name": "hideButtons", + "optional": true, + "type": "boolean", + }, + ], + "type": "object", + }, + "name": "zoom", + "optional": true, + "type": "ZoomOptions", + }, + { + "description": "The zoomed range of the x-axis. By default, the range is managed by the component. When using this property, +manage state updates with \`onZoomRangeChange\`, and use \`null\` to show the full data range. + +Supported options: +* \`x\` (optional, object) - The zoomed x-axis range, as \`startValue\` and \`endValue\`. For datetime axes the +values are timestamps in milliseconds.", + "inlineType": { + "name": "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": "ZoomRange | null", + }, ], "regions": [ { @@ -1232,6 +1512,53 @@ exports[`internal core API matches snapshot > internal-core-chart 1`] = ` "name": "onVisibleItemsChange", "systemTags": undefined, }, + { + "cancelable": false, + "deprecatedTag": undefined, + "description": "A callback function, triggered when the zoomed range changes as a result of user interaction with the chart +or the zoom controls. The detail's \`zoomRange\` is \`null\` when the zoom is reset to the full data range.", + "detailInlineType": { + "name": "ZoomChangeDetail", + "properties": [ + { + "inlineType": { + "name": "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": "ZoomRange | null", + }, + ], + "type": "object", + }, + "detailType": "ZoomChangeDetail", + "name": "onZoomRangeChange", + "systemTags": undefined, + }, ], "functions": [], "name": "CoreChart", @@ -1889,6 +2216,85 @@ When set to "side", displays the title along the axis line.", "type": "ReadonlyArray", "visualRefreshTag": undefined, }, + { + "analyticsTag": undefined, + "defaultValue": undefined, + "deprecatedTag": undefined, + "description": "Zoom settings, allowing the users to zoom into a range of the x-axis. Zooming is possible by dragging +across the chart plot, or by entering zoom mode with the "Zoom" button and selecting the range start and +end with a click, a tap, Enter, or Space. + +Supported options: +* \`enabled\` (optional, boolean) - Enables zooming. Defaults to \`false\`. +* \`hideButtons\` (optional, boolean) - Hides the built-in zoom buttons. Use it when providing custom +controls, that use the \`enterZoomMode\`, \`exitZoomMode\`, and \`resetZoom\` methods of the component's ref.", + "i18nTag": undefined, + "inlineType": { + "name": "ZoomOptions", + "properties": [ + { + "name": "enabled", + "optional": true, + "type": "boolean", + }, + { + "name": "hideButtons", + "optional": true, + "type": "boolean", + }, + ], + "type": "object", + }, + "name": "zoom", + "optional": true, + "systemTags": undefined, + "type": "ZoomOptions", + "visualRefreshTag": undefined, + }, + { + "analyticsTag": undefined, + "defaultValue": undefined, + "deprecatedTag": undefined, + "description": "The zoomed range of the x-axis. By default, the range is managed by the component. When using this property, +manage state updates with \`onZoomRangeChange\`, and use \`null\` to show the full data range. + +Supported options: +* \`x\` (optional, object) - The zoomed x-axis range, as \`startValue\` and \`endValue\`. For datetime axes the +values are timestamps in milliseconds.", + "i18nTag": undefined, + "inlineType": { + "name": "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, + "systemTags": undefined, + "type": "ZoomRange | null", + "visualRefreshTag": undefined, + }, ], "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..ac4f187b --- /dev/null +++ b/src/cartesian-chart/__tests__/cartesian-chart-zoom.test.tsx @@ -0,0 +1,1095 @@ +// 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 { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest"; + +import { KeyCode } from "@cloudscape-design/component-toolkit/internal"; + +import "@cloudscape-design/components/test-utils/dom"; +// The error bar series type is only available with the highcharts-more module. +import "highcharts/highcharts-more"; +import { CartesianChartProps } from "../../../lib/components/cartesian-chart"; +import { getSeriesData } from "../../../lib/components/internal/utils/highcharts"; +import { getChart, ref, renderCartesianChart } from "./common"; + +// Every test here renders a real chart, which takes ~2s in jsdom, and the zoom interactions re-render +// it multiple times. +const TEST_TIMEOUT = 15_000; + +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 }, +}; + +const onZoomRangeChange = vi.fn(); + +// Pointer interactions are hit-tested against the plot area, so the chart needs a plot area with a +// real size. Highcharts derives it from two browser APIs that jsdom does not implement faithfully: +// +// 1. SVGElement.getBBox, which jsdom does not provide at all, so all rendered text measures as +// undefined and every axis label offset becomes NaN. +// 2. The computed font size of the axis labels, which Highcharts parses into label metrics. Our +// labels are styled with Cloudscape design tokens (`var(--font-size-body-s, 12px)`), and jsdom +// returns the declaration verbatim instead of resolving it, which parses to NaN. +// +// Both make chart.plotHeight NaN, and every plot-area hit test then fails. Browsers resolve both, +// so the stubs below bring jsdom's geometry in line with the browser rather than papering over a +// product defect. Drag zooming is additionally covered end-to-end in test/functional. +const nativeGetComputedStyle = window.getComputedStyle.bind(window); +const nativeGetBBox = (SVGElement.prototype as { getBBox?: () => DOMRect }).getBBox; + +function resolveCustomProperty(value: string) { + const customPropertyWithFallback = /^var\(\s*--[\w-]+\s*,\s*([^)]+)\)$/.exec(value.trim()); + return customPropertyWithFallback ? customPropertyWithFallback[1].trim() : value; +} + +beforeAll(() => { + (SVGElement.prototype as { getBBox?: () => object }).getBBox = () => ({ x: 0, y: 0, width: 20, height: 12 }); + window.getComputedStyle = ((element: Element, pseudoElement?: null | string) => { + const style = nativeGetComputedStyle(element, pseudoElement); + return new Proxy(style, { + get(target, property) { + if (property === "getPropertyValue") { + return (name: string) => resolveCustomProperty(target.getPropertyValue(name)); + } + const value = Reflect.get(target, property, target); + if (typeof value === "function") { + return value.bind(target); + } + return typeof value === "string" ? resolveCustomProperty(value) : value; + }, + }); + }) as typeof window.getComputedStyle; +}); + +afterAll(() => { + if (nativeGetBBox) { + (SVGElement.prototype as { getBBox?: unknown }).getBBox = nativeGetBBox; + } else { + delete (SVGElement.prototype as { getBBox?: unknown }).getBBox; + } + window.getComputedStyle = nativeGetComputedStyle; +}); + +afterEach(() => { + onZoomRangeChange.mockReset(); + document.querySelectorAll("[data-testid='outside']").forEach((element) => element.remove()); +}); + +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 }; +} + +// The zoom affordances are DOM elements laid over the plot, positioned imperatively and hidden with +// `visibility` so their layout box survives. The overlay is the cursor's parent, and its children are +// in a fixed order (see zoom-overlay.tsx). +function getOverlay() { + const cursor = getChart().findZoomCursor()!.getElement() as HTMLElement; + const overlay = cursor.parentElement!; + const [band, startDivider, endDivider] = Array.from(overlay.children) as HTMLElement[]; + return { overlay, band, startDivider, endDivider, cursor }; +} + +function isVisible(element: HTMLElement) { + return element.style.visibility === "visible"; +} + +// The affordance marking the zoomed range is declared as x-axis plot bands and lines, so it ends up in the +// Highcharts `Axis.plotLinesAndBands` collection, which is not covered with TS. +function getZoomRangeAffordance() { + const xAxis = getCurrentChart().xAxis[0] as unknown as { + plotLinesAndBands: { options: { id?: string; from?: number; to?: number; value?: number } }[]; + userOptions: { plotBands?: { id?: string }[]; plotLines?: { id?: string }[] }; + }; + const findOptions = (id: string) => xAxis.plotLinesAndBands.find((item) => item.options.id === id)?.options ?? null; + const band = findOptions("awsui-zoom-range"); + const startLine = findOptions("awsui-zoom-range-start"); + const endLine = findOptions("awsui-zoom-range-end"); + return { + present: !!band && !!startLine && !!endLine, + band: band && { from: band.from, to: band.to }, + boundaries: startLine && endLine && [startLine.value, endLine.value], + // Everything the axis renders, the affordance included, so that the consumer's own lines can be shown to + // survive next to it. + totalCount: xAxis.plotLinesAndBands.length, + // Axis.update merges the new options over the previous ones, so a collection the zoom leaves out of them + // keeps the affordance of a range that is no longer current. + declaredIds: [...(xAxis.userOptions.plotBands ?? []), ...(xAxis.userOptions.plotLines ?? [])].map(({ id }) => id), + }; +} + +function enterZoomMode() { + getChart().findZoomButton()!.click(); +} + +function pressCursorKey(keyCode: number) { + getChart().findZoomCursor()!.keydown(keyCode); +} + +function pressCursorKeyTimes(keyCode: number, times: number) { + for (let i = 0; i < times; i++) { + pressCursorKey(keyCode); + } +} + +function getCursorAttributes() { + const cursor = getChart().findZoomCursor()!.getElement(); + return { + min: cursor.getAttribute("aria-valuemin"), + max: cursor.getAttribute("aria-valuemax"), + now: cursor.getAttribute("aria-valuenow"), + text: cursor.getAttribute("aria-valuetext"), + }; +} + +// Drives a full keyboard zoom over the visible points, by index: the cursor starts at the first +// visible point, so stepping right N times lands on the N-th visible point. +function keyboardZoomToIndexes(startIndex: number, endIndex: number) { + enterZoomMode(); + pressCursorKeyTimes(KeyCode.right, startIndex); + pressCursorKey(KeyCode.enter); + pressCursorKeyTimes(KeyCode.right, endIndex - startIndex); + pressCursorKey(KeyCode.enter); +} + +// Pointer events are dispatched on the Highcharts container and bubble to the plot wrapper that holds +// the zoom handlers. In jsdom the container has no offset and no scaling, so a chart-relative pixel +// (what Axis.toPixels returns) is also the client coordinate. +function pointerEventAtValue(type: string, value: number, offsetX = 0, init: PointerEventInit = {}) { + const chart = getCurrentChart(); + return new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: 1, + pointerType: "mouse", + button: 0, + buttons: 1, + clientX: chart.xAxis[0].toPixels(value, false) + offsetX, + clientY: chart.plotTop + chart.plotHeight / 2, + ...init, + }); +} + +function dispatchPointer(type: string, value: number, offsetX = 0, init: PointerEventInit = {}) { + const event = pointerEventAtValue(type, value, offsetX, init); + act(() => { + getCurrentChart().container.dispatchEvent(event); + }); +} + +function focusOutsideChart() { + const button = document.createElement("button"); + button.setAttribute("data-testid", "outside"); + document.body.appendChild(button); + act(() => button.focus()); + return button; +} + +describe("CartesianChart: zoom controls", { timeout: TEST_TIMEOUT }, () => { + test("renders no zoom affordances when zoom is not enabled", () => { + renderCartesianChart(defaultProps); + expect(getChart().findZoomButton()).toBe(null); + expect(getChart().findExitZoomButton()).toBe(null); + expect(getChart().findResetZoomButton()).toBe(null); + expect(getChart().findZoomCursor()).toBe(null); + }); + + test("renders the Zoom button in idle state when zoom is enabled", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + expect(getChart().findZoomButton()!.getElement()).toHaveTextContent("Zoom"); + expect(getChart().findZoomButton()!.isDisabled()).toBe(false); + expect(getChart().findExitZoomButton()).toBe(null); + expect(getChart().findResetZoomButton()).toBe(null); + }); + + test("labels the zoom controls region", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + const region = getChart().getElement().querySelector('[role="region"]')!; + expect(region).toHaveAttribute("aria-label", "Chart zoom controls"); + }); + + test("hides the built-in buttons with hideButtons, keeping the cursor available", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true, hideButtons: true } }); + expect(getChart().findZoomButton()).toBe(null); + expect(getChart().findExitZoomButton()).toBe(null); + expect(getChart().findZoomCursor()).not.toBe(null); + }); + + // The controls render in the chart's header area, in normal flow, so they precede the plot in the + // DOM and therefore in the focus order. + test("renders the zoom controls before the plot in DOM order", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + const zoomButton = getChart().findZoomButton()!.getElement(); + const position = zoomButton.compareDocumentPosition(getCurrentChart().container); + expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + test("clicking Zoom enters zoom mode and shows the Exit zoom button", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + expect(getChart().findZoomButton()).toBe(null); + expect(getChart().findExitZoomButton()!.getElement()).toHaveTextContent("Exit zoom"); + }); + + test("clicking Exit zoom returns to idle state", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + 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 restores the full range after a zoom", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + keyboardZoomToIndexes(1, 3); + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + + onZoomRangeChange.mockReset(); + act(() => ref.current!.resetZoom()); + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + expect(onZoomRangeChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { zoomRange: null } })); + }); + + test("ref.resetZoom does not fire an event when the chart is not zoomed", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + act(() => ref.current!.resetZoom()); + expect(onZoomRangeChange).not.toHaveBeenCalled(); + }); + + test("controlled zoomRange applies extremes and shows the Reset button", () => { + const { rerender } = renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, zoomRange: null }); + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + expect(getChart().findResetZoomButton()).toBe(null); + + rerender({ + ...defaultProps, + zoom: { enabled: true }, + zoomRange: { x: { startValue: 1, endValue: 3 } }, + }); + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + expect(getChart().findResetZoomButton()!.getElement()).toHaveTextContent("Reset"); + + rerender({ ...defaultProps, zoom: { enabled: true }, zoomRange: null }); + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + expect(getChart().findResetZoomButton()).toBe(null); + }); + + // The zoomed range is only as visible as the axis labels make it, so while zoomed the range it covers is + // marked with a tint and a boundary line at each end. + test("marks the zoomed range with a band and boundary lines", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + expect(getZoomRangeAffordance().present).toBe(false); + + keyboardZoomToIndexes(1, 3); + expect(getZoomRangeAffordance().band).toEqual({ from: 1, to: 3 }); + expect(getZoomRangeAffordance().boundaries).toEqual([1, 3]); + }); + + test("clears the zoomed range affordance when the zoom is reset", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + keyboardZoomToIndexes(1, 3); + getChart().findResetZoomButton()!.click(); + expect(getZoomRangeAffordance().present).toBe(false); + expect(getZoomRangeAffordance().declaredIds).toEqual([]); + }); + + test("marks the zoomed range of a controlled zoomRange", () => { + const { rerender } = renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, zoomRange: null }); + expect(getZoomRangeAffordance().present).toBe(false); + + rerender({ ...defaultProps, zoom: { enabled: true }, zoomRange: { x: { startValue: 3, endValue: 1 } } }); + // The range is normalized, so a reversed one still produces a band from the lower to the higher value. + expect(getZoomRangeAffordance().band).toEqual({ from: 1, to: 3 }); + + rerender({ ...defaultProps, zoom: { enabled: true }, zoomRange: null }); + expect(getZoomRangeAffordance().present).toBe(false); + }); + + // Zoom mode can be entered on top of an existing zoom, and until a new range is applied the chart is still + // zoomed to the old one, which must stay marked as such. + test("keeps the zoomed range affordance while zoom mode is re-entered", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + keyboardZoomToIndexes(1, 3); + // Zoom mode is entered on top of the existing zoom, which the chart still shows until a new range is + // applied. The cursor now moves over the zoomed range, whose visible points are 1, 2 and 3. + enterZoomMode(); + expect(getZoomRangeAffordance().band).toEqual({ from: 1, to: 3 }); + + pressCursorKey(KeyCode.right); + pressCursorKey(KeyCode.enter); + pressCursorKey(KeyCode.right); + pressCursorKey(KeyCode.enter); + expect(getZoomRangeAffordance().band).toEqual({ from: 2, to: 3 }); + }); + + // The thresholds a consumer declares are rendered as x-axis plot lines, which the affordance appends to + // rather than replaces. + test("keeps the consumer x-axis plot lines while zoomed", () => { + renderCartesianChart({ + ...defaultProps, + zoom: { enabled: true }, + series: [...series, { type: "x-threshold", name: "Peak", value: 2 }], + }); + keyboardZoomToIndexes(1, 3); + // The threshold line plus the affordance's band and two boundary lines. + expect(getZoomRangeAffordance()).toEqual(expect.objectContaining({ present: true, totalCount: 4 })); + }); + + test("controlled zoomRange is not changed by the interaction, which only fires the event", () => { + renderCartesianChart({ + ...defaultProps, + zoom: { enabled: true }, + zoomRange: null, + onZoomRangeChange, + }); + keyboardZoomToIndexes(1, 3); + // The consumer owns the range, so the chart still shows the full range, but the selection is over. + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + expect(getChart().findExitZoomButton()).toBe(null); + expect(onZoomRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 1, endValue: 3 } } } }), + ); + }); + + test("keeps the Zoom button visible alongside Reset while zoomed", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + keyboardZoomToIndexes(1, 4); + expect(getChart().findZoomButton()).not.toBe(null); + expect(getChart().findResetZoomButton()).not.toBe(null); + }); + + test("re-entering zoom mode while zoomed keeps the range", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + keyboardZoomToIndexes(1, 4); + enterZoomMode(); + expect(getChart().findExitZoomButton()).not.toBe(null); + // The Reset button steps aside while a new range is being selected. + expect(getChart().findResetZoomButton()).toBe(null); + expect(getXExtremes()).toEqual({ min: 1, max: 4 }); + }); + + test("exiting a re-zoom returns to the zoomed state with the range intact", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + keyboardZoomToIndexes(1, 4); + enterZoomMode(); + getChart().findExitZoomButton()!.click(); + expect(getChart().findResetZoomButton()).not.toBe(null); + expect(getChart().findZoomButton()).not.toBe(null); + expect(getXExtremes()).toEqual({ min: 1, max: 4 }); + }); + + test("re-zooming narrows the range from the visible window", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + keyboardZoomToIndexes(1, 4); + expect(getXExtremes()).toEqual({ min: 1, max: 4 }); + + onZoomRangeChange.mockReset(); + // The visible window now holds x=1..4, and the cursor starts at its first point (x=1). + keyboardZoomToIndexes(0, 1); + expect(getXExtremes()).toEqual({ min: 1, max: 2 }); + expect(onZoomRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 1, endValue: 2 } } } }), + ); + }); + + test("Escape during a re-zoom returns to the zoomed state without changing the range", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + keyboardZoomToIndexes(1, 4); + + onZoomRangeChange.mockReset(); + enterZoomMode(); + pressCursorKey(KeyCode.right); + pressCursorKey(KeyCode.enter); + pressCursorKey(KeyCode.escape); + expect(getXExtremes()).toEqual({ min: 1, max: 4 }); + expect(getChart().findResetZoomButton()).not.toBe(null); + expect(onZoomRangeChange).not.toHaveBeenCalled(); + }); + + // Zooming to two adjacent points leaves no room for another zoom, so the button is disabled rather + // than doing nothing when pressed. + test("disables the Zoom button once zoomed to the minimum range", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + keyboardZoomToIndexes(0, 1); + expect(getXExtremes()).toEqual({ min: 0, max: 1 }); + expect(getChart().findZoomButton()!.isDisabled()).toBe(true); + // Resetting makes zooming available again. + getChart().findResetZoomButton()!.click(); + expect(getChart().findZoomButton()!.isDisabled()).toBe(false); + }); + + test("disables the Zoom button when no series with data points are visible", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, visibleSeries: [] }); + expect(getChart().findZoomButton()!.isDisabled()).toBe(true); + act(() => ref.current!.enterZoomMode()); + expect(getChart().findExitZoomButton()).toBe(null); + }); + + test("disables the Zoom button for threshold-only charts", () => { + renderCartesianChart({ + ...defaultProps, + series: [{ type: "x-threshold", name: "Peak", value: 2 }], + zoom: { enabled: true }, + }); + expect(getChart().findZoomButton()!.isDisabled()).toBe(true); + }); + + test("leaves zoom mode when the last series is hidden mid-selection", () => { + // The series visibility stays controlled for the lifetime of the component: switching from + // uncontrolled to controlled is not supported and the update would be ignored. + const { rerender } = renderCartesianChart({ + ...defaultProps, + zoom: { enabled: true }, + visibleSeries: ["Requests"], + }); + enterZoomMode(); + expect(getChart().findExitZoomButton()).not.toBe(null); + + rerender({ ...defaultProps, zoom: { enabled: true }, visibleSeries: [] }); + expect(getChart().findExitZoomButton()).toBe(null); + expect(getChart().findZoomButton()!.isDisabled()).toBe(true); + }); + + test("supports i18n overrides for the zoom controls", () => { + renderCartesianChart({ + ...defaultProps, + zoom: { enabled: true }, + i18nStrings: { + enterZoomModeButtonText: "Vergrößern", + exitZoomModeButtonText: "Abbrechen", + resetZoomButtonText: "Zurücksetzen", + zoomControlsAriaLabel: "Zoom-Steuerung", + }, + }); + expect(getChart().getElement().querySelector('[role="region"]')).toHaveAttribute("aria-label", "Zoom-Steuerung"); + expect(getChart().findZoomButton()!.getElement()).toHaveTextContent("Vergrößern"); + + enterZoomMode(); + expect(getChart().findExitZoomButton()!.getElement()).toHaveTextContent("Abbrechen"); + + getChart().findExitZoomButton()!.click(); + keyboardZoomToIndexes(1, 3); + expect(getChart().findResetZoomButton()!.getElement()).toHaveTextContent("Zurücksetzen"); + }); + + test("announces the zoom range change, including with hideButtons", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true, hideButtons: true } }); + act(() => ref.current!.enterZoomMode()); + pressCursorKey(KeyCode.right); + pressCursorKey(KeyCode.enter); + pressCursorKeyTimes(KeyCode.right, 2); + pressCursorKey(KeyCode.enter); + expect(getChart().getElement()).toHaveTextContent("Zoomed from 1 to 3"); + }); +}); + +describe("CartesianChart: zoom keyboard interaction", { timeout: TEST_TIMEOUT }, () => { + test("selects a range with arrow keys and Enter", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + keyboardZoomToIndexes(1, 3); + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + expect(onZoomRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 1, endValue: 3 } } } }), + ); + }); + + test("selects a range with Space", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + pressCursorKey(KeyCode.right); + pressCursorKey(KeyCode.space); + pressCursorKeyTimes(KeyCode.right, 2); + pressCursorKey(KeyCode.space); + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + }); + + test("moves the cursor to the range edges with Home and End", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + pressCursorKey(KeyCode.end); + expect(getCursorAttributes().now).toBe("4"); + pressCursorKey(KeyCode.enter); + pressCursorKey(KeyCode.home); + expect(getCursorAttributes().now).toBe("0"); + pressCursorKey(KeyCode.enter); + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + }); + + test("steps the cursor with PageUp and PageDown", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + // With five points a page step is a single point. + pressCursorKey(KeyCode.pageUp); + expect(getCursorAttributes().now).toBe("1"); + pressCursorKey(KeyCode.pageDown); + expect(getCursorAttributes().now).toBe("0"); + }); + + test("ignores vertical arrow keys on a horizontal axis", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + pressCursorKey(KeyCode.up); + pressCursorKey(KeyCode.down); + expect(getCursorAttributes().now).toBe("0"); + }); + + test("Escape cancels the selection", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + enterZoomMode(); + pressCursorKey(KeyCode.right); + pressCursorKey(KeyCode.enter); + pressCursorKey(KeyCode.escape); + expect(getChart().findZoomButton()).not.toBe(null); + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + expect(onZoomRangeChange).not.toHaveBeenCalled(); + }); + + // A range of a single point has no width, so the chart cannot display it and the user cannot zoom + // out of it. The start point stays set instead. + test("rejects a selection of a single point", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + enterZoomMode(); + pressCursorKey(KeyCode.enter); + pressCursorKey(KeyCode.enter); + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + expect(onZoomRangeChange).not.toHaveBeenCalled(); + // Still selecting, and completing the range from here works. + expect(getChart().findExitZoomButton()).not.toBe(null); + pressCursorKey(KeyCode.right); + pressCursorKey(KeyCode.enter); + expect(getXExtremes()).toEqual({ min: 0, max: 1 }); + }); + + test("entering zoom mode moves focus to the cursor", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + expect(document.activeElement).toBe(getChart().findZoomCursor()!.getElement()); + }); + + test("exposes the cursor position on the slider", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + const cursor = getChart().findZoomCursor()!.getElement(); + expect(cursor).toHaveAttribute("role", "slider"); + expect(cursor).toHaveAttribute("aria-orientation", "horizontal"); + expect(cursor).toHaveAttribute("aria-label", "Zoom range cursor"); + expect(getCursorAttributes()).toEqual({ min: "0", max: "4", now: "0", text: "0" }); + + pressCursorKey(KeyCode.right); + expect(getCursorAttributes()).toEqual({ min: "0", max: "4", now: "1", text: "1" }); + + // Once the start point is set, the value text describes the range being selected. + pressCursorKey(KeyCode.enter); + pressCursorKey(KeyCode.right); + expect(getCursorAttributes()).toEqual({ + min: "0", + max: "4", + now: "2", + text: "Selecting zoom range from 1 to 2", + }); + }); + + test("moves focus to Reset after zooming, and back to Zoom after resetting", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + keyboardZoomToIndexes(1, 3); + expect(document.activeElement).toBe(getChart().findResetZoomButton()!.getElement()); + + getChart().findResetZoomButton()!.click(); + expect(document.activeElement).toBe(getChart().findZoomButton()!.getElement()); + }); + + test("moves focus to the Zoom button when the selection is cancelled", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + pressCursorKey(KeyCode.escape); + expect(document.activeElement).toBe(getChart().findZoomButton()!.getElement()); + }); + + // Focus leaving the chart abandons the selection, so the chart is never left in an active zoom + // state the user cannot see the focus for. + test("exits zoom mode when focus leaves the chart", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + expect(getChart().findExitZoomButton()).not.toBe(null); + + focusOutsideChart(); + expect(getChart().findExitZoomButton()).toBe(null); + expect(getChart().findZoomButton()).not.toBe(null); + }); + + test("keeps zoom mode when focus moves within the chart", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + act(() => (getChart().findExitZoomButton()!.getElement() as HTMLElement).focus()); + expect(getChart().findExitZoomButton()).not.toBe(null); + }); + + // Once zoomed, the cursor may only travel inside the visible window. Highcharts keeps the + // out-of-range points in the series (below cropThreshold), so the stops are recomputed from the + // current extremes rather than from the full data. + test("confines the cursor to the zoomed window", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + keyboardZoomToIndexes(1, 3); + enterZoomMode(); + expect(getCursorAttributes()).toEqual({ min: "1", max: "3", now: "1", text: "1" }); + + pressCursorKeyTimes(KeyCode.right, 5); + expect(getCursorAttributes().now).toBe("3"); + pressCursorKeyTimes(KeyCode.left, 5); + expect(getCursorAttributes().now).toBe("1"); + }); +}); + +describe("CartesianChart: zoom cursor buttons", { timeout: TEST_TIMEOUT }, () => { + test("renders the cursor buttons outside the tab order", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + const buttons = [getChart().findZoomCursorPreviousButton()!, getChart().findZoomCursorNextButton()!]; + for (const button of buttons) { + expect(button.getElement()).toHaveAttribute("tabindex", "-1"); + } + expect(buttons.map((button) => button.getElement().getAttribute("aria-label"))).toEqual([ + "Move zoom cursor left", + "Move zoom cursor right", + ]); + }); + + test("selects a full range with the cursor buttons and Enter", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + enterZoomMode(); + getChart().findZoomCursorNextButton()!.click(); + pressCursorKey(KeyCode.enter); + getChart().findZoomCursorNextButton()!.click(); + pressCursorKey(KeyCode.enter); + expect(getXExtremes()).toEqual({ min: 1, max: 2 }); + expect(onZoomRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 1, endValue: 2 } } } }), + ); + }); + + test("keeps clicking the cursor buttons out of the focus order", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + getChart().findZoomCursorNextButton()!.click(); + // The cursor keeps the focus, so arrow keys continue to work after using the buttons. + expect(document.activeElement).toBe(getChart().findZoomCursor()!.getElement()); + pressCursorKey(KeyCode.right); + expect(getCursorAttributes().now).toBe("2"); + }); + + test("keeps the cursor buttons enabled at the range edges", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + const previousButton = getChart().findZoomCursorPreviousButton()!.getElement() as HTMLButtonElement; + expect(previousButton.disabled).toBe(false); + getChart().findZoomCursorPreviousButton()!.click(); + expect(getCursorAttributes().now).toBe("0"); + expect(previousButton.disabled).toBe(false); + }); +}); + +describe("CartesianChart: zoom overlay", { timeout: TEST_TIMEOUT }, () => { + // The overlay must draw nothing while idle: a highlight left on the chart after a zoom looks like a + // pending selection, and on an empty chart it looks like a rendering artifact. + test("draws nothing while idle", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + const { band, startDivider, endDivider, cursor } = getOverlay(); + for (const element of [band, startDivider, endDivider, cursor]) { + expect(isVisible(element)).toBe(false); + } + }); + + test("draws nothing after a committed zoom", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + keyboardZoomToIndexes(1, 3); + const { band, startDivider, endDivider, cursor } = getOverlay(); + for (const element of [band, startDivider, endDivider, cursor]) { + expect(isVisible(element)).toBe(false); + } + }); + + test("shows the cursor while selecting, and the band once the start point is set", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + expect(isVisible(getOverlay().cursor)).toBe(true); + expect(isVisible(getOverlay().band)).toBe(false); + + pressCursorKey(KeyCode.enter); + expect(isVisible(getOverlay().startDivider)).toBe(true); + + pressCursorKey(KeyCode.right); + expect(isVisible(getOverlay().band)).toBe(true); + }); +}); + +describe("CartesianChart: zoom pointer interaction", { timeout: TEST_TIMEOUT }, () => { + test("dragging across the plot zooms into the dragged range", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + dispatchPointer("pointerdown", 1); + dispatchPointer("pointermove", 3); + dispatchPointer("pointerup", 3); + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + expect(onZoomRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 1, endValue: 3 } } } }), + ); + }); + + // The tooltip is rendered outside the plot wrapper and, with a dense series, sits right next to the + // pointer, so the moves and the release of a drag often land on it rather than on the plot. + test("keeps following a drag whose moves and release land outside the plot", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + const outside = document.createElement("div"); + document.body.appendChild(outside); + try { + dispatchPointer("pointerdown", 1); + act(() => { + outside.dispatchEvent(pointerEventAtValue("pointermove", 3)); + }); + expect(isVisible(getOverlay().band)).toBe(true); + + act(() => { + outside.dispatchEvent(pointerEventAtValue("pointerup", 3)); + }); + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + expect(isVisible(getOverlay().band)).toBe(false); + } finally { + outside.remove(); + } + }); + + // Highcharts measures the chart position against the document, while pointer client coordinates are + // relative to the viewport, so the two only agree when the page is not scrolled. + test("zooms on a drag over a chart further down a scrolled page", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + const scrollY = 1000; + const chart = getCurrentChart(); + vi.spyOn(chart.container, "getBoundingClientRect").mockReturnValue(new DOMRect(0, -scrollY, 600, 400)); + vi.spyOn(window, "pageYOffset", "get").mockReturnValue(scrollY); + delete (chart.pointer as { chartPosition?: unknown }).chartPosition; + // jsdom does not account for the scroll in pageY, which a browser does. + const dispatchScrolled = (type: string, value: number) => { + const clientY = chart.plotTop + chart.plotHeight / 2 - scrollY; + const event = pointerEventAtValue(type, value, 0, { clientY }); + Object.defineProperty(event, "pageY", { value: clientY + scrollY }); + act(() => { + chart.container.dispatchEvent(event); + }); + }; + try { + dispatchScrolled("pointerdown", 1); + dispatchScrolled("pointermove", 3); + dispatchScrolled("pointerup", 3); + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + } finally { + vi.restoreAllMocks(); + } + }); + + test("stops following the pointer once the press ends", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + dispatchPointer("pointerdown", 1); + dispatchPointer("pointerup", 1); + act(() => { + document.body.dispatchEvent(pointerEventAtValue("pointermove", 3)); + }); + expect(isVisible(getOverlay().band)).toBe(false); + expect(onZoomRangeChange).not.toHaveBeenCalled(); + }); + + test("moves the cursor with the pointer hovering the plot in zoom mode", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + const startLeft = getOverlay().cursor.style.left; + dispatchPointer("pointermove", 3, 0, { buttons: 0 }); + expect(getOverlay().cursor.style.left).not.toBe(startLeft); + expect(getCursorAttributes().now).toBe(String(3)); + }); + + test("previews the band with the pointer hovering the plot after the first click", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + dispatchPointer("pointerdown", 1); + dispatchPointer("pointerup", 1); + dispatchPointer("pointermove", 3, 0, { buttons: 0 }); + const { band } = getOverlay(); + expect(isVisible(band)).toBe(true); + expect(parseFloat(band.style.width)).toBeGreaterThan(1); + expect(getCursorAttributes().text).toBe("Selecting zoom range from 1 to 3"); + }); + + // Highcharts keeps tracking the hovered point while the tooltip is suppressed. Were that point kept + // highlighted, the tooltip would open on it as soon as the zoom is applied. + test("does not show the tooltip on the point hovered during a pointer selection once zoomed", async () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + enterZoomMode(); + dispatchPointer("pointerdown", 1); + dispatchPointer("pointerup", 1); + act(() => getSeriesData(getCurrentChart().series[0])[3].onMouseOver()); + dispatchPointer("pointerdown", 3); + dispatchPointer("pointerup", 3); + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + + await act(() => new Promise((resolve) => setTimeout(resolve, 500))); + expect(getChart().findTooltip()).toBe(null); + }); + + test("draws the drag boundaries while dragging", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + dispatchPointer("pointerdown", 1); + dispatchPointer("pointermove", 3); + const { band, startDivider, endDivider } = getOverlay(); + expect(isVisible(band)).toBe(true); + expect(isVisible(startDivider)).toBe(true); + expect(isVisible(endDivider)).toBe(true); + + dispatchPointer("pointerup", 3); + expect(isVisible(getOverlay().band)).toBe(false); + }); + + // A press with a small amount of travel is a click, not a drag: zooming on it would make the chart + // impossible to click. + test("does not zoom on a press that does not pass the drag threshold", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + dispatchPointer("pointerdown", 1); + dispatchPointer("pointermove", 1, 5); + dispatchPointer("pointerup", 1, 5); + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + expect(onZoomRangeChange).not.toHaveBeenCalled(); + }); + + // Capturing the pointer on press would retarget the click that follows away from the Highcharts + // container, and clicking the chart would no longer pin a point. Only a drag may capture it. + test("captures the pointer only once a press turns into a drag", () => { + const setPointerCapture = vi.fn(); + Object.assign(HTMLElement.prototype, { setPointerCapture, hasPointerCapture: () => false }); + try { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + dispatchPointer("pointerdown", 1); + dispatchPointer("pointermove", 1, 5); + expect(setPointerCapture).not.toHaveBeenCalled(); + + dispatchPointer("pointermove", 3); + expect(setPointerCapture).toHaveBeenCalledWith(1); + dispatchPointer("pointerup", 3); + } finally { + delete (HTMLElement.prototype as Partial).setPointerCapture; + delete (HTMLElement.prototype as Partial).hasPointerCapture; + } + }); + + // A drag that stays within one data point would produce a range with no width. + test("does not zoom on a drag covering a single data point", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + dispatchPointer("pointerdown", 1); + dispatchPointer("pointermove", 1, 20); + dispatchPointer("pointerup", 1, 20); + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + expect(onZoomRangeChange).not.toHaveBeenCalled(); + expect(isVisible(getOverlay().band)).toBe(false); + }); + + test("abandons the drag on pointercancel", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + dispatchPointer("pointerdown", 1); + dispatchPointer("pointermove", 3); + dispatchPointer("pointercancel", 3); + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + expect(onZoomRangeChange).not.toHaveBeenCalled(); + expect(isVisible(getOverlay().band)).toBe(false); + }); + + test("clicking twice in zoom mode selects the range", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + enterZoomMode(); + dispatchPointer("pointerdown", 1); + dispatchPointer("pointerup", 1); + expect(isVisible(getOverlay().startDivider)).toBe(true); + + dispatchPointer("pointerdown", 3); + dispatchPointer("pointerup", 3); + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + expect(onZoomRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 1, endValue: 3 } } } }), + ); + }); + + // The click that sets the second boundary ends zoom mode, and must not then pin a point in the chart. + test("keeps the clicks that set the range boundaries away from the chart", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + const onContainerClick = vi.fn(); + getCurrentChart().container.addEventListener("click", onContainerClick); + const click = (value: number) => { + dispatchPointer("pointerdown", value); + dispatchPointer("pointerup", value); + dispatchPointer("click", value); + }; + + enterZoomMode(); + click(1); + click(3); + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + expect(onContainerClick).not.toHaveBeenCalled(); + + click(2); + expect(onContainerClick).toHaveBeenCalledTimes(1); + }); + + test("ignores presses of non-primary mouse buttons", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + dispatchPointer("pointerdown", 1, 0, { button: 2, buttons: 2 }); + dispatchPointer("pointermove", 3); + dispatchPointer("pointerup", 3); + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + expect(onZoomRangeChange).not.toHaveBeenCalled(); + }); +}); + +describe("CartesianChart: zoom with other series types", { timeout: TEST_TIMEOUT }, () => { + // Highcharts computes a minRange of its own (five times the closest data range) when the axis has no + // explicit bounds, and silently widens any narrower range. Grouped column charts hit this most + // visibly, because zooming into a couple of categories appeared to do nothing. + test("zooms into two adjacent points without explicit axis bounds", () => { + renderCartesianChart({ + highcharts, + series: [ + { + type: "column", + name: "A", + data: [ + { x: 0, y: 1 }, + { x: 1, y: 2 }, + { x: 2, y: 3 }, + { x: 3, y: 4 }, + { x: 4, y: 5 }, + ], + }, + { + type: "column", + name: "B", + data: [ + { x: 0, y: 2 }, + { x: 1, y: 3 }, + { x: 2, y: 4 }, + { x: 3, y: 5 }, + { x: 4, y: 6 }, + ], + }, + ], + xAxis: { title: "X", type: "linear" as const }, + yAxis: { title: "Y", type: "linear" as const }, + zoom: { enabled: true }, + onZoomRangeChange, + }); + keyboardZoomToIndexes(1, 2); + expect(getXExtremes()).toEqual({ min: 1, max: 2 }); + expect(onZoomRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 1, endValue: 2 } } } }), + ); + }); + + // Error bar data items may omit x, in which case Highcharts assigns index-based x values that have + // nothing to do with the x values of the series they are linked to. They must not become cursor stops. + test("ignores error bar series when collecting cursor stops", () => { + renderCartesianChart({ + highcharts, + series: [ + { + type: "line", + id: "line", + name: "Line", + data: [ + { x: 0, y: 10 }, + { x: 10, y: 20 }, + { x: 20, y: 30 }, + { x: 30, y: 25 }, + { x: 40, y: 40 }, + ], + }, + { + type: "errorbar", + linkedTo: "line", + name: "Error", + data: [ + { low: 8, high: 12 }, + { low: 18, high: 22 }, + { low: 28, high: 32 }, + { low: 23, high: 27 }, + { low: 38, high: 42 }, + ], + }, + ], + xAxis: { title: "X", type: "linear" as const }, + yAxis: { title: "Y", type: "linear" as const }, + zoom: { enabled: true }, + onZoomRangeChange, + } as any); + enterZoomMode(); + // The index-based x values of the error bars (0..4) do not add stops of their own. + expect(getCursorAttributes()).toEqual({ min: "0", max: "40", now: "0", text: "0" }); + pressCursorKey(KeyCode.right); + expect(getCursorAttributes().now).toBe("10"); + + pressCursorKey(KeyCode.enter); + pressCursorKeyTimes(KeyCode.right, 2); + pressCursorKey(KeyCode.enter); + expect(getXExtremes()).toEqual({ min: 10, max: 30 }); + expect(onZoomRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 10, endValue: 30 } } } }), + ); + }); + + test("zooms an inverted chart with the keyboard", () => { + renderCartesianChart({ + ...defaultProps, + inverted: true, + zoom: { enabled: true }, + onZoomRangeChange, + }); + enterZoomMode(); + const cursor = getChart().findZoomCursor()!.getElement(); + expect(cursor).toHaveAttribute("aria-orientation", "vertical"); + + // On an inverted chart the x axis runs vertically, so the vertical arrows drive the cursor, which + // starts at the visual start of the plot: the top, where the smallest x value is rendered. + expect(getCursorAttributes().now).toBe("0"); + pressCursorKey(KeyCode.down); + expect(getCursorAttributes().now).toBe("1"); + pressCursorKey(KeyCode.enter); + pressCursorKeyTimes(KeyCode.down, 2); + expect(getCursorAttributes().now).toBe("3"); + pressCursorKey(KeyCode.enter); + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + }); +}); diff --git a/src/cartesian-chart/chart-cartesian-internal.tsx b/src/cartesian-chart/chart-cartesian-internal.tsx index aaa50670..cd767066 100644 --- a/src/cartesian-chart/chart-cartesian-internal.tsx +++ b/src/cartesian-chart/chart-cartesian-internal.tsx @@ -25,15 +25,12 @@ export const InternalCartesianChart = forwardRef( ({ tooltip, ...props }: InternalCartesianChartProps, ref: React.Ref) => { const apiRef = useRef(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 +42,10 @@ 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. + // 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 +64,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,26 +77,27 @@ 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. + // The zoom actions are implemented by the core chart, and are no-ops when zooming is not enabled. useImperativeHandle(ref, () => ({ setVisibleSeries: (visibleSeriesIds) => apiRef.current?.setItemsVisible(visibleSeriesIds), showAllSeries: () => apiRef.current?.setItemsVisible(allSeriesIds), + enterZoomMode: () => apiRef.current?.enterZoomMode(), + exitZoomMode: () => apiRef.current?.exitZoomMode(), + resetZoom: () => apiRef.current?.resetZoom(), })); return ( (apiRef.current = api)} + callback={(api) => { + apiRef.current = api; + }} options={{ - chart: { - inverted: props.inverted, - }, - plotOptions: { - series: { stacking: props.stacking }, - }, + chart: { inverted: props.inverted }, + plotOptions: { series: { stacking: props.stacking } }, + accessibility: { enabled: true, keyboardNavigation: { enabled: true } }, series, xAxis: castArray(props.xAxis)?.map((xAxisProps) => ({ ...xAxisProps, diff --git a/src/cartesian-chart/interfaces.ts b/src/cartesian-chart/interfaces.ts index 87bc0210..5920694d 100644 --- a/src/cartesian-chart/interfaces.ts +++ b/src/cartesian-chart/interfaces.ts @@ -109,6 +109,35 @@ export interface CartesianChartProps */ sizeAxis?: CartesianChartProps.SizeAxisOptions | readonly CartesianChartProps.SizeAxisOptions[]; + /** + * Zoom settings, allowing the users to zoom into a range of the x-axis. Zooming is possible by dragging + * across the chart plot, or by entering zoom mode with the "Zoom" button and selecting the range start and + * end with a click, Enter, or Space. In zoom mode the tooltip is suppressed, Escape or the "Exit zoom" + * button cancels the selection, and the "Reset" button restores the full data range once zoomed. + * + * Supported options: + * * `enabled` (optional, boolean) - Enables zooming. Defaults to `false`. + * * `hideButtons` (optional, boolean) - Hides the built-in zoom buttons. Use it when providing custom + * controls, that use the `enterZoomMode`, `exitZoomMode`, and `resetZoom` methods of the component's ref. + */ + zoom?: CartesianChartProps.ZoomOptions; + + /** + * The zoomed range of the x-axis. By default, the range is managed by the component. When using this property, + * manage state updates with `onZoomRangeChange`, and use `null` to show the full data range. + * + * Supported options: + * * `x` (optional, object) - The zoomed x-axis range, as `startValue` and `endValue`. For datetime axes the + * values are timestamps in milliseconds. + */ + zoomRange?: CartesianChartProps.ZoomRange | null; + + /** + * A callback function, triggered when the zoomed range changes as a result of user interaction with the chart + * or the zoom controls. The detail's `zoomRange` is `null` when the zoom is reset to the full data range. + */ + onZoomRangeChange?: NonCancelableEventHandler; + /** * 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`. @@ -132,6 +161,19 @@ export namespace CartesianChartProps { * Use this when implementing clear-filter actions in no-match states. */ showAllSeries(): void; + /** + * Enters zoom mode, in which the tooltip is suppressed and clicks on the chart set the start and end of + * the range to zoom into. Requires zooming to be enabled with the `zoom` property. + */ + enterZoomMode(): void; + /** + * Exits zoom mode, discarding the range being selected. Any range the chart is already zoomed into is kept. + */ + exitZoomMode(): void; + /** + * Resets the zoom to show the full data range. + */ + resetZoom(): void; } export type SeriesOptions = @@ -219,6 +261,13 @@ export namespace CartesianChartProps { export type FilterOptions = CoreTypes.BaseFilterOptions; export type NoDataOptions = CoreTypes.BaseNoDataOptions; + + // Zooming is implemented by the core chart, so the types are shared with it rather than duplicated. + export type ZoomOptions = CoreTypes.CoreChartProps.ZoomOptions; + + export type ZoomRange = CoreTypes.CoreChartProps.ZoomRange; + + export type ZoomChangeDetail = CoreTypes.CoreChartProps.ZoomChangeDetail; } // Internal types diff --git a/src/core/__tests__/chart-core-rendering.test.tsx b/src/core/__tests__/chart-core-rendering.test.tsx index 708c9075..ecc01cf8 100644 --- a/src/core/__tests__/chart-core-rendering.test.tsx +++ b/src/core/__tests__/chart-core-rendering.test.tsx @@ -43,6 +43,9 @@ describe("CoreChart: rendering", () => { highlightChartPoint: expect.any(Function), highlightChartGroup: expect.any(Function), clearChartHighlight: expect.any(Function), + enterZoomMode: expect.any(Function), + exitZoomMode: expect.any(Function), + resetZoom: expect.any(Function), }); }); diff --git a/src/core/__tests__/chart-core-zoom.test.tsx b/src/core/__tests__/chart-core-zoom.test.tsx new file mode 100644 index 00000000..7bdce838 --- /dev/null +++ b/src/core/__tests__/chart-core-zoom.test.tsx @@ -0,0 +1,197 @@ +// 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 { KeyCode } from "@cloudscape-design/component-toolkit/internal"; + +import "@cloudscape-design/components/test-utils/dom"; +import { CoreChartProps } from "../../../lib/components/core/interfaces"; +import { createChartWrapper, renderChart, renderStatefulChart } from "./common"; + +// Every test here renders a real chart, which takes ~2s in jsdom, and the zoom interactions re-render it. +const TEST_TIMEOUT = 15_000; + +// Zooming belongs to the core chart, which is what consumers that build their own chart components +// render. The affordances themselves are covered in depth against CartesianChart; the tests here cover +// what only the core chart can show: that zooming is reachable through the core chart's own properties, +// slots, and API, and that it observes the series visibility the core chart applies from its own effect. + +const series: CoreChartProps.ChartOptions["series"] = [ + { + type: "line", + name: "L1", + data: [ + { x: 0, y: 10 }, + { x: 1, y: 20 }, + { x: 2, y: 30 }, + { x: 3, y: 25 }, + { x: 4, y: 40 }, + ], + }, + { + type: "line", + name: "L2", + data: [ + { x: 0, y: 40 }, + { x: 1, y: 25 }, + { x: 2, y: 30 }, + { x: 3, y: 20 }, + { x: 4, y: 10 }, + ], + }, +]; + +const defaultProps = { + highcharts, + options: { series, xAxis: { min: 0, max: 4 } }, + zoom: { enabled: true }, +}; + +const onZoomRangeChange = vi.fn(); + +afterEach(() => { + onZoomRangeChange.mockReset(); +}); + +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 }; +} + +function enterZoomMode() { + createChartWrapper().findZoomButton()!.click(); +} + +function pressCursorKey(keyCode: number, times = 1) { + for (let i = 0; i < times; i++) { + createChartWrapper().findZoomCursor()!.keydown(keyCode); + } +} + +// Drives a full keyboard zoom over the visible points, by index: the cursor starts at the first visible +// point, so stepping right N times lands on the N-th visible point. +function keyboardZoomToIndexes(startIndex: number, endIndex: number) { + enterZoomMode(); + pressCursorKey(KeyCode.right, startIndex); + pressCursorKey(KeyCode.enter); + pressCursorKey(KeyCode.right, endIndex - startIndex); + pressCursorKey(KeyCode.enter); +} + +describe("CoreChart: zoom", { timeout: TEST_TIMEOUT }, () => { + test("renders no zoom affordances when zoom is not enabled", () => { + const { wrapper } = renderChart({ ...defaultProps, zoom: undefined }); + expect(wrapper.findZoomButton()).toBe(null); + expect(wrapper.findExitZoomButton()).toBe(null); + expect(wrapper.findResetZoomButton()).toBe(null); + expect(wrapper.findZoomCursor()).toBe(null); + }); + + // The zoom controls render in the chart's header area, so they precede the plot in the DOM and in the + // focus order, while a navigator given by the consumer keeps its own place after the plot. + test("renders the zoom controls before the plot, leaving the navigator slot to the consumer", () => { + const { wrapper } = renderChart({ + ...defaultProps, + navigator:
Custom navigator
, + }); + + const navigator = wrapper.findNavigator()!; + const zoomButton = wrapper.findZoomButton()!.getElement(); + const container = getCurrentChart().container; + + expect(navigator.getElement()).toHaveTextContent("Custom navigator"); + expect(navigator.getElement().contains(zoomButton)).toBe(false); + expect(zoomButton.compareDocumentPosition(container) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(navigator.getElement().compareDocumentPosition(container) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy(); + }); + + test("zooms into a range with the keyboard and resets it", () => { + const { wrapper } = renderChart({ ...defaultProps, onZoomRangeChange }); + + keyboardZoomToIndexes(1, 3); + + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + expect(onZoomRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 1, endValue: 3 } } } }), + ); + + onZoomRangeChange.mockReset(); + wrapper.findResetZoomButton()!.click(); + + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + expect(onZoomRangeChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { zoomRange: null } })); + }); + + test("takes the zoomed range from the zoomRange property when it is controlled", () => { + const { wrapper, rerender } = renderChart({ ...defaultProps, zoomRange: null, onZoomRangeChange }); + + keyboardZoomToIndexes(1, 3); + + // The consumer owns the range, so the chart still shows the full one and only announces the change. + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + expect(onZoomRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 1, endValue: 3 } } } }), + ); + + rerender({ ...defaultProps, zoomRange: { x: { startValue: 1, endValue: 3 } }, onZoomRangeChange }); + + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + expect(wrapper.findResetZoomButton()).not.toBe(null); + }); + + test("exposes the zoom methods on the chart API", () => { + let chartApi: CoreChartProps.ChartAPI | null = null; + const { wrapper } = renderChart({ ...defaultProps, callback: (api) => (chartApi = api) }); + + act(() => chartApi!.enterZoomMode()); + expect(wrapper.findExitZoomButton()).not.toBe(null); + + act(() => chartApi!.exitZoomMode()); + expect(wrapper.findExitZoomButton()).toBe(null); + + keyboardZoomToIndexes(1, 3); + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + + act(() => chartApi!.resetZoom()); + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + }); + + // The core chart applies the visibleItems property to the series from an effect of its own, and the + // zoom reconciles after that effect: it has to see the series as the chart leaves them, or it keeps + // offering a range that is no longer in the plot. + test("leaves zoom mode when the remaining series are hidden mid-selection", () => { + // The series visibility stays controlled for the lifetime of the component: switching from + // uncontrolled to controlled is not supported and the update would be ignored. + const { wrapper, rerender } = renderChart({ ...defaultProps, visibleItems: ["L1", "L2"] }); + + enterZoomMode(); + expect(wrapper.findExitZoomButton()).not.toBe(null); + + rerender({ ...defaultProps, visibleItems: [] }); + + expect(wrapper.findExitZoomButton()).toBe(null); + // With no points left there is no range to select, so the button has nothing to offer. + expect(wrapper.findZoomButton()!.isDisabled()).toBe(true); + }); + + test("zooms over the points of the series that are still visible", () => { + renderStatefulChart({ ...defaultProps, visibleItems: ["L2"], onZoomRangeChange }); + + // L1 is hidden, and the range is selected over the points of L2, which span the same x values. + keyboardZoomToIndexes(1, 3); + + expect(getXExtremes()).toEqual({ min: 1, max: 3 }); + expect(onZoomRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 1, endValue: 3 } } } }), + ); + }); +}); diff --git a/src/core/chart-api/chart-extra-context.tsx b/src/core/chart-api/chart-extra-context.tsx index de1a10c5..4b1dd8cd 100644 --- a/src/core/chart-api/chart-extra-context.tsx +++ b/src/core/chart-api/chart-extra-context.tsx @@ -106,7 +106,7 @@ function computeDerivedState(chart: SafeChart): ChartExtraContext.DerivedState { // Although "d" can't be undefined according to Highcharts API, it does become undefined for chart containing more datapoints // than the cropThreshold for that series (specific cases of re-rendering the chart with updated options listening to setExteme updates) - if (d.visible && d.y !== null) { + if (d.visible && d.y !== null && isPointWithinXExtremes(d)) { seriesPoints.push(d); allXSet.add(d.x); addPoint(d); @@ -146,6 +146,19 @@ function computeDerivedState(chart: SafeChart): ChartExtraContext.DerivedState { }; } +// Highcharts only crops series.points to the visible x range, and only once the series exceeds +// cropThreshold. Below that threshold every point stays in the series, so a zoomed-in chart would +// otherwise let keyboard navigation and the tooltip reach points that are not on screen. +function isPointWithinXExtremes(point: Highcharts.Point): boolean { + // Series without an x axis (pie) are never cropped by x. + const xAxis = point.series.xAxis; + if (!xAxis) { + return true; + } + const { min, max } = xAxis.getExtremes(); + return (typeof min !== "number" || point.x >= min) && (typeof max !== "number" || point.x <= max); +} + // The points are sorted to ensure consistent navigation, including inverted chart orientation. // The order of series in grouped column charts is kept, while stacked series are sorted by their // respective positions in the plot. diff --git a/src/core/chart-api/chart-extra-highlight.ts b/src/core/chart-api/chart-extra-highlight.ts index 07008042..45df5671 100644 --- a/src/core/chart-api/chart-extra-highlight.ts +++ b/src/core/chart-api/chart-extra-highlight.ts @@ -12,6 +12,7 @@ import { SafeChart, SafeSeries, } from "../../internal/utils/highcharts"; +import { isZoomAffordanceId } from "../chart-zoom/zoom-affordance"; import { getPointId, getSeriesId } from "../utils"; import { ChartExtraContext } from "./chart-extra-context"; @@ -225,7 +226,9 @@ function iteratePlotLines(chart: SafeChart, cb: (lineId: string, line: Highchart axis.plotLinesAndBands.forEach((line: Highcharts.PlotLineOrBand) => { // We explicitly do not touch plot lines that have no ID, assuming those are decorative. // Only plot lines that define ID can be dimmed when certain series get highlighted. - if (line.options.id) { + // The zoom range affordance is excluded too: its ID marks the zoomed range rather than a series, + // so dimming it would make the range tint flicker on every highlight. + if (line.options.id && !isZoomAffordanceId(line.options.id)) { cb(line.options.id, line); } }); diff --git a/src/core/chart-container.tsx b/src/core/chart-container.tsx index 17eead95..38fcc025 100644 --- a/src/core/chart-container.tsx +++ b/src/core/chart-container.tsx @@ -27,6 +27,9 @@ interface ChartContainerProps { verticalAxisTitlePlacement: "top" | "side"; header?: React.ReactNode; filter?: React.ReactNode; + // The zoom controls belong to the header area: they precede the plot both visually and in the focus + // order, so they cannot overlap the plot, its axis titles, or a legend placed to the side. + zoomControls?: React.ReactNode; navigator?: React.ReactNode; primaryLegend?: React.ReactNode; secondaryLegend?: React.ReactNode; @@ -46,6 +49,7 @@ export function ChartContainer({ verticalAxisTitlePlacement, header, filter, + zoomControls, footer, primaryLegend, secondaryLegend, @@ -80,6 +84,7 @@ export function ChartContainer({
{header} {filter} + {zoomControls}
{hasLegend && legendPosition === "side" ? ( diff --git a/src/core/chart-core.tsx b/src/core/chart-core.tsx index adf28c27..7c340efc 100644 --- a/src/core/chart-core.tsx +++ b/src/core/chart-core.tsx @@ -22,6 +22,7 @@ import { castArray } from "../internal/utils/utils"; import { useChartAPI } from "./chart-api"; import { ChartExtraContext } from "./chart-api/chart-extra-context"; import { ChartContainer } from "./chart-container"; +import { useChartZoom } from "./chart-zoom/use-chart-zoom"; import { ChartApplication } from "./components/core-application"; import { ChartFilters } from "./components/core-filters"; import { ChartLegend } from "./components/core-legend"; @@ -52,6 +53,9 @@ export function InternalCoreChart({ tooltip: tooltipOptions, noData: noDataOptions, navigator, + zoom: zoomOptions, + zoomRange, + onZoomRangeChange, legend: legendOptions, fallback = , callback, @@ -74,11 +78,26 @@ export function InternalCoreChart({ }: CoreChartProps & InternalBaseComponentProps) { const highcharts = rest.highcharts as null | typeof Highcharts; const labels = useChartI18n({ ariaLabel, ariaDescription, i18nStrings }); + const rootRef = useRef(null); + const inverted = !!options.chart?.inverted; + const isRtl = getIsRtl(rootRef.current); + const zoom = useChartZoom({ + zoom: zoomOptions, + zoomRange, + onZoomRangeChange, + i18nStrings, + series: options.series, + inverted, + isRtl, + rootRef, + }); const context: ChartExtraContext["settings"] = { chartId: useUniqueId(), noDataEnabled: !!noDataOptions, legendEnabled: legendOptions?.enabled !== false, - tooltipEnabled: tooltipOptions?.enabled !== false, + // While a zoom range is being selected the pointer sets the range boundaries, so a tooltip following + // it would only obstruct the plot. + tooltipEnabled: tooltipOptions?.enabled !== false && !zoom.tooltipSuppressed, keyboardNavigationEnabled: keyboardNavigation, labels, getItemOptions: getItemOptions ?? (() => ({})), @@ -87,8 +106,31 @@ export function InternalCoreChart({ const state = { visibleItems }; const api = useChartAPI(context, handlers, state); + // The chart API is handed out once, when Highcharts initializes, while the zoom actions are re-created on + // every render. The mirror ref lets the handed-out methods delegate to the current ones. + const zoomRef = useRef(zoom); + zoomRef.current = zoom; + const zoomApi = useRef({ + enterZoomMode: () => zoomRef.current.enterZoomMode(), + exitZoomMode: () => zoomRef.current.exitZoomMode(), + resetZoom: () => zoomRef.current.resetZoom(), + }).current; + + // The zoom needs to dismiss a highlighted point when a selection starts, and the API only exists once + // the context above is built. + useEffect(() => { + zoom.clearHighlightRef.current = () => api.clearChartHighlight({ isApiCall: false }); + }); + + // The zoom is reconciled from here, and not from an effect inside its own hook, because it must observe + // the chart as the API leaves it: the API applies the visibleItems property to the series from an effect + // of its own, and an effect declared inside useChartZoom would run before that and still see the previous + // set of visible series. No dependencies, because any render can move the plot or change the data. + useEffect(() => { + zoom.reconcileAfterRender(); + }); + const rootClassName = clsx(testClasses.root, styles.root, fitHeight && styles["root-fit-height"], className); - const rootRef = useRef(null); const mergedRootRef = useMergeRefs(rootRef, __internalRootRef); const rootProps = { ref: mergedRootRef, className: rootClassName, ...getDataAttributes(rest) }; const legendPosition = legendOptions?.position ?? "bottom"; @@ -152,8 +194,6 @@ export function InternalCoreChart({ } const apiOptions = api.getOptions(); - const inverted = !!options.chart?.inverted; - const isRtl = getIsRtl(rootRef?.current); // The Highcharts options takes all provided Highcharts options and custom properties and merges them together, so that // the Cloudscape features and custom Highcharts extensions co-exist. @@ -222,7 +262,7 @@ export function InternalCoreChart({ }, // We use the rtl adjusted axes (instead of the original options.xAxis/yAxis) to ensure the chart renders // with the correct axis orientation for RTL layouts, matching what was used for legend positioning above. - xAxis: castArray(options.xAxis)?.map((xAxisOptions) => ({ + xAxis: castArray(options.xAxis)?.map((xAxisOptions, index) => ({ ...Styles.xAxisOptions, ...xAxisOptions, // Depending on the chart.inverted the x-axis can be rendered as vertical, and needs to respect page direction. @@ -231,6 +271,10 @@ export function InternalCoreChart({ className: xAxisClassName(inverted, xAxisOptions.className), title: axisTitle(xAxisOptions.title ?? {}, !inverted || verticalAxisTitlePlacement === "side"), labels: axisLabels(xAxisOptions.labels ?? {}), + // The zoom applies to the primary x-axis only: it is the axis the cursor and the drag selection + // are measured against. The extremes are declared in the options, rather than imperatively set, + // because Highcharts re-initializes the axes on every React re-render. + ...(index === 0 ? zoom.getXAxisZoomOptions(xAxisOptions) : {}), })), yAxis: castArray(options.yAxis)?.map((yAxisOptions) => ({ ...Styles.yAxisOptions, @@ -310,6 +354,9 @@ export function InternalCoreChart({ }, render(event) { apiOptions.onChartRender.call(this, event); + // The zoom overlay is positioned against the plot, and the cached axis values depend on the + // visible range, so both need to be refreshed whenever Highcharts re-draws. + zoom.onChartRender(this); return options.chart?.events?.render?.call(this, event); }, click(event) { @@ -346,18 +393,28 @@ export function InternalCoreChart({ }, }; return ( - <> + // The anchor establishes the positioning context for the zoom overlay, which is drawn on top of the + // plot with DOM elements. Highcharts re-initializes its SVG on every React re-render, so imperatively + // added plot bands would not survive. +
- callback?.({ chart, highcharts: highcharts as typeof Highcharts, ...api.publicApi }) + callback?.({ + chart, + highcharts: highcharts as typeof Highcharts, + ...api.publicApi, + ...zoomApi, + }) } /> - + {zoom.overlay} +
); }} + zoomControls={zoom.controls} navigator={navigator} primaryLegend={ context.legendEnabled && legendProps.primary ? ( @@ -400,6 +457,8 @@ export function InternalCoreChart({ api={api} /> )} + + {zoom.liveRegion} ); } diff --git a/src/core/chart-zoom/__tests__/zoom-geometry.test.ts b/src/core/chart-zoom/__tests__/zoom-geometry.test.ts new file mode 100644 index 00000000..5e22571c --- /dev/null +++ b/src/core/chart-zoom/__tests__/zoom-geometry.test.ts @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type Highcharts from "highcharts"; +import { describe, expect, test } from "vitest"; + +import { getClusterRect } from "../zoom-geometry"; + +// A chart with a 40px gutter on each side of the plot, and an x axis mapping a value directly to a pixel. +const chart = { + chartWidth: 600, + chartHeight: 400, + plotLeft: 40, + plotTop: 40, + plotWidth: 520, + plotHeight: 320, +} as Highcharts.Chart; +const clusterSize = { width: 52, height: 24 }; + +function axis(horiz: boolean) { + return { horiz, toPixels: (value: number) => value } as unknown as Highcharts.Axis; +} + +describe("getClusterRect", () => { + test.each([ + ["the first point", 40], + ["a middle point", 300], + ["the last point", 560], + ])("centers the cluster on a cursor at %s of a horizontal axis", (_, pixel) => { + const rect = getClusterRect(chart, axis(true), pixel, clusterSize); + expect(rect.left + rect.width / 2).toBe(pixel); + expect(rect.top).toBe(40 + 320 - 24 - 8); + }); + + test.each([ + ["the first point", 40], + ["the last point", 360], + ])("centers the cluster on a cursor at %s of a vertical axis", (_, pixel) => { + const rect = getClusterRect(chart, axis(false), pixel, clusterSize); + expect(rect.top + rect.height / 2).toBe(pixel); + expect(rect.left).toBe(40 + 8); + }); + + test("keeps the cluster centered when the gutter beside the plot is narrower than the cluster", () => { + const narrowGutters = { ...chart, plotLeft: 10, plotWidth: 580 } as Highcharts.Chart; + expect(getClusterRect(narrowGutters, axis(true), 10, clusterSize).left).toBe(10 - 26); + expect(getClusterRect(narrowGutters, axis(true), 590, clusterSize).left).toBe(590 - 26); + }); +}); diff --git a/src/core/chart-zoom/__tests__/zoom-state-machine.test.ts b/src/core/chart-zoom/__tests__/zoom-state-machine.test.ts new file mode 100644 index 00000000..d8b24ea7 --- /dev/null +++ b/src/core/chart-zoom/__tests__/zoom-state-machine.test.ts @@ -0,0 +1,307 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from "vitest"; + +import { + getInteraction, + getOverlayView, + getPress, + IDLE_ZOOM_STATE, + ZoomEffect, + ZoomEvent, + zoomReducer, + ZoomState, +} from "../../../../lib/components/core/chart-zoom/zoom-state-machine"; + +// The machine is pure, so it can be tabulated: the table below states the outcome of every event in +// every state, which is the whole behaviour of the zoom interaction. The cases after it cover what a +// single transition cannot show: the sequences the three ways of selecting a range are made of. + +// Ten stops, which is more than the two a range needs, so zooming is possible throughout. +const COUNT = 10; + +const SELECTION = { cursorIndex: 3, anchorIndex: null }; +const SELECTING: ZoomState = { type: "selecting", selection: SELECTION }; +// A selection whose start point is already set, and which is therefore one commit away from zooming. +const STARTED = { cursorIndex: 5, anchorIndex: 2 }; +const PRESS = { pointerId: 1, clientX: 100, clientY: 100, startIndex: 4, currentIndex: 4 }; +const OTHER_PRESS = { pointerId: 2, clientX: 200, clientY: 200, startIndex: 7, currentIndex: 7 }; +// A press over a chart that is not in zoom mode, and one that interrupted a selection in progress. +const PRESSED: ZoomState = { type: "pressed", press: PRESS, resume: null }; +const PRESSED_IN_ZOOM: ZoomState = { type: "pressed", press: PRESS, resume: STARTED }; +const DRAGGING: ZoomState = { type: "dragging", press: { ...PRESS, currentIndex: 8 }, resume: null }; +const DRAGGING_IN_ZOOM: ZoomState = { type: "dragging", press: { ...PRESS, currentIndex: 8 }, resume: STARTED }; + +const ENTER: ZoomEvent = { type: "enterZoomMode", startIndex: 0 }; +const EXIT: ZoomEvent = { type: "exitZoomMode", moveFocus: true }; +const RESET: ZoomEvent = { type: "resetZoom", zoomed: true, moveFocus: true }; +const RESET_UNZOOMED: ZoomEvent = { type: "resetZoom", zoomed: false, moveFocus: true }; +const STEP: ZoomEvent = { type: "stepCursor", offset: 1 }; +const TO_EDGE: ZoomEvent = { type: "moveCursorToEdge", edge: "last" }; +const COMMIT: ZoomEvent = { type: "commitPoint" }; +const DOWN: ZoomEvent = { type: "pointerDown", press: OTHER_PRESS }; +// A move of the pointer that is holding the press, either past the drag threshold or short of it. +const DRAG_MOVE: ZoomEvent = { type: "pointerMove", pointerId: 1, index: 8, passedThreshold: true, insidePlot: true }; +const SMALL_MOVE: ZoomEvent = { type: "pointerMove", pointerId: 1, index: 8, passedThreshold: false, insidePlot: true }; +const UP: ZoomEvent = { type: "pointerUp", pointerId: 1 }; +const CANCEL: ZoomEvent = { type: "pointerCancel", pointerId: 1 }; +const RENDERED: ZoomEvent = { type: "chartRendered" }; + +// The state a new selection starts in, and the effects that announce it and give it the focus. +const FRESH: ZoomState = { type: "selecting", selection: { cursorIndex: 0, anchorIndex: null } }; +const ENTERED = ["clearHighlight", "announce", "focus"]; +const EXITED = ["announce", "focus"]; +const RESETTED = ["resetZoom", "focus"]; +const ZOOMED = ["applyZoom", "focus"]; + +// Every state, against every event. "same" means the event means nothing in that state: the machine +// returns the state it was given, unchanged and with no effects, and the hook can then skip the work +// a transition would cause. +const SAME = "same"; + +const TRANSITIONS: [name: string, from: ZoomState, event: ZoomEvent, to: ZoomState | typeof SAME, effects: string[]][] = + [ + // Outside an interaction, only entering zoom mode, resetting a zoom, and a press mean anything. + ["idle + enterZoomMode", IDLE_ZOOM_STATE, ENTER, FRESH, ENTERED], + ["idle + exitZoomMode", IDLE_ZOOM_STATE, EXIT, SAME, []], + ["idle + resetZoom", IDLE_ZOOM_STATE, RESET, IDLE_ZOOM_STATE, RESETTED], + ["idle + resetZoom, not zoomed", IDLE_ZOOM_STATE, RESET_UNZOOMED, IDLE_ZOOM_STATE, []], + ["idle + stepCursor", IDLE_ZOOM_STATE, STEP, SAME, []], + ["idle + moveCursorToEdge", IDLE_ZOOM_STATE, TO_EDGE, SAME, []], + ["idle + commitPoint", IDLE_ZOOM_STATE, COMMIT, SAME, []], + ["idle + pointerDown", IDLE_ZOOM_STATE, DOWN, { type: "pressed", press: OTHER_PRESS, resume: null }, []], + ["idle + pointerMove", IDLE_ZOOM_STATE, DRAG_MOVE, SAME, []], + ["idle + pointerUp", IDLE_ZOOM_STATE, UP, SAME, []], + ["idle + pointerCancel", IDLE_ZOOM_STATE, CANCEL, SAME, []], + ["idle + chartRendered", IDLE_ZOOM_STATE, RENDERED, SAME, []], + + // While selecting, the cursor moves and the range is committed a boundary at a time. + ["selecting + enterZoomMode", SELECTING, ENTER, FRESH, ENTERED], + ["selecting + exitZoomMode", SELECTING, EXIT, IDLE_ZOOM_STATE, EXITED], + ["selecting + resetZoom", SELECTING, RESET, IDLE_ZOOM_STATE, RESETTED], + ["selecting + stepCursor", SELECTING, STEP, { type: "selecting", selection: { cursorIndex: 4, anchorIndex: null } }, []], // prettier-ignore + ["selecting + moveCursorToEdge", SELECTING, TO_EDGE, { type: "selecting", selection: { cursorIndex: 9, anchorIndex: null } }, []], // prettier-ignore + ["selecting + commitPoint", SELECTING, COMMIT, { type: "selecting", selection: { cursorIndex: 3, anchorIndex: 3 } }, ["announce"]], // prettier-ignore + ["selecting + pointerDown", SELECTING, DOWN, { type: "pressed", press: OTHER_PRESS, resume: SELECTION }, []], // prettier-ignore + // With no press in progress the cursor follows the pointer, so that a click sets the boundary it shows. + ["selecting + pointerMove", SELECTING, DRAG_MOVE, { type: "selecting", selection: { cursorIndex: 8, anchorIndex: null } }, []], // prettier-ignore + ["selecting + pointerMove outside the plot", SELECTING, { ...DRAG_MOVE, insidePlot: false }, SAME, []], + ["selecting + pointerUp", SELECTING, UP, SAME, []], + ["selecting + pointerCancel", SELECTING, CANCEL, SAME, []], + ["selecting + chartRendered", SELECTING, RENDERED, SELECTING, []], + + // A press is watched until it either travels far enough to be a drag, or ends as a click. + ["pressed + enterZoomMode", PRESSED, ENTER, FRESH, ENTERED], + ["pressed + exitZoomMode", PRESSED, EXIT, SAME, []], + ["pressed + resetZoom", PRESSED, RESET, IDLE_ZOOM_STATE, RESETTED], + ["pressed + stepCursor", PRESSED, STEP, SAME, []], + ["pressed + moveCursorToEdge", PRESSED, TO_EDGE, SAME, []], + ["pressed + commitPoint", PRESSED, COMMIT, SAME, []], + ["pressed + pointerDown", PRESSED, DOWN, { type: "pressed", press: OTHER_PRESS, resume: null }, []], + ["pressed + pointerMove past the threshold", PRESSED, DRAG_MOVE, DRAGGING, ["clearHighlight"]], + ["pressed + pointerMove short of it", PRESSED, SMALL_MOVE, SAME, []], + ["pressed + pointerMove of another pointer", PRESSED, { ...DRAG_MOVE, pointerId: 2 }, SAME, []], + // Outside zoom mode the chart has handled the click itself, so the press just ends. + ["pressed + pointerUp", PRESSED, UP, IDLE_ZOOM_STATE, []], + ["pressed + pointerUp of another pointer", PRESSED, { ...UP, pointerId: 2 }, SAME, []], + ["pressed + pointerCancel", PRESSED, CANCEL, IDLE_ZOOM_STATE, []], + ["pressed + chartRendered", PRESSED, RENDERED, PRESSED, []], + + // A press in zoom mode belongs to the selection it interrupted, and returns to it. + ["pressed in zoom mode + exitZoomMode", PRESSED_IN_ZOOM, EXIT, IDLE_ZOOM_STATE, EXITED], + ["pressed in zoom mode + pointerMove past the threshold", PRESSED_IN_ZOOM, DRAG_MOVE, DRAGGING_IN_ZOOM, ["clearHighlight"]], // prettier-ignore + // The click sets the boundary where it landed, which completes the range this selection had started. + ["pressed in zoom mode + pointerUp", PRESSED_IN_ZOOM, UP, IDLE_ZOOM_STATE, ZOOMED], + ["pressed in zoom mode + pointerCancel", PRESSED_IN_ZOOM, CANCEL, { type: "selecting", selection: STARTED }, []], + ["pressed in zoom mode + pointerDown", PRESSED_IN_ZOOM, DOWN, { type: "pressed", press: OTHER_PRESS, resume: STARTED }, []], // prettier-ignore + + // A drag draws the range as it goes, and applies it when it ends. + ["dragging + enterZoomMode", DRAGGING, ENTER, FRESH, ENTERED], + ["dragging + exitZoomMode", DRAGGING, EXIT, IDLE_ZOOM_STATE, EXITED], + ["dragging + resetZoom", DRAGGING, RESET, IDLE_ZOOM_STATE, RESETTED], + ["dragging + stepCursor", DRAGGING, STEP, SAME, []], + ["dragging + moveCursorToEdge", DRAGGING, TO_EDGE, SAME, []], + ["dragging + commitPoint", DRAGGING, COMMIT, SAME, []], + ["dragging + pointerDown", DRAGGING, DOWN, { type: "pressed", press: OTHER_PRESS, resume: null }, []], + ["dragging + pointerMove", DRAGGING, { ...DRAG_MOVE, index: 6 }, { type: "dragging", press: { ...PRESS, currentIndex: 6 }, resume: null }, []], // prettier-ignore + ["dragging + pointerMove onto the same stop", DRAGGING, DRAG_MOVE, SAME, []], + ["dragging + pointerMove of another pointer", DRAGGING, { ...DRAG_MOVE, pointerId: 2, index: 6 }, SAME, []], + // Focus only follows the zoom when the cursor was holding it, which is the case in zoom mode alone. + ["dragging + pointerUp", DRAGGING, UP, IDLE_ZOOM_STATE, ["applyZoom"]], + ["dragging in zoom mode + pointerUp", DRAGGING_IN_ZOOM, UP, IDLE_ZOOM_STATE, ZOOMED], + ["dragging + pointerUp of another pointer", DRAGGING, { ...UP, pointerId: 2 }, SAME, []], + ["dragging + pointerCancel", DRAGGING, CANCEL, IDLE_ZOOM_STATE, []], + ["dragging in zoom mode + pointerCancel", DRAGGING_IN_ZOOM, CANCEL, { type: "selecting", selection: STARTED }, []], + ["dragging + chartRendered", DRAGGING, RENDERED, DRAGGING, []], + ]; + +describe("zoomReducer", () => { + test.each(TRANSITIONS)("%s", (_name, from, event, to, effects) => { + const transition = zoomReducer(from, event, { valuesCount: COUNT }); + + expect(transition.state).toEqual(to === SAME ? from : to); + expect(transition.effects.map((effect) => effect.type)).toEqual(effects); + if (to === SAME) { + // The hook compares the state by identity to tell a transition from an event it can ignore. + expect(transition.state).toBe(from); + } + }); + + // The three ways of selecting a range are the same interaction, and they end in the same zoom. + + test("zooms with the keyboard, a stop at a time", () => { + const entered = reduce(IDLE_ZOOM_STATE, ENTER); + expect(effectTypes(entered)).toEqual(ENTERED); + + const stepped = reduce(entered.state, { type: "stepCursor", offset: 2 }); + const started = reduce(stepped.state, COMMIT); + expect(started.effects).toEqual([{ type: "announce", announcement: { type: "startPointSet", index: 2 } }]); + + const steppedAgain = reduce(started.state, { type: "stepCursor", offset: 3 }); + expect(getOverlayView(steppedAgain.state)).toEqual({ type: "cursor", cursorIndex: 5, anchorIndex: 2 }); + + const zoomed = reduce(steppedAgain.state, COMMIT); + expect(zoomed.state).toEqual(IDLE_ZOOM_STATE); + expect(zoomed.effects).toEqual([ + { type: "applyZoom", fromIndex: 2, toIndex: 5 }, + { type: "focus", target: "resetButton" }, + ]); + }); + + test("zooms with two clicks, which set the boundaries where they land", () => { + const selecting = reduce(IDLE_ZOOM_STATE, ENTER).state; + // The first click lands on the stop the press started on, which sets the range start. + const pressed = reduce(selecting, { type: "pointerDown", press: PRESS }).state; + const started = reduce(pressed, UP); + + expect(started.state).toEqual({ type: "selecting", selection: { cursorIndex: 4, anchorIndex: 4 } }); + expect(started.effects).toEqual([{ type: "announce", announcement: { type: "startPointSet", index: 4 } }]); + + const secondPress = { ...PRESS, startIndex: 8, currentIndex: 8 }; + const pressedAgain = reduce(started.state, { type: "pointerDown", press: secondPress }); + const zoomed = reduce(pressedAgain.state, UP); + + expect(zoomed.state).toEqual(IDLE_ZOOM_STATE); + expect(zoomed.effects).toEqual([ + { type: "applyZoom", fromIndex: 4, toIndex: 8 }, + { type: "focus", target: "resetButton" }, + ]); + }); + + test("zooms by dragging across the plot", () => { + const pressed = reduce(IDLE_ZOOM_STATE, { type: "pointerDown", press: PRESS }).state; + // The press is invisible, and the chart keeps behaving as it did, until it travels far enough. + expect(getInteraction(pressed)).toBe("idle"); + expect(getOverlayView(pressed)).toEqual({ type: "hidden" }); + + const dragging = reduce(pressed, { ...DRAG_MOVE, index: 7 }).state; + + expect(getInteraction(dragging)).toBe("drag"); + expect(getOverlayView(dragging)).toEqual({ type: "range", fromIndex: 4, toIndex: 7 }); + + const zoomed = reduce(dragging, UP); + + expect(zoomed.state).toEqual(IDLE_ZOOM_STATE); + expect(zoomed.effects).toEqual([{ type: "applyZoom", fromIndex: 4, toIndex: 7 }]); + }); + + // A range of a single stop has no width, and the chart cannot show it. Rather than applying a zoom + // that cannot be undone by zooming again, such a selection is refused. + + test("keeps the start point when the range would be a single stop", () => { + const selection = { cursorIndex: 4, anchorIndex: 4 }; + const { state, effects } = reduce({ type: "selecting", selection }, COMMIT); + + expect(state).toEqual({ type: "selecting", selection }); + expect(effects).toEqual([{ type: "announce", announcement: { type: "startPointSet", index: 4 } }]); + }); + + test("discards a drag that is too narrow to zoom into", () => { + const narrow: ZoomState = { type: "dragging", press: PRESS, resume: STARTED }; + + // The interaction returns to where the drag started from, with nothing applied. + expect(reduce(narrow, UP)).toEqual({ state: { type: "selecting", selection: STARTED }, effects: [] }); + expect(reduce({ ...narrow, resume: null }, UP)).toEqual({ state: IDLE_ZOOM_STATE, effects: [] }); + }); + + test("cannot enter zoom mode when there is no narrower range left", () => { + for (const valuesCount of [0, 1, 2]) { + const transition = zoomReducer(IDLE_ZOOM_STATE, ENTER, { valuesCount }); + + expect(transition.state).toBe(IDLE_ZOOM_STATE); + expect(transition.effects).toEqual([]); + } + }); + + test("keeps the cursor within the stops the chart shows", () => { + const forward = reduce(SELECTING, { type: "stepCursor", offset: 100 }); + expect(forward.state).toEqual({ type: "selecting", selection: { cursorIndex: 9, anchorIndex: null } }); + + const back = reduce(SELECTING, { type: "stepCursor", offset: -100 }); + expect(back.state).toEqual({ type: "selecting", selection: { cursorIndex: 0, anchorIndex: null } }); + + // A step that does not move the cursor is not a transition at all, so nothing is redrawn for it. + expect(reduce(back.state, { type: "stepCursor", offset: -1 }).state).toBe(back.state); + }); + + // The stops the cursor can sit on change with the zoomed range and with which series are visible, + // and a selection that was made over stops that are gone cannot be applied. + + test("brings the selection back within the stops after a render", () => { + const { state } = zoomReducer({ type: "selecting", selection: { cursorIndex: 8, anchorIndex: 7 } }, RENDERED, { + valuesCount: 5, + }); + + // The cursor moves to the last stop, and the start point, which is no longer shown, is dropped. + expect(state).toEqual({ type: "selecting", selection: { cursorIndex: 4, anchorIndex: null } }); + }); + + test("leaves zoom mode after a render that left no stops to select", () => { + for (const state of [SELECTING, PRESSED_IN_ZOOM, DRAGGING]) { + const transition = zoomReducer(state, RENDERED, { valuesCount: 0 }); + + expect(transition.state).toEqual(IDLE_ZOOM_STATE); + expect(transition.effects).toEqual([{ type: "announce", announcement: { type: "zoomModeExited" } }]); + } + + // A press that is not part of a selection belongs to the chart, which is still there. + expect(zoomReducer(PRESSED, RENDERED, { valuesCount: 0 }).state).toBe(PRESSED); + }); + + // Focus is only moved when the interaction was being driven from the keyboard: the pointer paths + // leave it where it is, and the chart API is called from outside the chart altogether. + + test("only moves the focus when asked to", () => { + expect(effectTypes(reduce(SELECTING, { type: "exitZoomMode", moveFocus: false }))).toEqual(["announce"]); + expect(effectTypes(reduce(SELECTING, { type: "resetZoom", zoomed: true, moveFocus: false }))).toEqual([ + "resetZoom", + ]); + }); +}); + +describe("zoom state projections", () => { + test.each([ + [IDLE_ZOOM_STATE, "idle", { type: "hidden" }, null], + [SELECTING, "cursor", { type: "cursor", cursorIndex: 3, anchorIndex: null }, null], + // A press shows whatever it interrupted, until it becomes a drag. + [PRESSED, "idle", { type: "hidden" }, PRESS], + [PRESSED_IN_ZOOM, "cursor", { type: "cursor", ...STARTED }, PRESS], + [DRAGGING, "drag", { type: "range", fromIndex: 4, toIndex: 8 }, { ...PRESS, currentIndex: 8 }], + ])( + "describes the %# state to React, to the overlay, and to the pointer handlers", + (state, interaction, view, press) => { + expect(getInteraction(state)).toBe(interaction); + expect(getOverlayView(state)).toEqual(view); + expect(getPress(state)).toEqual(press); + }, + ); +}); + +function reduce(state: ZoomState, event: ZoomEvent) { + return zoomReducer(state, event, { valuesCount: COUNT }); +} + +function effectTypes({ effects }: { effects: readonly ZoomEffect[] }) { + return effects.map((effect) => effect.type); +} diff --git a/src/core/chart-zoom/interfaces.ts b/src/core/chart-zoom/interfaces.ts new file mode 100644 index 00000000..3cad64b8 --- /dev/null +++ b/src/core/chart-zoom/interfaces.ts @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// The public shape of the zoom feature, owned by the core chart and re-exported by the components that +// expose it. It lives next to the zoom implementation, rather than in the chart interfaces, so that the +// zoom module has no dependency on them. + +export interface ZoomOptions { + enabled?: boolean; + hideButtons?: boolean; +} + +// The range is nested under the axis it applies to, leaving room for a "y" range should zooming +// along the y-axis be supported later, without a breaking change to the property shape. +export interface ZoomRange { + x?: { startValue: number; endValue: number }; +} + +export interface ZoomChangeDetail { + zoomRange: ZoomRange | null; +} diff --git a/src/core/chart-zoom/styles.scss b/src/core/chart-zoom/styles.scss new file mode 100644 index 00000000..2c736b0c --- /dev/null +++ b/src/core/chart-zoom/styles.scss @@ -0,0 +1,130 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use "../../../node_modules/@cloudscape-design/design-tokens/index.scss" as cs; +@use "@cloudscape-design/component-toolkit/internal/focus-visible" as focus-visible; + +$button-size: cs.$space-static-xl; + +// The zoom buttons render in the chart's header area, in normal flow, so they precede the plot both +// visually and in the focus order, and never overlap the plot, its axis titles, or a side legend. +.controls { + display: flex; + justify-content: flex-end; + padding-block-end: cs.$space-scaled-xxs; +} + +// Covers the plot area and hosts every affordance drawn over it. Pointer events pass through, so +// hovering and dragging still reach the chart; only the cursor buttons opt back in. +.overlay { + position: absolute; + inset: 0; + pointer-events: none; +} + +// The overlay elements are positioned imperatively in physical pixels (see applyRect), because +// Highcharts renders its SVG left-to-right regardless of page direction. They are hidden with +// visibility rather than display so their layout box, and therefore their measurable size, survives. +.band, +.divider, +.cursor, +.cluster { + position: absolute; + visibility: hidden; +} + +// Tints the range between the two selected points while a selection is in progress. A shade deeper than the +// tint of an applied zoom (see zoom-affordance.ts), so a re-zoom selection still shows over a zoomed plot. +.band { + background-color: cs.$color-background-toggle-button-normal-pressed; + opacity: 0.5; +} + +// Marks a point that has been set: the start of a keyboard selection, or either end of a drag. +.divider { + background-color: cs.$color-border-item-selected; +} + +// The keyboard cursor. It is a slider, focused while zoom mode is active, and takes no pointer events +// so that a drag starting on top of it still goes to the plot. +.cursor { + background-color: cs.$color-border-item-selected; + pointer-events: none; + + &:focus { + outline: none; + } + + // The same outline the chart draws around a focused x group outside zoom mode (see FocusOutline and + // focusOutlineOffsets.group): a 2px rounded stroke 4px outside the line, shown only during keyboard + // interaction. That outline is an SVG stroke centered on its rect, so its outer edge sits one pixel further, + // and it wraps the group's point markers, which are 5px wider than the cursor line. + &::before { + content: ""; + position: absolute; + inset-block: -5px; + inset-inline: -7.5px; + border-block: 2px solid transparent; + border-inline: 2px solid transparent; + border-start-start-radius: 5px; + border-start-end-radius: 5px; + border-end-start-radius: 5px; + border-end-end-radius: 5px; + } + + // The physical orientation flips in inverted charts, where the cursor runs across the plot. + &.cursor-vertical::before { + inset-block: -7.5px; + inset-inline: -5px; + } + + @include focus-visible.when-visible { + &::before { + border-block-color: cs.$color-border-item-focused; + border-inline-color: cs.$color-border-item-focused; + } + } +} + +// Pointer-operated equivalents of the arrow keys and Enter, so a range can be selected without +// dragging (WCAG 2.5.7). Laid out along the axis the cursor moves on: the flex direction is set +// inline, because it follows the plot's physical orientation rather than the page's. +.cluster { + display: flex; + align-items: center; + gap: cs.$space-static-xxs; + pointer-events: auto; +} + +// The step buttons share the filled look of the "Exit zoom" button (see zoom-controls.tsx), so the controls +// of an active zoom interaction read as one set, and stand out against the plot they sit on. +.button { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + inline-size: $button-size; + block-size: $button-size; + padding-block: 0; + padding-inline: 0; + border-block: cs.$border-width-button solid cs.$color-border-button-primary-active; + border-inline: cs.$border-width-button solid cs.$color-border-button-primary-active; + border-start-start-radius: 50%; + border-start-end-radius: 50%; + border-end-start-radius: 50%; + border-end-end-radius: 50%; + background-color: cs.$color-background-button-primary-active; + color: cs.$color-text-button-primary-active; + box-shadow: cs.$shadow-card; + cursor: pointer; + + &:disabled { + border-block-color: cs.$color-border-button-primary-disabled; + border-inline-color: cs.$color-border-button-primary-disabled; + background-color: cs.$color-background-button-primary-disabled; + color: cs.$color-text-button-primary-disabled; + cursor: default; + } +} diff --git a/src/core/chart-zoom/use-chart-zoom.tsx b/src/core/chart-zoom/use-chart-zoom.tsx new file mode 100644 index 00000000..8de42d16 --- /dev/null +++ b/src/core/chart-zoom/use-chart-zoom.tsx @@ -0,0 +1,720 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useMemo, useRef, useState } from "react"; +import type Highcharts from "highcharts"; + +import { KeyCode } from "@cloudscape-design/component-toolkit/internal"; +import LiveRegion from "@cloudscape-design/components/live-region"; + +import { fireNonCancelableEvent, NonCancelableEventHandler } from "../../internal/events"; +import { getFormatter } from "../formatters"; +import { ZoomChangeDetail, ZoomOptions, ZoomRange } from "./interfaces"; +import { getZoomAffordanceOptions } from "./zoom-affordance"; +import ZoomControls, { FocusableRef } from "./zoom-controls"; +import { + applyPosition, + applyRect, + getBandRect, + getClusterRect, + getDividerRect, + isInsidePlot, + isPixelForward, + isXAxisHorizontal, + pixelToValue, +} from "./zoom-geometry"; +import { useZoomI18n, ZoomI18nStrings } from "./zoom-i18n"; +import ZoomOverlay, { PixelDirection, ZoomOverlayRefs } from "./zoom-overlay"; +import { + canZoomInto, + getInteraction, + getOverlayView, + getPress, + IDLE_ZOOM_STATE, + ZoomAnnouncement, + ZoomEffect, + ZoomEvent, + ZoomFocusTarget, + ZoomInteraction, + zoomReducer, + ZoomState, +} from "./zoom-state-machine"; +import { findNearestIndex, getPageStep, getSeriesMinRange, getVisibleXValues, SeriesOptionsLike } from "./zoom-values"; + +// How far the pointer must travel before a press is treated as a drag. Shorter movements are treated as +// clicks: they must not commit a zoom, both because an accidental one-pixel drag would zoom into a single +// point, and because in zoom mode a click sets the range boundary. +const DRAG_THRESHOLD = 10; + +interface ZoomExtremes { + min: number; + max: number; +} + +interface UseChartZoomProps { + zoom?: ZoomOptions; + zoomRange?: ZoomRange | null; + onZoomRangeChange?: NonCancelableEventHandler; + i18nStrings?: ZoomI18nStrings; + // Series options, used to derive the axis minRange. Taken from the options rather than from the chart, + // because the value is needed to render the chart in the first place. + series: undefined | readonly SeriesOptionsLike[]; + inverted: boolean; + isRtl: boolean; + // The chart's root element, used to tell focus moving within the chart from focus leaving it. + rootRef: React.RefObject; +} + +export interface ChartZoom { + // True when zooming is enabled with the zoom property. + enabled: boolean; + // True while the Highcharts tooltip must not open: during a range selection the pointer sets the range + // boundaries, and a tooltip following it would only get in the way. + tooltipSuppressed: boolean; + // Props for the element wrapping the plot, which hosts the drag interaction and the overlay. + plotProps: React.HTMLAttributes; + // Merges the zoomed range into the first x axis' options. + getXAxisZoomOptions: (xAxisOptions: Highcharts.XAxisOptions) => Highcharts.XAxisOptions; + // Called from the chart's render event: the visible values, the cursor, and the overlay geometry all + // depend on what Highcharts has just drawn. + onChartRender: (chart: Highcharts.Chart) => void; + // Brings the zoom state in line with what the chart is currently showing: the cursor position, the + // overlay geometry, and whether there is still a range left to zoom into. The caller invokes it from an + // effect that runs after the chart has settled for this render, which includes the series visibility the + // chart API applies from its own effect. Reconciling from an effect declared inside this hook would run + // too early and miss that update. + reconcileAfterRender: () => void; + // Set by the chart to the function that dismisses the highlighted point and its tooltip. + clearHighlightRef: React.MutableRefObject<() => void>; + controls: null | React.ReactNode; + overlay: null | React.ReactNode; + liveRegion: null | React.ReactNode; + // The zoom actions exposed on the components' refs. + enterZoomMode: () => void; + exitZoomMode: () => void; + resetZoom: () => void; +} + +// Zooming into a range of the x axis, by dragging across the plot, by setting the range boundaries with a +// click or a tap, or with the keyboard. The three paths are equivalent, which is what WCAG 2.5.7 requires: +// no functionality may depend on dragging. +// +// The interaction itself is a state machine, defined in zoom-state-machine: every button, key, and pointer +// event below turns into one of its events, and this hook carries out what the machine decides. What is +// left here is the part that needs the chart, the DOM, and React: resolving stops to values, drawing the +// overlay, moving focus, and holding the zoomed range. +// +// The zoom lives in the core chart, so that all charts built on top of it can offer zooming, and so that +// consumers of the core chart get it too. +export function useChartZoom({ + zoom, + zoomRange, + onZoomRangeChange, + i18nStrings, + series, + inverted, + isRtl, + rootRef, +}: UseChartZoomProps): ChartZoom { + const i18n = useZoomI18n(i18nStrings); + const enabled = zoom?.enabled ?? false; + + // Only these four values live in React state, because a state update re-renders the chart, and + // re-rendering the chart re-initializes the Highcharts axes. Everything that happens per pointer event or + // per keystroke is applied to the DOM directly instead. + const [interaction, setInteraction] = useState("idle"); + const [localExtremes, setLocalExtremes] = useState(null); + const [canZoom, setCanZoom] = useState(true); + const [announcement, setAnnouncement] = useState(""); + + // The machine's state is held in a ref, and React only gets the part of it that it renders from. The ref + // is what the imperative overlay updates read, so that they see a transition without waiting for a render. + const stateRef = useRef(IDLE_ZOOM_STATE); + const chartRef = useRef(null); + // The x values the cursor can land on. Recomputed on demand after every chart render rather than on + // every step: with a large series, walking all points per keystroke is what made keyboard zooming + // unusable, and Highcharts crops the series data to the visible window, so it cannot be cached once. + const valuesRef = useRef([]); + const valuesStaleRef = useRef(true); + // Where focus must go once the current render commits. Focus is moved after the render, because the + // element to focus often only appears as a result of the state change that requested the move. + const pendingFocusRef = useRef(null); + // True between a press made in zoom mode and the click that follows it. That click has already set a + // range boundary, and must not reach Highcharts as well: the press that sets the second boundary ends + // zoom mode, so by the time the click arrives the tooltip is no longer suppressed, and Highcharts would + // pin the point under the pointer. + const swallowClickRef = useRef(false); + // Removes the document listeners that follow the current press, see trackPress. + const stopTrackingPressRef = useRef void)>(null); + // The latest press handlers, read by the document listeners, which outlive the render that added them. + const pressHandlersRef = useRef({ move: onPressMove, end: onPressEnd }); + pressHandlersRef.current = { move: onPressMove, end: onPressEnd }; + const clearHighlightRef = useRef<() => void>(() => {}); + const zoomButtonRef = useRef(null); + const resetButtonRef = useRef(null); + const overlayRefs: ZoomOverlayRefs = { + band: useRef(null), + startDivider: useRef(null), + endDivider: useRef(null), + cursor: useRef(null), + cluster: useRef(null), + previousButton: useRef(null), + nextButton: useRef(null), + }; + + // The range is controlled when the property is defined, including when it is null, which is how a + // controlled chart expresses "show the full data range". + const controlled = zoomRange !== undefined; + const extremes = controlled ? extremesFromRange(zoomRange) : localExtremes; + const zoomed = extremes !== null; + const active = interaction === "cursor"; + const minRange = useMemo(() => (enabled ? getSeriesMinRange(series) : undefined), [enabled, series]); + + // A press still in progress when the chart unmounts must not leave its document listeners behind. + useEffect(() => () => stopTrackingPress(), []); + + function getValues(): number[] { + if (valuesStaleRef.current) { + valuesRef.current = chartRef.current ? getVisibleXValues(chartRef.current) : []; + valuesStaleRef.current = false; + } + return valuesRef.current; + } + + function getXAxis(): null | Highcharts.Axis { + // The axis instance is replaced whenever the chart updates, which happens on every React render, so + // it is always read from the chart instead of being held on to. + return chartRef.current?.xAxis?.[0] ?? null; + } + + function formatValue(value: number): string { + return getFormatter(getXAxis() ?? undefined)(value); + } + + // Every zoom interaction goes through here: the machine decides the next state and what has to happen for + // it, and this is the only place that carries those decisions out. + function dispatch(event: ZoomEvent) { + const previous = stateRef.current; + const { state, effects } = zoomReducer(previous, event, { valuesCount: getValues().length }); + if (state === previous && effects.length === 0) { + // The event means nothing in this state, so nothing has to be drawn or announced either. + return; + } + // The ref is updated before anything else, because the effects and the overlay update below run in the + // same event and must already see the new state. + stateRef.current = state; + const nextInteraction = getInteraction(state); + if (nextInteraction !== getInteraction(previous)) { + // The only render an interaction causes, and it is needed: what the controls show, and whether the + // tooltip may follow the pointer, both depend on it. The cursor and the selection are drawn onto the + // overlay instead, so that stepping through a large series does not re-render, and therefore does not + // re-initialize the chart, per step. + setInteraction(nextInteraction); + // The chart keeps tracking the hovered point while the tooltip is suppressed. Once the interaction + // ends the tooltip is shown again, and would open on that point, which after a zoom sits at the edge + // of the new range, only to be closed by the next chart render. + if (nextInteraction === "idle") { + clearHighlightRef.current(); + } + } + for (const effect of effects) { + runEffect(effect); + } + syncOverlay(); + } + + function runEffect(effect: ZoomEffect) { + switch (effect.type) { + case "clearHighlight": + clearHighlightRef.current(); + break; + case "announce": + setAnnouncement(describeAnnouncement(effect.announcement)); + break; + case "focus": + pendingFocusRef.current = effect.target; + break; + case "applyZoom": { + const values = getValues(); + const fromValue = values[effect.fromIndex]; + const toValue = values[effect.toIndex]; + if (fromValue === undefined || toValue === undefined) { + // The stops the selection was made on are gone, for instance because the series were filtered + // while it was in progress. There is no range left to apply. + break; + } + const min = Math.min(fromValue, toValue); + const max = Math.max(fromValue, toValue); + valuesStaleRef.current = true; + if (!controlled) { + setLocalExtremes({ min, max }); + } + setAnnouncement(i18n.zoomRangeChangeAnnouncementText(formatValue(min), formatValue(max))); + fireNonCancelableEvent(onZoomRangeChange, { zoomRange: { x: { startValue: min, endValue: max } } }); + break; + } + case "resetZoom": + valuesStaleRef.current = true; + if (!controlled) { + setLocalExtremes(null); + } + setAnnouncement(i18n.zoomResetAnnouncementText); + fireNonCancelableEvent(onZoomRangeChange, { zoomRange: null }); + break; + } + } + + function describeAnnouncement(announcement: ZoomAnnouncement): string { + const values = getValues(); + switch (announcement.type) { + case "zoomModeEntered": + return i18n.zoomModeEnteredAnnouncementText(formatValue(values[announcement.index])); + case "startPointSet": + return i18n.zoomStartPointAnnouncementText(formatValue(values[announcement.index])); + case "zoomModeExited": + return i18n.zoomModeExitedAnnouncementText; + } + } + + // Draws the current state onto the overlay, and describes the cursor to assistive technology. Called + // after every transition and after every chart render. + function syncOverlay() { + const chart = chartRef.current; + const xAxis = getXAxis(); + const values = getValues(); + const view = getOverlayView(stateRef.current); + if (!chart || !xAxis || values.length === 0 || view.type === "hidden") { + hideOverlay(); + return; + } + if (view.type === "range") { + const fromValue = values[view.fromIndex]; + const toValue = values[view.toIndex]; + applyRect(overlayRefs.band.current, getBandRect(chart, xAxis, fromValue, toValue)); + applyRect(overlayRefs.startDivider.current, getDividerRect(chart, xAxis, fromValue)); + applyRect(overlayRefs.endDivider.current, getDividerRect(chart, xAxis, toValue)); + // The cursor is kept visible while it holds focus: hiding a focused element moves focus to the + // document body, which would cancel the interaction the drag is part of. It coincides with the + // drag's trailing boundary, which is where the cursor would be after the same selection by keyboard. + const cursorFocused = !!overlayRefs.cursor.current && overlayRefs.cursor.current === document.activeElement; + applyRect(overlayRefs.cursor.current, cursorFocused ? getDividerRect(chart, xAxis, toValue) : null); + applyPosition(overlayRefs.cluster.current, null); + return; + } + const cursorValue = values[view.cursorIndex]; + const anchorIndex = view.anchorIndex; + applyRect(overlayRefs.cursor.current, getDividerRect(chart, xAxis, cursorValue)); + applyPosition(overlayRefs.cluster.current, getClusterRect(chart, xAxis, cursorValue, measureCluster())); + applyRect(overlayRefs.endDivider.current, null); + if (anchorIndex === null) { + applyRect(overlayRefs.band.current, null); + applyRect(overlayRefs.startDivider.current, null); + } else { + const anchorValue = values[anchorIndex]; + applyRect(overlayRefs.band.current, getBandRect(chart, xAxis, anchorValue, cursorValue)); + applyRect(overlayRefs.startDivider.current, getDividerRect(chart, xAxis, anchorValue)); + } + // The cursor is a slider, so its position is announced from its own value as it moves. That keeps + // stepping free of React renders, which is what makes the cursor usable in large series. + const cursor = overlayRefs.cursor.current; + if (cursor) { + cursor.setAttribute("aria-valuemin", `${values[0]}`); + cursor.setAttribute("aria-valuemax", `${values[values.length - 1]}`); + cursor.setAttribute("aria-valuenow", `${cursorValue}`); + cursor.setAttribute( + "aria-valuetext", + anchorIndex === null + ? i18n.zoomCursorPositionAnnouncementText(formatValue(cursorValue)) + : i18n.zoomSelectionAnnouncementText(formatValue(values[anchorIndex]), formatValue(cursorValue)), + ); + } + } + + function hideOverlay() { + applyRect(overlayRefs.band.current, null); + applyRect(overlayRefs.startDivider.current, null); + applyRect(overlayRefs.endDivider.current, null); + applyRect(overlayRefs.cursor.current, null); + applyPosition(overlayRefs.cluster.current, null); + } + + // The cluster sizes itself from its contents, so it is measured rather than given a size. It keeps its + // layout box while hidden, which is why the measurement is available before it is first shown. + function measureCluster() { + const cluster = overlayRefs.cluster.current; + return { width: cluster?.offsetWidth ?? 0, height: cluster?.offsetHeight ?? 0 }; + } + + function applyPendingFocus() { + const target = pendingFocusRef.current; + if (target === null) { + return; + } + pendingFocusRef.current = null; + if (target === "cursor") { + overlayRefs.cursor.current?.focus(); + return; + } + // The reset button is the natural place to land after zooming, but it is not rendered when the chart + // is controlled with hidden buttons. Focus then falls back to whatever the chart does offer, so that + // it never ends up on the document body. + const candidates = + target === "resetButton" ? [resetButtonRef.current, zoomButtonRef.current] : [zoomButtonRef.current]; + for (const candidate of candidates) { + if (candidate) { + candidate.focus(); + return; + } + } + const application = rootRef.current?.querySelector('[role="application"]'); + application?.focus(); + } + + function enterZoomMode() { + const xAxis = getXAxis(); + const values = getValues(); + if (!enabled || !xAxis) { + return; + } + // The cursor always starts at the visible start of the plot, whichever way the axis runs, so that + // repeated zooming behaves the same regardless of how the previous range was selected. + dispatch({ type: "enterZoomMode", startIndex: isPixelForward(xAxis, values) ? 0 : values.length - 1 }); + } + + function exitZoomMode({ withFocus }: { withFocus: boolean }) { + dispatch({ type: "exitZoomMode", moveFocus: withFocus }); + } + + function resetZoom({ withFocus }: { withFocus: boolean }) { + dispatch({ type: "resetZoom", zoomed, moveFocus: withFocus }); + } + + // Moves the cursor by the given number of stops towards the start or the end of the plot on screen. The + // direction is expressed in pixels and translated to a value step here, so that the arrow keys and the + // step buttons follow the plot in inverted charts, on reversed axes, and in right-to-left pages. + function stepCursor(direction: PixelDirection, stops = 1) { + const xAxis = getXAxis(); + const values = getValues(); + if (!xAxis || values.length === 0) { + return; + } + dispatch({ type: "stepCursor", offset: (isPixelForward(xAxis, values) ? 1 : -1) * direction * stops }); + } + + function moveCursorToEdge(direction: PixelDirection) { + const xAxis = getXAxis(); + const values = getValues(); + if (!xAxis || values.length === 0) { + return; + } + const atStart = isPixelForward(xAxis, values) === (direction === -1); + dispatch({ type: "moveCursorToEdge", edge: atStart ? "first" : "last" }); + } + + function onCursorKeyDown(event: React.KeyboardEvent) { + const xAxis = getXAxis(); + if (!xAxis || getInteraction(stateRef.current) !== "cursor") { + return; + } + // On an inverted chart the x axis runs vertically, and so does the cursor. Only the arrow pair that + // runs along the axis moves the cursor; the perpendicular pair is left to the page. + const vertical = !isXAxisHorizontal(xAxis); + const backwardKey = vertical ? KeyCode.up : KeyCode.left; + const forwardKey = vertical ? KeyCode.down : KeyCode.right; + const pageStop = getPageStep(getValues().length); + let handled = true; + switch (event.keyCode) { + case backwardKey: + stepCursor(-1); + break; + case forwardKey: + stepCursor(1); + break; + case KeyCode.pageUp: + stepCursor(1, pageStop); + break; + case KeyCode.pageDown: + stepCursor(-1, pageStop); + break; + case KeyCode.home: + moveCursorToEdge(-1); + break; + case KeyCode.end: + moveCursorToEdge(1); + break; + case KeyCode.enter: + case KeyCode.space: + dispatch({ type: "commitPoint" }); + break; + case KeyCode.escape: + exitZoomMode({ withFocus: true }); + break; + default: + handled = false; + } + if (handled) { + event.preventDefault(); + // The chart listens for Escape on the document to dismiss the tooltip, and the page may bind keys + // of its own. Neither should also act on a key the cursor has just used. + event.stopPropagation(); + } + } + + function onCursorBlur(event: React.FocusEvent) { + const nextTarget = event.relatedTarget; + // Focus moving within the chart, for instance onto the exit button, is that control's business. + // Focus leaving the chart altogether, or going nowhere at all, ends the interaction. + if (nextTarget instanceof Node && rootRef.current?.contains(nextTarget)) { + return; + } + exitZoomMode({ withFocus: false }); + } + + // Highcharts measures the chart position against the document rather than the viewport, so the pointer + // position has to be converted by Highcharts itself to account for the page scroll. + function getChartCoordinates(chart: Highcharts.Chart, event: PointerEvent) { + const { chartX, chartY } = chart.pointer.normalize(event); + return { chartX, chartY }; + } + + function onPlotPointerDown(event: React.PointerEvent) { + swallowClickRef.current = false; + const chart = chartRef.current; + const xAxis = getXAxis(); + const values = getValues(); + if (!chart || !xAxis || values.length === 0) { + return; + } + // Secondary mouse buttons open context menus and must not start a selection. + if (event.pointerType === "mouse" && event.button !== 0) { + return; + } + // The cursor buttons sit on top of the plot and handle their own presses. + if (event.target instanceof Node && overlayRefs.cluster.current?.contains(event.target)) { + return; + } + const { chartX, chartY } = getChartCoordinates(chart, event.nativeEvent); + if (!isInsidePlot(chart, chartX, chartY)) { + return; + } + const inZoomMode = getInteraction(stateRef.current) === "cursor"; + swallowClickRef.current = inZoomMode; + trackPress(event.currentTarget, event.pointerId); + const index = findNearestIndex(values, pixelToValue(xAxis, chartX, chartY)); + dispatch({ + type: "pointerDown", + press: { + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + startIndex: index, + currentIndex: index, + }, + }); + if (inZoomMode) { + // In zoom mode the press belongs to the range selection: it must not move focus off the cursor, + // which would end the selection. Outside zoom mode the press is left alone, so that clicking the + // chart still selects a point. + event.preventDefault(); + } + } + + // Follows a press on the document until it ends. Listening on the plot wrapper alone loses the press as + // soon as the pointer passes over anything rendered outside of it: the tooltip, which with a dense series + // sits right next to the pointer, or the page around the chart. The press then never turns into a drag, + // or a drag never ends, leaving its boundaries drawn over the plot. + function trackPress(plot: HTMLDivElement, pointerId: number) { + stopTrackingPress(); + const document = plot.ownerDocument; + const onMove = (event: PointerEvent) => event.pointerId === pointerId && pressHandlersRef.current.move(event, plot); + const onUp = (event: PointerEvent) => + event.pointerId === pointerId && pressHandlersRef.current.end(event, plot, "pointerUp"); + const onCancel = (event: PointerEvent) => + event.pointerId === pointerId && pressHandlersRef.current.end(event, plot, "pointerCancel"); + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onUp); + document.addEventListener("pointercancel", onCancel); + stopTrackingPressRef.current = () => { + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onUp); + document.removeEventListener("pointercancel", onCancel); + }; + } + + function stopTrackingPress() { + stopTrackingPressRef.current?.(); + stopTrackingPressRef.current = null; + } + + function onPressMove(event: PointerEvent, plot: HTMLDivElement) { + const chart = chartRef.current; + const xAxis = getXAxis(); + const state = stateRef.current; + if (!chart || !xAxis || state.type === "idle") { + return; + } + const values = getValues(); + if (values.length === 0) { + return; + } + const press = getPress(state); + const { chartX, chartY } = getChartCoordinates(chart, event); + const distance = press ? travelledAlongAxis(xAxis, event, press.clientX, press.clientY) : 0; + dispatch({ + type: "pointerMove", + pointerId: event.pointerId, + index: findNearestIndex(values, pixelToValue(xAxis, chartX, chartY)), + passedThreshold: distance >= DRAG_THRESHOLD, + insidePlot: isInsidePlot(chart, chartX, chartY), + }); + // The pointer is only captured once the press has turned into a drag. Capturing it on press would + // retarget the click that follows to the plot wrapper, and Highcharts, which listens for clicks on its + // own container, would then never see it: clicking the chart would no longer pin a point. + if (stateRef.current.type === "dragging") { + capturePointer(plot, event.pointerId); + } + } + + // Hovering the plot in zoom mode moves the cursor, and during a selection the band, along with the + // pointer. A press is followed on the document instead, so its moves are not handled twice. + function onPlotPointerMove(event: React.PointerEvent) { + if (!stopTrackingPressRef.current) { + onPressMove(event.nativeEvent, event.currentTarget); + } + } + + function onPressEnd(event: PointerEvent, plot: HTMLDivElement, type: "pointerUp" | "pointerCancel") { + stopTrackingPress(); + releasePointer(plot, event.pointerId); + dispatch({ type, pointerId: event.pointerId }); + } + + // Runs in the capture phase, before the click reaches the Highcharts container inside the plot wrapper. + function onPlotClickCapture(event: React.MouseEvent) { + if (swallowClickRef.current) { + swallowClickRef.current = false; + event.stopPropagation(); + } + } + + // Called from the chart's render event, which Highcharts fires from within the effect that updates the + // chart. Only refs are touched here: a state update at this point would re-render the chart, and + // re-rendering re-initializes its SVG, discarding what Highcharts has just drawn. Everything that needs + // state is deferred to reconcileAfterRender, which runs right after. + function onChartRender(chart: Highcharts.Chart) { + if (!enabled) { + return; + } + chartRef.current = chart; + // What the cursor can land on depends on the extremes and on which series are visible, both of which + // this render may have changed. + valuesStaleRef.current = true; + } + + // Called from an effect after every render, see ChartZoom.reconcileAfterRender. + function reconcileAfterRender() { + if (!enabled) { + return; + } + const values = getValues(); + dispatch({ type: "chartRendered" }); + // The state may be unchanged while the geometry underneath it moved, so the overlay is drawn again + // regardless of what the render event above decided. + syncOverlay(); + applyPendingFocus(); + const nextCanZoom = canZoomInto(values.length); + if (nextCanZoom !== canZoom) { + setCanZoom(nextCanZoom); + } + } + + return { + enabled, + tooltipSuppressed: enabled && interaction !== "idle", + plotProps: enabled + ? { + onPointerDown: onPlotPointerDown, + onPointerMove: onPlotPointerMove, + onClickCapture: onPlotClickCapture, + // A drag across the plot must not select the text around it. + style: { userSelect: "none" }, + } + : {}, + getXAxisZoomOptions: (xAxisOptions) => + enabled + ? { + minRange, + // Highcharts reads null as "no bound", so the zoomed range can be cleared by rendering the + // axis without one, falling back to the bounds the consumer defined. + min: extremes ? extremes.min : (xAxisOptions.min ?? null), + max: extremes ? extremes.max : (xAxisOptions.max ?? null), + // While zoomed, the range is marked with a band and a boundary line at each end. + ...getZoomAffordanceOptions(xAxisOptions, extremes), + } + : {}, + onChartRender, + reconcileAfterRender, + clearHighlightRef, + controls: + enabled && !zoom?.hideButtons ? ( + exitZoomMode({ withFocus: true })} + onResetZoom={() => resetZoom({ withFocus: true })} + /> + ) : null, + overlay: enabled ? ( + + ) : null, + liveRegion: enabled ? : null, + enterZoomMode, + exitZoomMode: () => exitZoomMode({ withFocus: false }), + resetZoom: () => resetZoom({ withFocus: false }), + }; +} + +function extremesFromRange(zoomRange: undefined | null | ZoomRange): null | ZoomExtremes { + const x = zoomRange?.x; + return x ? { min: Math.min(x.startValue, x.endValue), max: Math.max(x.startValue, x.endValue) } : null; +} + +// How far the pointer has travelled from where it went down, along the axis the selection runs on. Movement +// across the axis is ignored: it does not change the range, and counting it would turn a shaky click into a +// drag. +function travelledAlongAxis( + xAxis: Highcharts.Axis, + event: { clientX: number; clientY: number }, + clientX: number, + clientY: number, +) { + return isXAxisHorizontal(xAxis) ? Math.abs(event.clientX - clientX) : Math.abs(event.clientY - clientY); +} + +// Capturing the pointer keeps a drag alive when the pointer leaves the plot, and delivers its end even +// then. Both calls are feature-detected, because the pointer capture API is missing from jsdom, which is +// what consumers test their charts in; without it a drag simply ends where it leaves the plot. +function capturePointer(element: HTMLElement, pointerId: number) { + if (element.hasPointerCapture?.(pointerId) === false) { + element.setPointerCapture?.(pointerId); + } +} + +function releasePointer(element: HTMLElement, pointerId: number) { + if (element.hasPointerCapture?.(pointerId)) { + element.releasePointerCapture(pointerId); + } +} diff --git a/src/core/chart-zoom/zoom-affordance.ts b/src/core/chart-zoom/zoom-affordance.ts new file mode 100644 index 00000000..0eb0a613 --- /dev/null +++ b/src/core/chart-zoom/zoom-affordance.ts @@ -0,0 +1,75 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type Highcharts from "highcharts"; + +import { colorBackgroundItemSelected, colorBorderItemSelected } from "@cloudscape-design/design-tokens"; + +// The persistent affordance marking the range the chart is zoomed to: a boundary line at each end of the range +// plus a subtle tint between them, so a zoomed chart reads as zoomed even when the zoom controls are out of +// sight. It is declared as axis options rather than drawn with Axis.addPlotBand, because Highcharts +// re-initializes the axes on every React re-render and discards anything added imperatively. +// +// The ids share a prefix so the affordance can be told apart from the consumer's own plot lines, which the +// highlight logic dims by id (see isZoomAffordanceId). +const ZOOM_AFFORDANCE_ID_PREFIX = "awsui-zoom-range"; +const ZOOM_RANGE_BAND_ID = ZOOM_AFFORDANCE_ID_PREFIX; +const ZOOM_RANGE_START_LINE_ID = `${ZOOM_AFFORDANCE_ID_PREFIX}-start`; +const ZOOM_RANGE_END_LINE_ID = `${ZOOM_AFFORDANCE_ID_PREFIX}-end`; + +// The band sits below the plot content, so the tint goes under the series instead of washing them out, and the +// boundary lines above it, matching the z index of the cartesian thresholds' plot lines. +const BAND_Z_INDEX = 0; +const LINE_Z_INDEX = 3; +// Matches the thickness of the zoom cursor and the selection dividers, see DIVIDER_THICKNESS. +const LINE_WIDTH = 1; + +// True for the ids of the zoom affordance's own band and lines. Used to leave them out of the by-id plot line +// handling that applies to the lines a consumer declared: the affordance marks the zoomed range rather than a +// series, so it must not dim when a series is highlighted. +export function isZoomAffordanceId(id: string): boolean { + return id.startsWith(ZOOM_AFFORDANCE_ID_PREFIX); +} + +// The axis bands and lines to render while the chart is zoomed to `extremes`, or just the ones the consumer +// declared when it is not: the affordance is appended to those, rather than replacing them, so that for example +// the cartesian thresholds survive. +// +// Both collections are always returned, even when there is nothing to add to them. Axis.update merges the new +// options over the ones the axis already has, and a collection left out of them keeps its previous value, so +// omitting `plotBands` while not zoomed would leave the band of the range just reset behind. +export function getZoomAffordanceOptions( + xAxisOptions: Highcharts.XAxisOptions, + extremes: null | { min: number; max: number }, +): Pick { + const plotBands = [...(xAxisOptions.plotBands ?? [])]; + const plotLines = [...(xAxisOptions.plotLines ?? [])]; + if (!extremes) { + return { plotBands, plotLines }; + } + return { + plotBands: [ + ...plotBands, + { + id: ZOOM_RANGE_BAND_ID, + from: extremes.min, + to: extremes.max, + color: colorBackgroundItemSelected, + zIndex: BAND_Z_INDEX, + }, + ], + plotLines: [ + ...plotLines, + ...[ + { id: ZOOM_RANGE_START_LINE_ID, value: extremes.min }, + { id: ZOOM_RANGE_END_LINE_ID, value: extremes.max }, + ].map(({ id, value }) => ({ + id, + value, + color: colorBorderItemSelected, + width: LINE_WIDTH, + zIndex: LINE_Z_INDEX, + })), + ], + }; +} diff --git a/src/core/chart-zoom/zoom-controls.tsx b/src/core/chart-zoom/zoom-controls.tsx new file mode 100644 index 00000000..ce031039 --- /dev/null +++ b/src/core/chart-zoom/zoom-controls.tsx @@ -0,0 +1,113 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import clsx from "clsx"; + +import Button, { ButtonProps } from "@cloudscape-design/components/button"; +import SpaceBetween from "@cloudscape-design/components/space-between"; +import { colorBackgroundButtonPrimaryActive, colorBorderButtonPrimaryActive } from "@cloudscape-design/design-tokens"; + +import { ResolvedZoomI18n } from "./zoom-i18n"; + +import testClasses from "../test-classes/styles.css.js"; +import styles from "./styles.css.js"; + +const exitButtonStyle: ButtonProps.Style = { + root: { + background: { + default: colorBackgroundButtonPrimaryActive, + hover: colorBackgroundButtonPrimaryActive, + active: colorBackgroundButtonPrimaryActive, + }, + borderColor: { + default: colorBorderButtonPrimaryActive, + hover: colorBorderButtonPrimaryActive, + active: colorBorderButtonPrimaryActive, + }, + }, +}; + +// Cloudscape buttons expose only a focus method, which is all the zoom controls need to move focus +// between them as they appear and disappear. +export interface FocusableRef { + focus(): void; +} + +interface ZoomControlsProps { + i18n: ResolvedZoomI18n; + // True while a range is being selected, with either the cursor or a drag. + active: boolean; + // True when a zoom range is applied, so the chart shows less than the full data range. + zoomed: boolean; + // False when the visible range is already as narrow as it can get, in which case there is nothing left + // to zoom into and the button would do nothing. + canZoom: boolean; + zoomButtonRef: React.RefObject; + resetButtonRef: React.RefObject; + onEnterZoomMode: () => void; + onExitZoomMode: () => void; + onResetZoom: () => void; +} + +// The zoom controls, rendered in the chart's header area rather than over the plot. In normal flow they +// come before the chart both visually and in the focus order, so a screen reader user learns that the +// chart is zoomed, and can reset it, before reading the chart itself. It also keeps them clear of the +// axis titles and of a legend placed to the side. +export default function ZoomControls({ + i18n, + active, + zoomed, + canZoom, + zoomButtonRef, + resetButtonRef, + onEnterZoomMode, + onExitZoomMode, + onResetZoom, +}: ZoomControlsProps) { + return ( +
+ + {zoomed && !active && ( + + + + )} + {!active ? ( + + + + ) : ( + + + + )} + +
+ ); +} diff --git a/src/core/chart-zoom/zoom-geometry.ts b/src/core/chart-zoom/zoom-geometry.ts new file mode 100644 index 00000000..e6be3005 --- /dev/null +++ b/src/core/chart-zoom/zoom-geometry.ts @@ -0,0 +1,155 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type Highcharts from "highcharts"; + +// Rectangles are expressed in physical pixels relative to the Highcharts container's top-left corner, +// matching chart.plotLeft / plotTop / toPixels. Highcharts renders its SVG left-to-right regardless of +// page direction, so the overlays are positioned with physical left/top rather than logical properties. +export interface OverlayRect { + left: number; + top: number; + width: number; + height: number; +} + +// Thickness of the cursor and boundary lines drawn over the plot, matching the chart's own cursor line. +const DIVIDER_THICKNESS = 1; +// Distance between the cursor button cluster and the plot edge it is anchored to. +const CLUSTER_OFFSET = 8; + +// In an inverted chart the x axis runs vertically, so every pixel computation swaps the two dimensions +// and Axis.toValue / toPixels operate on chart y coordinates instead of x ones. +export function isXAxisHorizontal(xAxis: Pick): boolean { + return xAxis.horiz !== false; +} + +// Converts a pointer position over the chart into an x-axis value. +export function pixelToValue(xAxis: Highcharts.Axis, chartX: number, chartY: number): number { + return xAxis.toValue(isXAxisHorizontal(xAxis) ? chartX : chartY, false); +} + +// Converts an x-axis value into a chart-relative pixel along the axis. +export function valueToPixel(xAxis: Highcharts.Axis, value: number): number { + return xAxis.toPixels(value, false); +} + +// True when increasing x values map to increasing pixel coordinates. Derived from the rendered pixels +// rather than from the reversed / inverted options, so it holds for right-to-left pages, consumer-set +// `reversed` axes, and inverted charts alike, including combinations of the three. +export function isPixelForward(xAxis: Highcharts.Axis, values: readonly number[]): boolean { + if (values.length < 2) { + return true; + } + return valueToPixel(xAxis, values[values.length - 1]) >= valueToPixel(xAxis, values[0]); +} + +// A line across the plot at the given x value. +export function getDividerRect(chart: Highcharts.Chart, xAxis: Highcharts.Axis, value: number): OverlayRect { + const pixel = valueToPixel(xAxis, value); + return isXAxisHorizontal(xAxis) + ? { + left: pixel - DIVIDER_THICKNESS / 2, + top: chart.plotTop, + width: DIVIDER_THICKNESS, + height: chart.plotHeight, + } + : { + left: chart.plotLeft, + top: pixel - DIVIDER_THICKNESS / 2, + width: chart.plotWidth, + height: DIVIDER_THICKNESS, + }; +} + +// The band covering the range between two x values, spanning the plot across the other dimension. +export function getBandRect( + chart: Highcharts.Chart, + xAxis: Highcharts.Axis, + fromValue: number, + toValue: number, +): OverlayRect { + const fromPixel = valueToPixel(xAxis, fromValue); + const toPixel = valueToPixel(xAxis, toValue); + const start = Math.min(fromPixel, toPixel); + // A range of a single value still needs a visible band, hence the minimum of one pixel. + const size = Math.max(1, Math.abs(toPixel - fromPixel)); + return isXAxisHorizontal(xAxis) + ? { left: start, top: chart.plotTop, width: size, height: chart.plotHeight } + : { left: chart.plotLeft, top: start, width: chart.plotWidth, height: size }; +} + +// The cursor button cluster, centered on the cursor and pinned to a plot edge: the bottom edge when the +// x axis is horizontal, the inline start edge when it is vertical. It is never shifted along the axis, so +// it stays aligned with a cursor at the first or the last point by extending past the plot edge. +export function getClusterRect( + chart: Highcharts.Chart, + xAxis: Highcharts.Axis, + value: number, + clusterSize: { width: number; height: number }, +): OverlayRect { + const pixel = valueToPixel(xAxis, value); + const { width, height } = clusterSize; + if (isXAxisHorizontal(xAxis)) { + return { + left: pixel - width / 2, + top: chart.plotTop + Math.max(0, chart.plotHeight - height - CLUSTER_OFFSET), + width, + height, + }; + } + return { + left: chart.plotLeft + CLUSTER_OFFSET, + top: pixel - height / 2, + width, + height, + }; +} + +// True when the given chart coordinates fall inside the plot area. +export function isInsidePlot(chart: Highcharts.Chart, chartX: number, chartY: number): boolean { + return ( + chartX >= chart.plotLeft && + chartX <= chart.plotLeft + chart.plotWidth && + chartY >= chart.plotTop && + chartY <= chart.plotTop + chart.plotHeight + ); +} + +// Writes a rectangle onto an overlay element and makes it visible. Applied imperatively: the cursor and +// the drag preview move with every pointer event, and re-rendering the chart component for each of those +// would re-initialize the Highcharts axes. +// +// Visibility is used rather than display so the elements keep their layout box while hidden: the cursor +// button cluster is a flex container whose own display must survive, and its size has to be measurable +// before it is first shown. Hidden elements are neither focusable nor exposed to assistive technology, +// and do not receive pointer events. +export function applyRect(element: null | HTMLElement, rect: null | OverlayRect) { + if (!element) { + return; + } + if (!rect) { + element.style.visibility = "hidden"; + return; + } + element.style.visibility = "visible"; + element.style.left = `${rect.left}px`; + element.style.top = `${rect.top}px`; + element.style.width = `${rect.width}px`; + element.style.height = `${rect.height}px`; +} + +// Positions an element without constraining its size, used for the cursor button cluster: its size comes +// from its contents, and writing the measured size back onto it would freeze that measurement. +export function applyPosition(element: null | HTMLElement, rect: null | OverlayRect) { + if (!element) { + return; + } + if (!rect) { + element.style.visibility = "hidden"; + return; + } + element.style.visibility = "visible"; + element.style.left = `${rect.left}px`; + element.style.top = `${rect.top}px`; +} diff --git a/src/core/chart-zoom/zoom-i18n.ts b/src/core/chart-zoom/zoom-i18n.ts new file mode 100644 index 00000000..d4eb3de0 --- /dev/null +++ b/src/core/chart-zoom/zoom-i18n.ts @@ -0,0 +1,67 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo } from "react"; + +// The zoom strings a consumer can override. Kept in this module rather than in the chart interfaces so +// the core chart and the cartesian chart share one definition of the zoom vocabulary. +export interface ZoomI18nStrings { + enterZoomModeButtonText?: string; + enterZoomModeButtonAriaLabel?: string; + exitZoomModeButtonText?: string; + exitZoomModeButtonAriaLabel?: string; + resetZoomButtonText?: string; + resetZoomButtonAriaLabel?: string; + zoomControlsAriaLabel?: string; + zoomCursorAriaLabel?: string; + zoomCursorPreviousButtonAriaLabel?: string; + zoomCursorNextButtonAriaLabel?: string; + zoomModeEnteredAnnouncementText?: (value: string) => string; + zoomCursorPositionAnnouncementText?: (value: string) => string; + zoomStartPointAnnouncementText?: (value: string) => string; + zoomRangeChangeAnnouncementText?: (startValue: string, endValue: string) => string; + zoomSelectionAnnouncementText?: (startValue: string, endValue: string) => string; + zoomModeExitedAnnouncementText?: string; + zoomResetAnnouncementText?: string; +} + +// Every zoom string, with the consumer's overrides applied. +export type ResolvedZoomI18n = Required; + +// Resolves the zoom strings against their defaults. Memoized on the strings object so its identity is +// stable across renders, keeping the zoom callbacks (which depend on the announcement formatters) from +// being recreated on every render. +export function useZoomI18n(i18nStrings: undefined | ZoomI18nStrings): ResolvedZoomI18n { + return 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", + // The cursor is a slider: the label names the control, and its value is announced from + // aria-valuetext as it moves. + zoomCursorAriaLabel: i18nStrings?.zoomCursorAriaLabel ?? "Zoom range cursor", + 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}`), + zoomModeExitedAnnouncementText: i18nStrings?.zoomModeExitedAnnouncementText ?? "Zoom mode cancelled", + zoomResetAnnouncementText: i18nStrings?.zoomResetAnnouncementText ?? "Zoom reset. Showing the full data range.", + }), + [i18nStrings], + ); +} diff --git a/src/core/chart-zoom/zoom-overlay.tsx b/src/core/chart-zoom/zoom-overlay.tsx new file mode 100644 index 00000000..6a4ff25c --- /dev/null +++ b/src/core/chart-zoom/zoom-overlay.tsx @@ -0,0 +1,132 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import clsx from "clsx"; + +import Icon, { IconProps } from "@cloudscape-design/components/icon"; + +import { ResolvedZoomI18n } from "./zoom-i18n"; + +import testClasses from "../test-classes/styles.css.js"; +import styles from "./styles.css.js"; + +// The direction the cursor moves in, expressed in pixels rather than in data values: -1 moves towards +// the start of the plot on screen, +1 towards its end. The hook translates that into a value step, which +// is the opposite when the axis is reversed. Sharing the pixel convention with the arrow keys keeps the +// buttons pointing where the cursor actually goes. +export type PixelDirection = -1 | 1; + +// Elements the zoom hook writes to directly. Everything on the cursor-move and drag hot paths is applied +// imperatively: a React re-render of the chart re-initializes the Highcharts axes, which is far too +// expensive to do per pointer event. +export interface ZoomOverlayRefs { + band: React.RefObject; + startDivider: React.RefObject; + endDivider: React.RefObject; + cursor: React.RefObject; + cluster: React.RefObject; + previousButton: React.RefObject; + nextButton: React.RefObject; +} + +interface ZoomOverlayProps { + refs: ZoomOverlayRefs; + i18n: ResolvedZoomI18n; + // True when the x axis runs vertically, which is the case in inverted charts. + vertical: boolean; + isRtl: boolean; + onKeyDown: React.KeyboardEventHandler; + onBlur: React.FocusEventHandler; + onStep: (direction: PixelDirection) => void; +} + +// Everything drawn on top of the plot while a zoom interaction is in progress: the selected range, the +// boundaries of the selection, the keyboard cursor, and the cursor's pointer controls. The overlay covers +// the plot but lets pointer events through, so hovering and dragging the chart keep working; only the +// buttons opt back in. +export default function ZoomOverlay({ refs, i18n, vertical, isRtl, onKeyDown, onBlur, onStep }: ZoomOverlayProps) { + return ( +
+
+
+
+ + {/* + The cursor is a real slider so that its position is announced as it moves, without a live region, + and so that the arrow keys it handles are the ones assistive technology expects. Its value and + range are written imperatively by the hook; a keyboard step must not re-render the chart. + */} +
+ +
+ onStep(-1)} + /> + onStep(1)} + /> +
+
+ ); +} + +interface ZoomCursorButtonProps { + buttonRef: React.RefObject; + iconName: IconProps.Name; + ariaLabel: string; + testClassName: string; + onClick: () => void; +} + +function ZoomCursorButton({ buttonRef, iconName, ariaLabel, testClassName, onClick }: ZoomCursorButtonProps) { + return ( + + ); +} + +// The arrow to draw on a step button. Cloudscape mirrors the horizontal arrow icons in right-to-left +// pages, so the name is chosen such that the rendered arrow points along the plot, whichever way the +// page runs. The vertical arrows are never mirrored. +function stepIconName(direction: PixelDirection, vertical: boolean, isRtl: boolean): IconProps.Name { + if (vertical) { + return direction === -1 ? "arrow-up" : "arrow-down"; + } + const pointsPhysicallyLeft = direction === -1; + return pointsPhysicallyLeft !== isRtl ? "arrow-left" : "arrow-right"; +} diff --git a/src/core/chart-zoom/zoom-state-machine.ts b/src/core/chart-zoom/zoom-state-machine.ts new file mode 100644 index 00000000..48e4ddf5 --- /dev/null +++ b/src/core/chart-zoom/zoom-state-machine.ts @@ -0,0 +1,467 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { MIN_ZOOM_POINTS } from "./zoom-values"; + +/** + * The zoom interaction as a finite state machine. + * + * Selecting a range is a small interaction with many entry points: three buttons, eight keys, the pointer, + * and the chart re-rendering underneath it. The states and the transitions between them are therefore + * spelled out here, separately from the React and Highcharts plumbing that carries them out. This module is + * pure: it holds no chart, measures nothing, and deals only in indices into the list of x values the cursor + * can sit on. The hook resolves those indices to values, draws them, and moves focus. + * + * enterZoomMode + * +-------------------------------------------------+ + * | v + * +------+ +-----------+ --+ stepCursor, moveCursorToEdge, + * | | exitZoomMode, resetZoom, and | | | pointerMove, and the commitPoint + * | idle | <------------------------------------- | selecting | <-+ that sets the range start + * | | the commitPoint that applies a zoom | | + * +------+ +-----------+ + * | ^ | ^ + * | | pointerDown, and the pointerUp that | | pointerDown, and the pointerUp that + * | | ends a click the chart has handled | | sets a boundary where the click landed + * v | v | + * +---------------------------------------------------------+ + * | pressed | + * | A press that has not travelled yet, and may still turn | + * | out to be a click. | + * +---------------------------------------------------------+ + * | + * | the pointer travels past the drag threshold + * v + * +----------+ --> idle, applying the zoom, when the drag was wide enough + * | dragging | + * +----------+ --> back to where the press started from, on a pointerCancel or on a + * drag too narrow to zoom into + * + * Both pointer states carry the selection they interrupted, so that a press over a chart in zoom mode + * returns to that selection, while a press over a chart outside zoom mode returns to idle. + */ + +/** The two boundaries of a range being selected. The range is complete once the anchor is set. */ +export interface ZoomSelection { + // Where the cursor is, which is the boundary the next commit sets. + cursorIndex: number; + // Where the range starts, or null while no start point has been set. + anchorIndex: null | number; +} + +/** A pointer being held down over the plot. */ +export interface ZoomPress { + pointerId: number; + // Where the pointer went down, in client coordinates. The hook measures the travel from here against the + // drag threshold, because only it knows which way the axis runs on screen. + clientX: number; + clientY: number; + // The stop the press started on, and the one under the pointer now. They are equal until the press turns + // into a drag. + startIndex: number; + currentIndex: number; +} + +interface IdleState { + type: "idle"; +} + +interface SelectingState { + type: "selecting"; + selection: ZoomSelection; +} + +interface PressedState { + type: "pressed"; + press: ZoomPress; + // The selection the press interrupted, or null when the press started outside zoom mode. + resume: null | ZoomSelection; +} + +interface DraggingState { + type: "dragging"; + press: ZoomPress; + resume: null | ZoomSelection; +} + +export type ZoomState = IdleState | SelectingState | PressedState | DraggingState; + +export const IDLE_ZOOM_STATE: ZoomState = { type: "idle" }; + +export type ZoomEvent = + // The "Zoom" button, the chart API, and a shortcut all start a selection at the stop the hook picks: the + // visible start of the plot, whichever way the axis runs. + | { type: "enterZoomMode"; startIndex: number } + // The "Exit zoom" button, Escape, the chart API, and focus leaving the chart. + | { type: "exitZoomMode"; moveFocus: boolean } + // The "Reset" button and the chart API. Whether there is a zoom to reset is the hook's to know, because + // the range may be controlled by the consumer. + | { type: "resetZoom"; zoomed: boolean; moveFocus: boolean } + // The arrow keys, Page Up/Down, and the cursor's step buttons. The offset is already expressed in stops + // along the value axis, with the direction on screen resolved by the hook. + | { type: "stepCursor"; offset: number } + // Home and End. + | { type: "moveCursorToEdge"; edge: "first" | "last" } + // Enter, Space, and a click or tap on the plot. + | { type: "commitPoint" } + | { type: "pointerDown"; press: ZoomPress } + | { + type: "pointerMove"; + pointerId: number; + // The stop nearest to the pointer. + index: number; + // Whether the pointer has travelled far enough from where it went down to count as a drag. + passedThreshold: boolean; + insidePlot: boolean; + } + | { type: "pointerUp"; pointerId: number } + | { type: "pointerCancel"; pointerId: number } + // The chart has re-rendered: the stops the cursor can sit on change with the zoomed range and with which + // series are visible. + | { type: "chartRendered" }; + +/** Where focus must go once the transition has been rendered. */ +export type ZoomFocusTarget = "cursor" | "zoomButton" | "resetButton"; + +export type ZoomAnnouncement = + | { type: "zoomModeEntered"; index: number } + | { type: "startPointSet"; index: number } + | { type: "zoomModeExited" }; + +/** + * What the hook must do for a transition, beyond drawing the new state. The two zoom commands each also + * announce their outcome, which the hook words from the values they resolve to. + */ +export type ZoomEffect = + // Dismiss the highlighted point and its tooltip, which the selection is taking the plot over from. + | { type: "clearHighlight" } + | { type: "announce"; announcement: ZoomAnnouncement } + | { type: "focus"; target: ZoomFocusTarget } + // Zoom into the range between two stops. + | { type: "applyZoom"; fromIndex: number; toIndex: number } + // Show the full range again. Only emitted for a chart that is zoomed in. + | { type: "resetZoom" }; + +export interface ZoomTransition { + state: ZoomState; + effects: readonly ZoomEffect[]; +} + +export interface ZoomContext { + // How many stops the cursor can sit on, as of the chart's last render. + valuesCount: number; +} + +/** + * The next state and what has to happen for it. An event that means nothing in the current state returns + * that same state, with no effects: the hook can then skip the work a transition would cause. + */ +export function zoomReducer(state: ZoomState, event: ZoomEvent, context: ZoomContext): ZoomTransition { + switch (state.type) { + case "idle": + return fromIdle(state, event, context); + case "selecting": + return fromSelecting(state, event, context); + case "pressed": + return fromPressed(state, event, context); + case "dragging": + return fromDragging(state, event, context); + } +} + +function fromIdle(state: IdleState, event: ZoomEvent, context: ZoomContext): ZoomTransition { + switch (event.type) { + case "enterZoomMode": + return enterZoomMode(state, event.startIndex, context); + case "resetZoom": + return resetZoom(event); + case "pointerDown": + // Outside zoom mode a press is only watched in case it becomes a drag. Until then the chart keeps + // behaving as it did, so that a click still selects a point. + return stay({ type: "pressed", press: event.press, resume: null }); + default: + return stay(state); + } +} + +function fromSelecting(state: SelectingState, event: ZoomEvent, context: ZoomContext): ZoomTransition { + const { valuesCount } = context; + switch (event.type) { + case "enterZoomMode": + return enterZoomMode(state, event.startIndex, context); + case "exitZoomMode": + return exitZoomMode(event.moveFocus); + case "resetZoom": + return resetZoom(event); + case "stepCursor": + return moveCursor(state, state.selection.cursorIndex + event.offset, valuesCount); + case "moveCursorToEdge": + return moveCursor(state, event.edge === "first" ? 0 : valuesCount - 1, valuesCount); + case "commitPoint": + return commitPoint(state.selection, context); + case "pointerDown": + return stay({ type: "pressed", press: event.press, resume: state.selection }); + case "pointerMove": + // With no press in progress the cursor follows the pointer, so that a click sets the boundary the + // cursor is showing. Outside the plot the cursor stays where it is. + return event.insidePlot ? moveCursor(state, event.index, valuesCount) : stay(state); + case "chartRendered": + return reconcile(state, context); + default: + return stay(state); + } +} + +function fromPressed(state: PressedState, event: ZoomEvent, context: ZoomContext): ZoomTransition { + const { press, resume } = state; + switch (event.type) { + case "pointerMove": + if (event.pointerId !== press.pointerId || !event.passedThreshold) { + // Below the threshold the press is still a candidate click, and the cursor stays where it is, so + // that releasing sets the boundary the cursor is showing. + return stay(state); + } + return transition({ type: "dragging", press: { ...press, currentIndex: event.index }, resume }, [ + { type: "clearHighlight" }, + ]); + case "pointerUp": { + if (event.pointerId !== press.pointerId) { + return stay(state); + } + if (!resume) { + // Outside zoom mode the chart has already handled the click. + return stay(IDLE_ZOOM_STATE); + } + // In zoom mode a press that did not travel is a click, which sets a range boundary where it landed. + const cursorIndex = clampIndex(press.startIndex, context.valuesCount); + return commitPoint({ cursorIndex, anchorIndex: resume.anchorIndex }, context); + } + case "pointerCancel": + return event.pointerId === press.pointerId ? stay(resumeState(resume)) : stay(state); + case "pointerDown": + // A new press supersedes the one in progress, and inherits the selection it was interrupting. + return stay({ type: "pressed", press: event.press, resume }); + case "enterZoomMode": + return enterZoomMode(state, event.startIndex, context); + case "exitZoomMode": + // Outside zoom mode there is nothing to exit, and the press is left to run its course. + return resume ? exitZoomMode(event.moveFocus) : stay(state); + case "resetZoom": + return resetZoom(event); + case "chartRendered": + return reconcile(state, context); + default: + return stay(state); + } +} + +function fromDragging(state: DraggingState, event: ZoomEvent, context: ZoomContext): ZoomTransition { + const { press, resume } = state; + switch (event.type) { + case "pointerMove": + if (event.pointerId !== press.pointerId || event.index === press.currentIndex) { + return stay(state); + } + return stay({ type: "dragging", press: { ...press, currentIndex: event.index }, resume }); + case "pointerUp": { + if (event.pointerId !== press.pointerId) { + return stay(state); + } + if (!isZoomableRange(press.startIndex, press.currentIndex)) { + // A drag too narrow to zoom into is discarded, and the interaction returns to where it started. + return stay(resumeState(resume)); + } + const effects: ZoomEffect[] = [{ type: "applyZoom", fromIndex: press.startIndex, toIndex: press.currentIndex }]; + // Focus only follows the zoom when the drag started in zoom mode, where the cursor holds it and is + // about to be hidden. A drag started outside zoom mode was driven by the pointer alone. + if (resume) { + effects.push({ type: "focus", target: "resetButton" }); + } + return transition(IDLE_ZOOM_STATE, effects); + } + case "pointerCancel": + return event.pointerId === press.pointerId ? stay(resumeState(resume)) : stay(state); + case "pointerDown": + return stay({ type: "pressed", press: event.press, resume }); + case "enterZoomMode": + return enterZoomMode(state, event.startIndex, context); + case "exitZoomMode": + return exitZoomMode(event.moveFocus); + case "resetZoom": + return resetZoom(event); + case "chartRendered": + return reconcile(state, context); + default: + return stay(state); + } +} + +// Entering zoom mode starts a new selection at the given stop, dropping whatever was selected before. +function enterZoomMode(state: ZoomState, startIndex: number, { valuesCount }: ZoomContext): ZoomTransition { + if (!canZoomInto(valuesCount)) { + return stay(state); + } + const cursorIndex = clampIndex(startIndex, valuesCount); + return transition({ type: "selecting", selection: { cursorIndex, anchorIndex: null } }, [ + { type: "clearHighlight" }, + { type: "announce", announcement: { type: "zoomModeEntered", index: cursorIndex } }, + { type: "focus", target: "cursor" }, + ]); +} + +function exitZoomMode(moveFocus: boolean): ZoomTransition { + const effects: ZoomEffect[] = [{ type: "announce", announcement: { type: "zoomModeExited" } }]; + if (moveFocus) { + effects.push({ type: "focus", target: "zoomButton" }); + } + return transition(IDLE_ZOOM_STATE, effects); +} + +// Resetting ends the interaction from any state, but only a chart that is zoomed in has something to reset. +function resetZoom({ zoomed, moveFocus }: { zoomed: boolean; moveFocus: boolean }): ZoomTransition { + if (!zoomed) { + return stay(IDLE_ZOOM_STATE); + } + const effects: ZoomEffect[] = [{ type: "resetZoom" }]; + if (moveFocus) { + effects.push({ type: "focus", target: "zoomButton" }); + } + return transition(IDLE_ZOOM_STATE, effects); +} + +// Sets the range start on the first commit and applies the zoom on the second, which is what a click or a tap +// on the plot, and Enter, all do. +function commitPoint(selection: ZoomSelection, { valuesCount }: ZoomContext): ZoomTransition { + const selecting: ZoomState = { type: "selecting", selection }; + if (valuesCount === 0) { + return stay(selecting); + } + const { cursorIndex, anchorIndex } = selection; + if (anchorIndex === null) { + return transition({ type: "selecting", selection: { cursorIndex, anchorIndex: cursorIndex } }, [ + { type: "announce", announcement: { type: "startPointSet", index: cursorIndex } }, + ]); + } + if (!isZoomableRange(anchorIndex, cursorIndex)) { + // A range of a single point has no width, and the chart cannot display it. Rather than applying a zoom + // that cannot be undone by zooming again, the start point is kept and restated. + return transition(selecting, [{ type: "announce", announcement: { type: "startPointSet", index: anchorIndex } }]); + } + return transition(IDLE_ZOOM_STATE, [ + { type: "applyZoom", fromIndex: anchorIndex, toIndex: cursorIndex }, + { type: "focus", target: "resetButton" }, + ]); +} + +// Brings the state in line with what the chart now shows. +function reconcile(state: ZoomState, { valuesCount }: ZoomContext): ZoomTransition { + if (valuesCount === 0) { + // Nothing left to select, for instance because all series were filtered out. A press that is not part + // of a selection is left alone: it belongs to the chart, which is still there. + return getInteraction(state) === "idle" ? stay(state) : exitZoomMode(false); + } + switch (state.type) { + case "idle": + return stay(state); + case "selecting": + return stay({ type: "selecting", selection: clampSelection(state.selection, valuesCount) }); + case "pressed": + return stay({ ...state, resume: state.resume && clampSelection(state.resume, valuesCount) }); + case "dragging": + return stay({ ...state, resume: state.resume && clampSelection(state.resume, valuesCount) }); + } +} + +function moveCursor(state: SelectingState, index: number, valuesCount: number): ZoomTransition { + if (valuesCount === 0) { + return stay(state); + } + const cursorIndex = clampIndex(index, valuesCount); + return stay( + cursorIndex === state.selection.cursorIndex + ? state + : { type: "selecting", selection: { ...state.selection, cursorIndex } }, + ); +} + +function stay(state: ZoomState): ZoomTransition { + return { state, effects: NO_EFFECTS }; +} + +function transition(state: ZoomState, effects: readonly ZoomEffect[]): ZoomTransition { + return { state, effects }; +} + +const NO_EFFECTS: readonly ZoomEffect[] = []; + +function resumeState(resume: null | ZoomSelection): ZoomState { + return resume ? { type: "selecting", selection: resume } : IDLE_ZOOM_STATE; +} + +function clampSelection(selection: ZoomSelection, valuesCount: number): ZoomSelection { + return { + cursorIndex: clampIndex(selection.cursorIndex, valuesCount), + // A start point that is no longer in the plot cannot be zoomed into, so the selection starts over. + anchorIndex: selection.anchorIndex !== null && selection.anchorIndex < valuesCount ? selection.anchorIndex : null, + }; +} + +function clampIndex(index: number, valuesCount: number): number { + return Math.min(Math.max(index, 0), Math.max(0, valuesCount - 1)); +} + +// A range must span at least two stops: a narrower one has no width, and the chart cannot display it. +function isZoomableRange(fromIndex: number, toIndex: number): boolean { + return Math.abs(toIndex - fromIndex) + 1 >= MIN_ZOOM_POINTS; +} + +// With two stops or fewer there is no narrower range left to select, so there is nothing to zoom into. +export function canZoomInto(valuesCount: number): boolean { + return valuesCount > MIN_ZOOM_POINTS; +} + +/** + * What React must know about the state. Everything else is drawn imperatively, so that stepping the cursor + * or dragging across the plot causes no render at all. + */ +export type ZoomInteraction = "idle" | "cursor" | "drag"; + +export function getInteraction(state: ZoomState): ZoomInteraction { + switch (state.type) { + case "idle": + return "idle"; + case "selecting": + return "cursor"; + // A press is invisible until it becomes a drag: it shows whatever it interrupted. + case "pressed": + return state.resume ? "cursor" : "idle"; + case "dragging": + return "drag"; + } +} + +/** What the overlay draws for the state. */ +export type ZoomOverlayView = + | { type: "hidden" } + | { type: "cursor"; cursorIndex: number; anchorIndex: null | number } + | { type: "range"; fromIndex: number; toIndex: number }; + +export function getOverlayView(state: ZoomState): ZoomOverlayView { + switch (state.type) { + case "idle": + return HIDDEN_VIEW; + case "selecting": + return { type: "cursor", ...state.selection }; + case "pressed": + return state.resume ? { type: "cursor", ...state.resume } : HIDDEN_VIEW; + case "dragging": + return { type: "range", fromIndex: state.press.startIndex, toIndex: state.press.currentIndex }; + } +} + +const HIDDEN_VIEW: ZoomOverlayView = { type: "hidden" }; + +/** The press in progress, which the hook measures the drag threshold against. */ +export function getPress(state: ZoomState): null | ZoomPress { + return state.type === "pressed" || state.type === "dragging" ? state.press : null; +} diff --git a/src/core/chart-zoom/zoom-values.ts b/src/core/chart-zoom/zoom-values.ts new file mode 100644 index 00000000..15cf0cdf --- /dev/null +++ b/src/core/chart-zoom/zoom-values.ts @@ -0,0 +1,127 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type Highcharts from "highcharts"; + +import { getChartSeries, getSeriesData } from "../../internal/utils/highcharts"; + +// A zoomed range always spans at least this many data points. Selecting a single point produces a range +// with no width, which Highcharts cannot display and which the user cannot zoom out of by zooming again. +export const MIN_ZOOM_POINTS = 2; + +// Error bar series are excluded everywhere x values are collected. Their data items declare `low` and +// `high` but may omit `x` (see CoreChartProps range data), in which case Highcharts assigns index-based +// x values that have nothing to do with the parent series' x values, and would otherwise appear as +// cursor stops of their own. +function isZoomableSeries(series: { type?: string }) { + return series.type !== "errorbar"; +} + +// The series options accepted by the core chart form a wider union than Highcharts.SeriesOptionsType, and +// the minimal range computation only needs the type and the raw data items. +export interface SeriesOptionsLike { + type?: string; + // Most series declare an array of data items, but a few Highcharts series types (for instance + // networkgraph) allow an object instead, so the shape is narrowed where it is read. + data?: unknown; +} + +// Collects the x values the zoom cursor can land on, sorted ascending. Only values inside the current +// axis extremes are returned: once zoomed in, the cursor must not be able to step outside the visible +// window, and Highcharts keeps out-of-range points in the series until the series exceeds cropThreshold. +export function getVisibleXValues(chart: Highcharts.Chart): number[] { + // The render event can fire before the axes exist, for example when the chart has no data at all. + const xAxis = chart.xAxis?.[0]; + const { min, max } = xAxis ? xAxis.getExtremes() : { min: undefined, max: undefined }; + const xValues = new Set(); + for (const series of getChartSeries(chart)) { + if (series.visible && isZoomableSeries(series)) { + for (const point of getSeriesData(series)) { + if ((typeof min !== "number" || point.x >= min) && (typeof max !== "number" || point.x <= max)) { + xValues.add(point.x); + } + } + } + } + return Array.from(xValues).sort((a, b) => a - b); +} + +// Index of the value closest to the target. Binary search, because this runs for every pointer move and +// every cursor step, on series that can hold tens of thousands of points. +export function findNearestIndex(values: readonly number[], target: number): number { + if (values.length === 0) { + return -1; + } + let lo = 0; + let hi = values.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (values[mid] < target) { + lo = mid + 1; + } else { + hi = mid; + } + } + // The search settles on the first value >= target, so its predecessor can still be the closer one. + if (lo > 0 && Math.abs(values[lo - 1] - target) <= Math.abs(values[lo] - target)) { + return lo - 1; + } + return lo; +} + +// The number of cursor stops a page step covers. Small series step by one point, so paging still moves +// the cursor when the visible window holds fewer than ten points. +export function getPageStep(valuesCount: number): number { + return Math.max(1, Math.floor(valuesCount / 10)); +} + +// The smallest distance between two adjacent x values across all series, used as the axis minRange. +// +// Highcharts computes a minRange of its own when the axis has no explicit bounds (five times the closest +// data range, see Axis.adjustForMinRange), and silently widens any narrower range passed to setExtremes. +// Zooming to a handful of points would then apply a range the chart never displays. Deriving minRange +// from the series data instead allows a zoom down to two adjacent points. It never affects the initial +// view: the full data range is at least as wide as the gap between two adjacent points. +export function getSeriesMinRange(allSeries: undefined | readonly SeriesOptionsLike[]): undefined | number { + const xValues = new Set(); + for (const series of allSeries ?? []) { + if (!isZoomableSeries(series)) { + continue; + } + const data = series.data; + if (!Array.isArray(data)) { + continue; + } + data.forEach((item, index) => { + const x = getDataItemX(item, index); + if (x !== undefined) { + xValues.add(x); + } + }); + } + const sorted = Array.from(xValues).sort((a, b) => a - b); + let smallest: undefined | number; + for (let i = 1; i < sorted.length; i++) { + const gap = sorted[i] - sorted[i - 1]; + if (gap > 0 && (smallest === undefined || gap < smallest)) { + smallest = gap; + } + } + return smallest; +} + +// Highcharts accepts three data item shapes: a tuple ([x, y]), an object (x optional), or a bare y value. +// The latter two fall back to the item's index, matching how Highcharts assigns x values itself. +function getDataItemX(item: unknown, index: number): undefined | number { + if (Array.isArray(item)) { + return typeof item[0] === "number" && isFinite(item[0]) ? item[0] : undefined; + } + if (item && typeof item === "object") { + const x = (item as { x?: unknown }).x; + if (x === undefined) { + return index; + } + return typeof x === "number" && isFinite(x) ? x : undefined; + } + return index; +} diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index 9c910817..fc1f06b7 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -4,6 +4,11 @@ import type Highcharts from "highcharts"; import { type NonCancelableEventHandler } from "../types/events"; +import type { + ZoomChangeDetail as ChartZoomChangeDetail, + ZoomOptions as ChartZoomOptions, + ZoomRange as ChartZoomRange, +} from "./chart-zoom/interfaces"; export type ChartSeriesMarkerStatus = "warning" | "default"; @@ -160,6 +165,23 @@ export interface WithCartesianI18nStrings { * * `chartRoleDescription` (optional, string) - Accessible role description of the chart plot area, e.g. "interactive chart". * * `xAxisRoleDescription` (optional, string) - Accessible role description of the x axis, e.g. "x axis". * * `yAxisRoleDescription` (optional, string) - Accessible role description of the y axis, e.g. "y axis". + * * `enterZoomModeButtonText` (optional, string) - Visible label for the "Zoom" button that enters zoom mode. + * * `enterZoomModeButtonAriaLabel` (optional, string) - Accessible label for the "Zoom" button. + * * `exitZoomModeButtonText` (optional, string) - Visible label for the "Exit zoom" button that exits zoom mode without zooming. + * * `exitZoomModeButtonAriaLabel` (optional, string) - Accessible label for the "Exit zoom" button. + * * `resetZoomButtonText` (optional, string) - Visible label for the "Reset" button that resets zoom to full range. + * * `resetZoomButtonAriaLabel` (optional, string) - Accessible label for the "Reset" button. + * * `zoomControlsAriaLabel` (optional, string) - Accessible label for the zoom controls region, e.g. "Chart zoom controls". + * * `zoomCursorAriaLabel` (optional, string) - Accessible label for the zoom range cursor. + * * `zoomCursorPreviousButtonAriaLabel` (optional, string) - Accessible label for the button that moves the zoom cursor to the previous data point. + * * `zoomCursorNextButtonAriaLabel` (optional, string) - Accessible label for the button that moves the zoom cursor to the next data point. + * * `zoomModeEnteredAnnouncementText` (optional, function) - Screen reader announcement when zoom mode is entered. Receives the formatted cursor value. + * * `zoomCursorPositionAnnouncementText` (optional, function) - Screen reader announcement when the zoom cursor moves. Receives the formatted cursor value. + * * `zoomStartPointAnnouncementText` (optional, function) - Screen reader announcement when the start of the range is set. Receives the formatted start value. + * * `zoomRangeChangeAnnouncementText` (optional, function) - Screen reader announcement when the zoom range changes. Receives the formatted start and end values. + * * `zoomModeExitedAnnouncementText` (optional, string) - Screen reader announcement when zoom mode is exited without zooming. + * * `zoomResetAnnouncementText` (optional, string) - Screen reader announcement when the zoom is reset to the full data range. + * * `zoomSelectionAnnouncementText` (optional, function) - Screen reader announcement while the range is being selected. Receives the formatted start and end values. */ i18nStrings?: CartesianI18nStrings; } @@ -187,6 +209,40 @@ export interface WithPieI18nStrings { export interface CartesianI18nStrings extends BaseI18nStrings { xAxisRoleDescription?: string; yAxisRoleDescription?: string; + /** Visible label for the "Zoom" button that enters zoom mode. @defaultValue "Zoom" */ + enterZoomModeButtonText?: string; + /** Accessible label for the "Zoom" button. @defaultValue "Enter zoom mode" */ + enterZoomModeButtonAriaLabel?: string; + /** Visible label for the "Exit zoom" button that exits zoom mode without zooming. @defaultValue "Exit zoom" */ + exitZoomModeButtonText?: string; + /** Accessible label for the "Exit zoom" button. @defaultValue "Exit zoom mode" */ + exitZoomModeButtonAriaLabel?: string; + /** Visible label for the "Reset" button that resets zoom to full range. @defaultValue "Reset" */ + resetZoomButtonText?: string; + /** Accessible label for the "Reset" button. @defaultValue "Reset zoom to show full data range" */ + resetZoomButtonAriaLabel?: string; + /** Accessible label for the zoom controls region. @defaultValue "Chart zoom controls" */ + zoomControlsAriaLabel?: string; + /** Accessible label for the zoom range cursor. @defaultValue "Zoom range cursor" */ + zoomCursorAriaLabel?: string; + /** Accessible label for the button that moves the zoom cursor to the previous point. @defaultValue "Move zoom cursor left" */ + zoomCursorPreviousButtonAriaLabel?: string; + /** Accessible label for the button that moves the zoom cursor to the next point. @defaultValue "Move zoom cursor right" */ + zoomCursorNextButtonAriaLabel?: string; + /** Screen reader announcement when zoom mode is entered. Receives the formatted cursor value. @defaultValue (value) => \`Zoom mode. Cursor at ${value}. Use arrow keys to move, Enter to set the start point.\` */ + zoomModeEnteredAnnouncementText?: (value: string) => string; + /** Screen reader announcement when the zoom cursor moves. Receives the formatted cursor value. @defaultValue (value) => value */ + zoomCursorPositionAnnouncementText?: (value: string) => string; + /** Screen reader announcement when the zoom start point is set. Receives the formatted start value. @defaultValue (value) => \`Start point set at ${value}. Move the cursor and set the end point to zoom.\` */ + zoomStartPointAnnouncementText?: (value: string) => string; + /** Screen reader announcement when the zoom range changes. Receives the formatted start and end values. @defaultValue (startValue, endValue) => \`Zoomed from ${startValue} to ${endValue}\` */ + zoomRangeChangeAnnouncementText?: (startValue: string, endValue: string) => string; + /** Screen reader announcement when zoom mode is exited without zooming. @defaultValue "Zoom mode cancelled" */ + zoomModeExitedAnnouncementText?: string; + /** Screen reader announcement when the zoom is reset to the full data range. @defaultValue "Zoom reset. Showing the full data range." */ + zoomResetAnnouncementText?: string; + /** Screen reader announcement while adjusting the keyboard zoom selection. Receives the formatted start and end values. @defaultValue (startValue, endValue) => \`Selecting zoom range from ${startValue} to ${endValue}\` */ + zoomSelectionAnnouncementText?: (startValue: string, endValue: string) => string; } export interface PieI18nStrings extends BaseI18nStrings { @@ -392,6 +448,31 @@ export interface CoreChartProps * Use this property to add timeline navigation, range selectors, or other custom navigation elements. */ navigator?: React.ReactNode; + /** + * Zoom settings, allowing the users to zoom into a range of the x-axis. Zooming is possible by dragging + * across the chart plot, or by entering zoom mode with the "Zoom" button and selecting the range start and + * end with a click, a tap, Enter, or Space. + * + * Supported options: + * * `enabled` (optional, boolean) - Enables zooming. Defaults to `false`. + * * `hideButtons` (optional, boolean) - Hides the built-in zoom buttons. Use it when providing custom + * controls, that use the `enterZoomMode`, `exitZoomMode`, and `resetZoom` methods of the component's ref. + */ + zoom?: CoreChartProps.ZoomOptions; + /** + * The zoomed range of the x-axis. By default, the range is managed by the component. When using this property, + * manage state updates with `onZoomRangeChange`, and use `null` to show the full data range. + * + * Supported options: + * * `x` (optional, object) - The zoomed x-axis range, as `startValue` and `endValue`. For datetime axes the + * values are timestamps in milliseconds. + */ + zoomRange?: CoreChartProps.ZoomRange | null; + /** + * A callback function, triggered when the zoomed range changes as a result of user interaction with the chart + * or the zoom controls. The detail's `zoomRange` is `null` when the zoom is reset to the full data range. + */ + onZoomRangeChange?: NonCancelableEventHandler; /** * A custom slot below the chart plot and legend. */ @@ -466,6 +547,10 @@ export namespace CoreChartProps { highlightChartPoint(point: Highcharts.Point): void; highlightChartGroup(group: readonly Highcharts.Point[]): void; clearChartHighlight(): void; + // The zoom methods are no-ops when zooming is not enabled with the zoom property. + enterZoomMode(): void; + exitZoomMode(): void; + resetZoom(): void; } /** @@ -482,6 +567,12 @@ export namespace CoreChartProps { export type XAxisOptions = Highcharts.XAxisOptions & { valueFormatter?: (value: null | number) => string }; export type YAxisOptions = Highcharts.YAxisOptions & { valueFormatter?: (value: null | number) => string }; + // The zoom types are owned by the zoom implementation, and re-exported here so that consumers of the + // core chart, and the components built on top of it, refer to a single definition. + export type ZoomOptions = ChartZoomOptions; + export type ZoomRange = ChartZoomRange; + export type ZoomChangeDetail = ChartZoomChangeDetail; + export interface SizeAxisOptions { id?: string; title: string; diff --git a/src/core/styles.scss b/src/core/styles.scss index a705dc86..2bd7f2a1 100644 --- a/src/core/styles.scss +++ b/src/core/styles.scss @@ -111,6 +111,12 @@ $side-legend-max-inline-size: 30%; flex: 1; } +// The positioning context for the zoom overlay. It wraps the Highcharts container so that the overlay +// elements can use the plot coordinates that Highcharts reports, without an extra offset computation. +.chart-plot-anchor { + position: relative; +} + .bottom-legend-container { display: flex; overflow: auto; @@ -131,3 +137,12 @@ $side-legend-max-inline-size: 30%; // We hide the native focus outline to render a custom one around the chart plot instead. outline: none; } + +// stylelint-disable-next-line selector-class-pattern +:global(.highcharts-navigator-mask-inside) { + cursor: grab; + + &:active { + cursor: grabbing; + } +} diff --git a/src/core/test-classes/styles.scss b/src/core/test-classes/styles.scss index d5bbdbc2..401278f3 100644 --- a/src/core/test-classes/styles.scss +++ b/src/core/test-classes/styles.scss @@ -17,6 +17,13 @@ .chart-header, .chart-footer, .chart-navigator, +.zoom-controls, +.zoom-button, +.exit-zoom-button, +.reset-zoom-button, +.zoom-cursor, +.zoom-cursor-previous-button, +.zoom-cursor-next-button, .axis-x, .axis-x-title, .axis-y, diff --git a/src/test-utils/dom/cartesian-chart/index.ts b/src/test-utils/dom/cartesian-chart/index.ts index da825494..ec881fd9 100644 --- a/src/test-utils/dom/cartesian-chart/index.ts +++ b/src/test-utils/dom/cartesian-chart/index.ts @@ -1,12 +1,14 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { ButtonWrapper } from "@cloudscape-design/components/test-utils/dom"; import { ElementWrapper } from "@cloudscape-design/test-utils-core/dom"; import BaseChartWrapper from "../internal/base"; import { CartesianChartTooltipWrapper } from "./tooltip"; import testClasses from "../../../cartesian-chart/test-classes/styles.selectors.js"; +import coreTestClasses from "../../../core/test-classes/styles.selectors.js"; export default class CartesianChartWrapper extends BaseChartWrapper { static rootSelector: string = testClasses.root; @@ -24,4 +26,52 @@ export default class CartesianChartWrapper extends BaseChartWrapper { public findSeries(): Array { return this.findAllByClassName("highcharts-series"); } + + /** + * Finds the "Zoom" button that enters zoom mode. + * Visible when zoom is enabled and no range is being selected. + */ + public findZoomButton(): null | ButtonWrapper { + return this.findComponent(`.${coreTestClasses["zoom-button"]} .${ButtonWrapper.rootSelector}`, ButtonWrapper); + } + + /** + * Finds the "Exit zoom" button that exits zoom mode without applying zoom. + * Visible while a zoom range is being selected. + */ + public findExitZoomButton(): null | ButtonWrapper { + return this.findComponent(`.${coreTestClasses["exit-zoom-button"]} .${ButtonWrapper.rootSelector}`, ButtonWrapper); + } + + /** + * Finds the "Reset" button that resets zoom to show the full data range. + * Visible when the chart is zoomed in. + */ + public findResetZoomButton(): null | ButtonWrapper { + return this.findComponent(`.${coreTestClasses["reset-zoom-button"]} .${ButtonWrapper.rootSelector}`, ButtonWrapper); + } + + /** + * Finds the zoom range cursor. It is a slider, holding the keyboard focus while a range is being selected. + * Present whenever zoom is enabled, and only shown while a range is being selected. + */ + public findZoomCursor(): null | ElementWrapper { + return this.findByClassName(coreTestClasses["zoom-cursor"]); + } + + /** + * Finds the button that moves the zoom cursor to the previous data point. + * Present whenever zoom is enabled, and only shown while a range is being selected. + */ + public findZoomCursorPreviousButton(): null | ElementWrapper { + return this.findByClassName(coreTestClasses["zoom-cursor-previous-button"]); + } + + /** + * Finds the button that moves the zoom cursor to the next data point. + * Present whenever zoom is enabled, and only shown while a range is being selected. + */ + public findZoomCursorNextButton(): null | ElementWrapper { + return this.findByClassName(coreTestClasses["zoom-cursor-next-button"]); + } } diff --git a/src/test-utils/dom/internal/core.ts b/src/test-utils/dom/internal/core.ts index c14f4f6b..b91f3727 100644 --- a/src/test-utils/dom/internal/core.ts +++ b/src/test-utils/dom/internal/core.ts @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { ButtonWrapper } from "@cloudscape-design/components/test-utils/dom"; import ChartTooltipWrapper from "@cloudscape-design/components/test-utils/dom/internal/chart-tooltip"; import { ElementWrapper } from "@cloudscape-design/test-utils-core/dom"; @@ -38,6 +39,54 @@ export default class CoreChartWrapper extends BaseChartWrapper { public findHorizontalAxisTitle(): null | ElementWrapper { return this.find(`.highcharts-axis.${testClasses["axis-horizontal"]} > .highcharts-axis-title`); } + + /** + * Finds the "Zoom" button that enters zoom mode. + * Visible when zoom is enabled and no range is being selected. + */ + public findZoomButton(): null | ButtonWrapper { + return this.findComponent(`.${testClasses["zoom-button"]} .${ButtonWrapper.rootSelector}`, ButtonWrapper); + } + + /** + * Finds the "Exit zoom" button that exits zoom mode without applying zoom. + * Visible while a zoom range is being selected. + */ + public findExitZoomButton(): null | ButtonWrapper { + return this.findComponent(`.${testClasses["exit-zoom-button"]} .${ButtonWrapper.rootSelector}`, ButtonWrapper); + } + + /** + * Finds the "Reset" button that resets zoom to show the full data range. + * Visible when the chart is zoomed in. + */ + public findResetZoomButton(): null | ButtonWrapper { + return this.findComponent(`.${testClasses["reset-zoom-button"]} .${ButtonWrapper.rootSelector}`, ButtonWrapper); + } + + /** + * Finds the zoom range cursor. It is a slider, holding the keyboard focus while a range is being selected. + * Present whenever zoom is enabled, and only shown while a range is being selected. + */ + public findZoomCursor(): null | ElementWrapper { + return this.findByClassName(testClasses["zoom-cursor"]); + } + + /** + * Finds the button that moves the zoom cursor to the previous data point. + * Present whenever zoom is enabled, and only shown while a range is being selected. + */ + public findZoomCursorPreviousButton(): null | ElementWrapper { + return this.findByClassName(testClasses["zoom-cursor-previous-button"]); + } + + /** + * Finds the button that moves the zoom cursor to the next data point. + * Present whenever zoom is enabled, and only shown while a range is being selected. + */ + public findZoomCursorNextButton(): null | ElementWrapper { + return this.findByClassName(testClasses["zoom-cursor-next-button"]); + } } export class CoreChartLegendWrapper extends BaseChartLegendWrapper { diff --git a/test/functional/cartesian-chart.test.ts b/test/functional/cartesian-chart.test.ts index 4cffc106..64b9d59f 100644 --- a/test/functional/cartesian-chart.test.ts +++ b/test/functional/cartesian-chart.test.ts @@ -36,3 +36,32 @@ test( await expect(page.isExisting(chart.findTooltip().findDismissButton().toSelector())).resolves.toBe(true); }), ); + +// The unit tests emulate the pointer events of a drag, which cannot prove that a real browser produces +// the events the handlers rely on. This exercises the same interaction with an actual pointer. +test( + "zooms into a range by dragging across the plot and resets it afterwards", + setupTest("#/01-cartesian-chart/zoom", async (page) => { + // The page collects a chart per zoom scenario, so the main demo is addressed by its test id. + const chart = w.findCartesianHighcharts('[data-testid="zoom-chart"]'); + const xAxisLabels = chart.find(".highcharts-xaxis-labels").toSelector(); + + const labelsBeforeZoom = await page.getText(xAxisLabels); + await expect(page.isExisting(chart.findResetZoomButton().toSelector())).resolves.toBe(false); + + // Drag across the middle of the plot, from a quarter in to two thirds in. + const plotBox = await page.getBoundingBox(chart.find(".highcharts-plot-background").toSelector()); + await page.moveCursorTo(plotBox.left + plotBox.width * 0.25, plotBox.top + plotBox.height / 2); + await page.dragBy(Math.round(plotBox.width * 0.4), 0); + + // The zoom is applied, so the axis now shows a narrower range and can be reset. + await page.waitForVisible(chart.findResetZoomButton().toSelector()); + await expect(page.getText(xAxisLabels)).resolves.not.toBe(labelsBeforeZoom); + // Nothing of the selection is left drawn over the plot once the zoom is committed. + await expect(page.isDisplayed(chart.findZoomCursor().toSelector())).resolves.toBe(false); + + await page.click(chart.findResetZoomButton().toSelector()); + await expect(page.getText(xAxisLabels)).resolves.toBe(labelsBeforeZoom); + await expect(page.isExisting(chart.findResetZoomButton().toSelector())).resolves.toBe(false); + }), +); diff --git a/test/utils.ts b/test/utils.ts index 7473b711..976d30ba 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -42,6 +42,30 @@ class ChartPageObject extends BasePageObject { ]); } + // Presses the pointer at its current position, moves it by the given offset, and releases it. The move + // is split into steps, because a drag is only recognized from the pointer events emitted along the way. + async dragBy(xOffset: number, yOffset: number, steps = 5) { + await this.browser.performActions([ + { + type: "pointer", + id: "event", + parameters: { pointerType: "mouse" }, + actions: [ + { type: "pointerDown", origin: "pointer", button: 0, duration: 20 }, + ...Array.from({ length: steps }, () => ({ + type: "pointerMove" as const, + duration: 50, + origin: "pointer" as const, + x: Math.round(xOffset / steps), + y: Math.round(yOffset / steps), + })), + { type: "pointerUp", origin: "pointer", button: 0, duration: 20 }, + { type: "pause", duration: 150 }, + ], + }, + ]); + } + async clickHere() { await this.browser.performActions([ {