diff --git a/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Basic-actions-1-chromium-linux.png b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Basic-actions-1-chromium-linux.png new file mode 100644 index 00000000..38349558 Binary files /dev/null and b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Basic-actions-1-chromium-linux.png differ diff --git a/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Basic-actions-2-chromium-linux.png b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Basic-actions-2-chromium-linux.png new file mode 100644 index 00000000..09ad9ed8 Binary files /dev/null and b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Basic-actions-2-chromium-linux.png differ diff --git a/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Basic-actions-3-chromium-linux.png b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Basic-actions-3-chromium-linux.png new file mode 100644 index 00000000..cafbae0b Binary files /dev/null and b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Basic-actions-3-chromium-linux.png differ diff --git a/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Basic-actions-4-chromium-linux.png b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Basic-actions-4-chromium-linux.png new file mode 100644 index 00000000..27563057 Binary files /dev/null and b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Basic-actions-4-chromium-linux.png differ diff --git a/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Default-range-size-duration-1-chromium-linux.png b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Default-range-size-duration-1-chromium-linux.png new file mode 100644 index 00000000..fda467f6 Binary files /dev/null and b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Default-range-size-duration-1-chromium-linux.png differ diff --git a/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Default-range-size-number-1-chromium-linux.png b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Default-range-size-number-1-chromium-linux.png new file mode 100644 index 00000000..16e33fc7 Binary files /dev/null and b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Default-range-size-number-1-chromium-linux.png differ diff --git a/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Height-option-1-chromium-linux.png b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Height-option-1-chromium-linux.png new file mode 100644 index 00000000..79951151 Binary files /dev/null and b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Height-option-1-chromium-linux.png differ diff --git a/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Hide-series-1-chromium-linux.png b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Hide-series-1-chromium-linux.png new file mode 100644 index 00000000..4258d47f Binary files /dev/null and b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Hide-series-1-chromium-linux.png differ diff --git a/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Margin-option-1-chromium-linux.png b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Margin-option-1-chromium-linux.png new file mode 100644 index 00000000..6c60c4e5 Binary files /dev/null and b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Margin-option-1-chromium-linux.png differ diff --git a/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Overlay-click-1-chromium-linux.png b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Overlay-click-1-chromium-linux.png new file mode 100644 index 00000000..c4316bd6 Binary files /dev/null and b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Overlay-click-1-chromium-linux.png differ diff --git a/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Overlay-click-2-chromium-linux.png b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Overlay-click-2-chromium-linux.png new file mode 100644 index 00000000..590017df Binary files /dev/null and b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Overlay-click-2-chromium-linux.png differ diff --git a/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Overlay-click-3-chromium-linux.png b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Overlay-click-3-chromium-linux.png new file mode 100644 index 00000000..3864b3a4 Binary files /dev/null and b/src/__snapshots__/range-slider.visual.test.tsx-snapshots/Range-slider-Overlay-click-3-chromium-linux.png differ diff --git a/src/__stories__/Other/RangeSlider/RangeSlider.stories.tsx b/src/__stories__/Other/RangeSlider/RangeSlider.stories.tsx index 4f095eaf..b0c26f6a 100644 --- a/src/__stories__/Other/RangeSlider/RangeSlider.stories.tsx +++ b/src/__stories__/Other/RangeSlider/RangeSlider.stories.tsx @@ -20,6 +20,7 @@ export default meta; type Story = StoryObj; const dateTimeData = cloneDeep(lineTwoYAxisData); +set(dateTimeData, 'xAxis.maxPadding', 0); set(dateTimeData, 'xAxis.rangeSlider', {enabled: true}); set(dateTimeData, 'legend', {enabled: true}); set(dateTimeData, 'title', { @@ -27,6 +28,7 @@ set(dateTimeData, 'title', { }); const dateTimeDataWithRange = cloneDeep(lineTwoYAxisData); +set(dateTimeDataWithRange, 'xAxis.maxPadding', 0); set(dateTimeDataWithRange, 'xAxis.rangeSlider', {enabled: true, defaultRange: {size: 'P1M'}}); set(dateTimeDataWithRange, 'legend', {enabled: true}); set(dateTimeDataWithRange, 'title', { @@ -48,6 +50,7 @@ export const RangeSliderDateTime = { } satisfies Story; const linearData = cloneDeep(scatterLinearXAxisData); +set(linearData, 'xAxis.maxPadding', 0); set(linearData, 'xAxis.rangeSlider', {enabled: true}); set(linearData, 'legend', {enabled: true}); set(linearData, 'title', { @@ -55,6 +58,7 @@ set(linearData, 'title', { }); const linearDataWithRange = cloneDeep(scatterLinearXAxisData); +set(linearDataWithRange, 'xAxis.maxPadding', 0); set(linearDataWithRange, 'xAxis.rangeSlider', {enabled: true, defaultRange: {size: 1000}}); set(linearDataWithRange, 'legend', {enabled: true}); set(linearDataWithRange, 'title', { @@ -74,3 +78,20 @@ export const RangeSliderLinear = { ), } satisfies Story; + +const dateTimeDataWithPadding = cloneDeep(lineTwoYAxisData); +set(dateTimeDataWithPadding, 'xAxis.maxPadding', 0.2); +set(dateTimeDataWithPadding, 'xAxis.rangeSlider', {enabled: true}); +set(dateTimeDataWithPadding, 'legend', {enabled: true}); +set(dateTimeDataWithPadding, 'title', { + text: 'With maxPadding 0.2', +}); + +export const RangeSliderWithPadding = { + name: 'With maxPadding', + render: () => ( +
+ +
+ ), +} satisfies Story; diff --git a/src/__tests__/range-slider.visual.test.tsx b/src/__tests__/range-slider.visual.test.tsx new file mode 100644 index 00000000..2f54aea0 --- /dev/null +++ b/src/__tests__/range-slider.visual.test.tsx @@ -0,0 +1,157 @@ +import React from 'react'; + +import {expect, test} from '@playwright/experimental-ct-react'; +import cloneDeep from 'lodash/cloneDeep'; +import merge from 'lodash/merge'; + +import {ChartTestStory} from '../../playwright/components/ChartTestStory'; +import {lineTwoYAxisData, scatterLinearXAxisData} from '../__stories__/__data__'; +import type {ChartData, DeepPartial} from '../types'; + +import {dragElementByCalculatedPosition, getLocator, getLocatorBoundingBox} from './utils'; + +function getData(args: {basicData: ChartData; extraData?: DeepPartial}): ChartData { + const {basicData, extraData} = args; + const data = cloneDeep(basicData); + const defaults: DeepPartial = { + chart: {margin: {top: 10, right: 10, bottom: 10, left: 10}}, + legend: {enabled: false}, + title: {text: ''}, + xAxis: { + labels: {enabled: false}, + maxPadding: 0, + rangeSlider: {enabled: true}, + title: {text: ''}, + type: 'datetime', + }, + yAxis: [{title: {text: ''}}, {title: {text: ''}}], + }; + merge(data, defaults, extraData); + + return data; +} + +test.describe('Range slider', () => { + test('Basic actions', async ({mount, page}) => { + const data = getData({basicData: lineTwoYAxisData}); + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + + // Drag left brush handle to the right + await dragElementByCalculatedPosition({ + component, + page, + selector: '.gcharts-brush .handle--w', + getDragOptions: ({boundingBox}) => { + const startX = boundingBox.x + boundingBox.width / 2; + const endX = startX + 100; + const y = boundingBox.y + boundingBox.height / 2; + return {from: [startX, y], to: [endX, y]}; + }, + }); + await expect(component.locator('svg')).toHaveScreenshot(); + + // Drag right brush handle to the left + await dragElementByCalculatedPosition({ + component, + page, + selector: '.gcharts-brush .handle--e', + getDragOptions: ({boundingBox}) => { + const startX = boundingBox.x + boundingBox.width / 2; + const endX = startX - 100; + const y = boundingBox.y + boundingBox.height / 2; + return {from: [startX, y], to: [endX, y]}; + }, + }); + await expect(component.locator('svg')).toHaveScreenshot(); + + // Drag selection to the right + await dragElementByCalculatedPosition({ + component, + page, + selector: '.gcharts-brush .selection', + getDragOptions: ({boundingBox}) => { + const startX = boundingBox.x + boundingBox.width / 2; + const endX = startX + 50; + const y = boundingBox.y + boundingBox.height / 2; + return {from: [startX, y], to: [endX, y]}; + }, + }); + await expect(component.locator('svg')).toHaveScreenshot(); + }); + + test('Default range size duration', async ({mount}) => { + const data = getData({ + basicData: lineTwoYAxisData, + extraData: {xAxis: {rangeSlider: {defaultRange: {size: 'P1M'}}}}, + }); + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); + + test('Default range size number', async ({mount}) => { + const data = getData({ + basicData: scatterLinearXAxisData, + extraData: {xAxis: {rangeSlider: {defaultRange: {size: 1000}}, type: 'linear'}}, + }); + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); + + test('Hide series', async ({mount}) => { + const data = getData({ + basicData: lineTwoYAxisData, + extraData: { + series: { + data: [{rangeSlider: {visible: false}}], + }, + }, + }); + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); + + test('Height option', async ({mount}) => { + const data = getData({ + basicData: lineTwoYAxisData, + extraData: {xAxis: {rangeSlider: {height: 80}}}, + }); + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); + + test('Margin option', async ({mount}) => { + const data = getData({ + basicData: lineTwoYAxisData, + extraData: {xAxis: {rangeSlider: {margin: 30}}}, + }); + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); + + test('Overlay click', async ({mount, page}) => { + const data = getData({ + basicData: lineTwoYAxisData, + extraData: {xAxis: {rangeSlider: {defaultRange: {size: 'P1M'}}}}, + }); + const component = await mount(); + + // Click on the overlay center + const overlay = await getLocator({component, selector: '.gcharts-brush .overlay'}); + const boundingBox = await getLocatorBoundingBox(overlay); + let x = boundingBox.x + boundingBox.width / 2; + const y = boundingBox.y + boundingBox.height / 2; + await page.mouse.click(x, y); + await expect(component.locator('svg')).toHaveScreenshot(); + + // Click on the overlay left edge + x = boundingBox.x + 5; + await page.mouse.click(x, y); + await expect(component.locator('svg')).toHaveScreenshot(); + + // Click on the overlay right edge + x = boundingBox.x + boundingBox.width - 5; + await page.mouse.click(x, y); + await expect(component.locator('svg')).toHaveScreenshot(); + }); +}); diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts new file mode 100644 index 00000000..7e23b353 --- /dev/null +++ b/src/__tests__/utils.ts @@ -0,0 +1,49 @@ +import {expect} from '@playwright/experimental-ct-react'; +import type {MountResult} from '@playwright/experimental-ct-react'; +import type {Locator, Page} from '@playwright/test'; + +export async function getLocator(args: {component: MountResult; selector: string}) { + const {component, selector} = args; + const locator = component.locator(selector); + await expect(locator).toBeVisible(); + + return locator; +} + +export async function getLocatorBoundingBox(locator: Locator) { + const boundingBox = await locator.evaluate((el) => el.getBoundingClientRect()); + + if (!boundingBox) { + throw new Error('Bounding box not found'); + } + + return boundingBox; +} + +async function simulateDrag(args: {from: [number, number]; page: Page; to: [number, number]}) { + const {from, page, to} = args; + const [fromX, fromY] = from; + const [toX, toY] = to; + + await page.mouse.move(fromX, fromY); + await page.mouse.down(); + await page.mouse.move(toX, toY); + await page.mouse.up(); +} + +export async function dragElementByCalculatedPosition(args: { + component: MountResult; + getDragOptions: (args: {boundingBox: DOMRect}) => { + from: [number, number]; + to: [number, number]; + }; + page: Page; + selector: string; +}) { + const {component, getDragOptions, page, selector} = args; + const locator = await getLocator({component, selector}); + const boundingBox = await getLocatorBoundingBox(locator); + const {from, to} = getDragOptions({boundingBox}); + + await simulateDrag({page, from, to}); +} diff --git a/src/hooks/useAxisScales/index.ts b/src/hooks/useAxisScales/index.ts index 4a4a21c5..6aabed8d 100644 --- a/src/hooks/useAxisScales/index.ts +++ b/src/hooks/useAxisScales/index.ts @@ -353,7 +353,7 @@ export function createXScale(args: { order: axis.order, }); } - const maxPadding = get(axis, 'maxPadding', 0); + const maxPadding = rangeSliderState ? 0 : get(axis, 'maxPadding', 0); const xAxisMaxPadding = boundsWidth * maxPadding + calculateXAxisPadding(series); const range = getXScaleRange({ diff --git a/src/hooks/useRangeSlider/index.ts b/src/hooks/useRangeSlider/index.ts index a9b49123..0e621716 100644 --- a/src/hooks/useRangeSlider/index.ts +++ b/src/hooks/useRangeSlider/index.ts @@ -96,6 +96,7 @@ export function useRangeSlider(props: UseRangeSliderProps): PreparedRangeSliderP }, [rangeSliderState, xScale]); const offsetTop = getRangeSliderOffsetTop({ height, + preparedChart, preparedLegend, preparedRangeSlider, }); diff --git a/src/hooks/useRangeSlider/utils.ts b/src/hooks/useRangeSlider/utils.ts index 5f66ba06..60e7727f 100644 --- a/src/hooks/useRangeSlider/utils.ts +++ b/src/hooks/useRangeSlider/utils.ts @@ -2,20 +2,28 @@ import {isBandScale} from '../../utils'; import type {PreparedRangeSlider} from '../useAxis/types'; import type {ChartScale} from '../useAxisScales'; import type {BrushSelection} from '../useBrush/types'; +import type {PreparedChart} from '../useChartOptions/types'; import type {PreparedLegend} from '../useSeries/types'; import type {RangeSliderState} from './types'; export function getRangeSliderOffsetTop(args: { height: number; + preparedChart: PreparedChart; preparedLegend: PreparedLegend | null; preparedRangeSlider: PreparedRangeSlider; }) { - const {height, preparedLegend, preparedRangeSlider} = args; + const {height, preparedChart, preparedLegend, preparedRangeSlider} = args; const legendHeight = preparedLegend?.enabled ? (preparedLegend?.height ?? 0) : 0; const legendMargin = preparedLegend?.enabled ? (preparedLegend?.margin ?? 0) : 0; - return height - preparedRangeSlider.height - legendHeight - legendMargin; + return ( + height - + preparedRangeSlider.height - + legendHeight - + legendMargin - + preparedChart.margin.bottom + ); } export function getRangeSliderSelection(args: { diff --git a/src/types/misc.ts b/src/types/misc.ts index 2e88b9de..89b219d0 100644 --- a/src/types/misc.ts +++ b/src/types/misc.ts @@ -13,3 +13,10 @@ export type PointPosition = [number, number]; export type DeepRequired = Required<{ [K in keyof T]: T[K] extends Required ? T[K] : DeepRequired; }>; + +/** + * Makes all properties in T optional, including nested objects. + */ +export type DeepPartial = { + [K in keyof T]?: T[K] extends object ? DeepPartial : T[K]; +};