diff --git a/src/__snapshots__/x-axis.visual.test.tsx-snapshots/X-axis-max-1-chromium-linux.png b/src/__snapshots__/x-axis.visual.test.tsx-snapshots/X-axis-max-1-chromium-linux.png new file mode 100644 index 00000000..afa91060 Binary files /dev/null and b/src/__snapshots__/x-axis.visual.test.tsx-snapshots/X-axis-max-1-chromium-linux.png differ diff --git a/src/__snapshots__/x-axis.visual.test.tsx-snapshots/X-axis-min-1-chromium-linux.png b/src/__snapshots__/x-axis.visual.test.tsx-snapshots/X-axis-min-1-chromium-linux.png new file mode 100644 index 00000000..8b6894c4 Binary files /dev/null and b/src/__snapshots__/x-axis.visual.test.tsx-snapshots/X-axis-min-1-chromium-linux.png differ diff --git a/src/__snapshots__/x-axis.visual.test.tsx-snapshots/X-axis-min-max-1-chromium-linux.png b/src/__snapshots__/x-axis.visual.test.tsx-snapshots/X-axis-min-max-1-chromium-linux.png new file mode 100644 index 00000000..a7037213 Binary files /dev/null and b/src/__snapshots__/x-axis.visual.test.tsx-snapshots/X-axis-min-max-1-chromium-linux.png differ diff --git a/src/__snapshots__/y-axis.visual.test.tsx-snapshots/Y-axis-max-1-chromium-linux.png b/src/__snapshots__/y-axis.visual.test.tsx-snapshots/Y-axis-max-1-chromium-linux.png new file mode 100644 index 00000000..d60578d1 Binary files /dev/null and b/src/__snapshots__/y-axis.visual.test.tsx-snapshots/Y-axis-max-1-chromium-linux.png differ diff --git a/src/__snapshots__/y-axis.visual.test.tsx-snapshots/Y-axis-min-1-chromium-linux.png b/src/__snapshots__/y-axis.visual.test.tsx-snapshots/Y-axis-min-1-chromium-linux.png new file mode 100644 index 00000000..ef697b00 Binary files /dev/null and b/src/__snapshots__/y-axis.visual.test.tsx-snapshots/Y-axis-min-1-chromium-linux.png differ diff --git a/src/__snapshots__/y-axis.visual.test.tsx-snapshots/Y-axis-min-max-1-chromium-linux.png b/src/__snapshots__/y-axis.visual.test.tsx-snapshots/Y-axis-min-max-1-chromium-linux.png new file mode 100644 index 00000000..16e01c4c Binary files /dev/null and b/src/__snapshots__/y-axis.visual.test.tsx-snapshots/Y-axis-min-max-1-chromium-linux.png differ diff --git a/src/__tests__/x-axis.visual.test.tsx b/src/__tests__/x-axis.visual.test.tsx new file mode 100644 index 00000000..e8620249 --- /dev/null +++ b/src/__tests__/x-axis.visual.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import {expect, test} from '@playwright/experimental-ct-react'; +import cloneDeep from 'lodash/cloneDeep'; +import set from 'lodash/set'; + +import {ChartTestStory} from '../../playwright/components/ChartTestStory'; +import {barYBasicData} from '../__stories__/__data__'; + +test.describe('X-axis', () => { + test('min', async ({mount}) => { + const data = cloneDeep(barYBasicData); + set(data, 'xAxis.min', 60); + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); + + test('max', async ({mount}) => { + const data = cloneDeep(barYBasicData); + set(data, 'xAxis.max', 120); + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); + + test('min-max', async ({mount}) => { + const data = cloneDeep(barYBasicData); + set(data, 'xAxis.min', 60); + set(data, 'xAxis.max', 120); + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); +}); diff --git a/src/__tests__/y-axis.visual.test.tsx b/src/__tests__/y-axis.visual.test.tsx new file mode 100644 index 00000000..75db3283 --- /dev/null +++ b/src/__tests__/y-axis.visual.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import {expect, test} from '@playwright/experimental-ct-react'; +import cloneDeep from 'lodash/cloneDeep'; +import set from 'lodash/set'; + +import {ChartTestStory} from '../../playwright/components/ChartTestStory'; +import {scatterBasicData} from '../__stories__/__data__'; + +test.describe('Y-axis', () => { + test('min', async ({mount}) => { + const data = cloneDeep(scatterBasicData); + set(data, 'yAxis[0].min', 5); + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); + + test('max', async ({mount}) => { + const data = cloneDeep(scatterBasicData); + set(data, 'yAxis[0].max', 8); + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); + + test('min-max', async ({mount}) => { + const data = cloneDeep(scatterBasicData); + set(data, 'yAxis[0].min', 5); + set(data, 'yAxis[0].max', 8); + const component = await mount(); + await expect(component.locator('svg')).toHaveScreenshot(); + }); +}); diff --git a/src/components/ChartInner/index.tsx b/src/components/ChartInner/index.tsx index 3ab5e9fc..fb2363c2 100644 --- a/src/components/ChartInner/index.tsx +++ b/src/components/ChartInner/index.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import {useUniqId} from '@gravity-ui/uikit'; + import {useCrosshair} from '../../hooks'; import {EventType, block, getDispatcher} from '../../utils'; import {AxisX, AxisY} from '../Axis'; @@ -23,6 +25,7 @@ export const ChartInner = (props: ChartInnerProps) => { const htmlLayerRef = React.useRef(null); const plotRef = React.useRef(null); const dispatcher = React.useMemo(() => getDispatcher(), []); + const clipPathId = useUniqId(); const { boundsHeight, boundsOffsetLeft, @@ -125,6 +128,11 @@ export const ChartInner = (props: ChartInnerProps) => { onTouchMove={throttledHandleTouchMove} onClick={handleChartClick} > + + + + + {title && } <g transform={`translate(0, ${boundsOffsetTop})`}> {preparedSplit.plots.map((plot, index) => { @@ -161,7 +169,7 @@ export const ChartInner = (props: ChartInnerProps) => { </g> </React.Fragment> )} - {shapes} + <g clipPath={`url(#${clipPathId})`}>{shapes}</g> </g> {preparedLegend.enabled && ( <Legend diff --git a/src/hooks/useAxisScales/index.ts b/src/hooks/useAxisScales/index.ts index 7271b6b7..4a409b75 100644 --- a/src/hooks/useAxisScales/index.ts +++ b/src/hooks/useAxisScales/index.ts @@ -11,6 +11,7 @@ import { getAxisHeight, getDataCategoryValue, getDefaultMaxXAxisValue, + getDefaultMinXAxisValue, getDomainDataXBySeries, getDomainDataYBySeries, getOnlyVisibleSeries, @@ -66,7 +67,8 @@ const filterCategoriesByVisibleSeries = (args: { export function createYScale(axis: PreparedAxis, series: PreparedSeries[], boundsHeight: number) { const yType: ChartAxisType = get(axis, 'type', DEFAULT_AXIS_TYPE); - const yMin = get(axis, 'min'); + const yMinProps = get(axis, 'min'); + const yMaxProps = get(axis, 'max'); const yCategories = get(axis, 'categories'); const yTimestamps = get(axis, 'timestamps'); @@ -77,15 +79,21 @@ export function createYScale(axis: PreparedAxis, series: PreparedSeries[], bound const range = [boundsHeight, boundsHeight * axis.maxPadding]; if (isNumericalArrayData(domain)) { - const [domainYMin, domainMax] = extent(domain) as [number, number]; - const yMinValue = typeof yMin === 'number' ? yMin : domainYMin; - let yMaxValue = domainMax; - if (series.some((s) => CHART_SERIES_WITH_VOLUME_ON_Y_AXIS.includes(s.type))) { - yMaxValue = Math.max(yMaxValue, 0); + const [yMinDomain, yMaxDomain] = extent(domain) as [number, number]; + const yMin = typeof yMinProps === 'number' ? yMinProps : yMinDomain; + let yMax: number; + + if (typeof yMaxProps === 'number') { + yMax = yMaxProps; + } else { + const hasSeriesWithVolumeOnYAxis = series.some((s) => + CHART_SERIES_WITH_VOLUME_ON_Y_AXIS.includes(s.type), + ); + yMax = hasSeriesWithVolumeOnYAxis ? Math.max(yMaxDomain, 0) : yMaxDomain; } const scaleFn = yType === 'logarithmic' ? scaleLog : scaleLinear; - return scaleFn().domain([yMinValue, yMaxValue]).range(range).nice(); + return scaleFn().domain([yMin, yMax]).range(range).nice(); } break; @@ -106,13 +114,17 @@ export function createYScale(axis: PreparedAxis, series: PreparedSeries[], bound const range = [boundsHeight, boundsHeight * axis.maxPadding]; if (yTimestamps) { - const [yMin, yMax] = extent(yTimestamps) as [number, number]; + const [yMinTimestamp, yMaxTimestamp] = extent(yTimestamps) as [number, number]; + const yMin = typeof yMinProps === 'number' ? yMinProps : yMinTimestamp; + const yMax = typeof yMaxProps === 'number' ? yMaxProps : yMaxTimestamp; return scaleUtc().domain([yMin, yMax]).range(range).nice(); } else { const domain = getDomainDataYBySeries(series); if (isNumericalArrayData(domain)) { - const [yMin, yMax] = extent(domain) as [number, number]; + const [yMinTimestamp, yMaxTimestamp] = extent(domain) as [number, number]; + const yMin = typeof yMinProps === 'number' ? yMinProps : yMinTimestamp; + const yMax = typeof yMaxProps === 'number' ? yMaxProps : yMaxTimestamp; return scaleUtc().domain([yMin, yMax]).range(range).nice(); } } @@ -150,13 +162,12 @@ export function createXScale( series: (PreparedSeries | ChartSeries)[], boundsWidth: number, ) { - const xMin = get(axis, 'min'); - const xMax = getDefaultMaxXAxisValue(series); + const xMinProps = get(axis, 'min'); + const xMaxProps = get(axis, 'max'); const xType: ChartAxisType = get(axis, 'type', DEFAULT_AXIS_TYPE); const xCategories = get(axis, 'categories'); const xTimestamps = get(axis, 'timestamps'); const maxPadding = get(axis, 'maxPadding', 0); - const xAxisMinPadding = boundsWidth * maxPadding + calculateXAxisPadding(series); const xRange = [0, boundsWidth - xAxisMinPadding]; @@ -166,13 +177,29 @@ export function createXScale( const domain = getDomainDataXBySeries(series); if (isNumericalArrayData(domain)) { - const [domainXMin, domainXMax] = extent(domain) as [number, number]; - const xMinValue = typeof xMin === 'number' ? xMin : domainXMin; - const xMaxValue = - typeof xMax === 'number' ? Math.max(xMax, domainXMax) : domainXMax; + const [xMinDomain, xMaxDomain] = extent(domain) as [number, number]; + let xMin: number; + let xMax: number; + + if (typeof xMinProps === 'number') { + xMin = xMinProps; + } else { + const xMinDefault = getDefaultMinXAxisValue(series); + xMin = xMinDefault ?? xMinDomain; + } + + if (typeof xMaxProps === 'number') { + xMax = xMaxProps; + } else { + const xMaxDefault = getDefaultMaxXAxisValue(series); + xMax = + typeof xMaxDefault === 'number' + ? Math.max(xMaxDefault, xMaxDomain) + : xMaxDomain; + } const scaleFn = xType === 'logarithmic' ? scaleLog : scaleLinear; - return scaleFn().domain([xMinValue, xMaxValue]).range(xRange).nice(); + return scaleFn().domain([xMin, xMax]).range(xRange).nice(); } break; @@ -197,13 +224,17 @@ export function createXScale( } case 'datetime': { if (xTimestamps) { - const [xMin, xMax] = extent(xTimestamps) as [number, number]; + const [xMinTimestamp, xMaxTimestamp] = extent(xTimestamps) as [number, number]; + const xMin = typeof xMinProps === 'number' ? xMinProps : xMinTimestamp; + const xMax = typeof xMaxProps === 'number' ? xMaxProps : xMaxTimestamp; return scaleUtc().domain([xMin, xMax]).range(xRange).nice(); } else { const domain = getDomainDataXBySeries(series); if (isNumericalArrayData(domain)) { - const [xMin, xMax] = extent(domain) as [number, number]; + const [xMinTimestamp, xMaxTimestamp] = extent(domain) as [number, number]; + const xMin = typeof xMinProps === 'number' ? xMinProps : xMinTimestamp; + const xMax = typeof xMaxProps === 'number' ? xMaxProps : xMaxTimestamp; return scaleUtc().domain([xMin, xMax]).range(xRange).nice(); } } diff --git a/src/hooks/useChartOptions/types.ts b/src/hooks/useChartOptions/types.ts index d543fd1c..ecdf8b24 100644 --- a/src/hooks/useChartOptions/types.ts +++ b/src/hooks/useChartOptions/types.ts @@ -50,7 +50,6 @@ export type PreparedAxis = Omit<ChartAxis, 'type' | 'labels' | 'plotLines' | 'pl align: ChartAxisTitleAlignment; maxRowCount: number; }; - min?: number; grid: { enabled: boolean; }; diff --git a/src/hooks/useChartOptions/x-axis.ts b/src/hooks/useChartOptions/x-axis.ts index 610c6996..dfcadc9e 100644 --- a/src/hooks/useChartOptions/x-axis.ts +++ b/src/hooks/useChartOptions/x-axis.ts @@ -10,7 +10,6 @@ import { } from '../../constants'; import type {BaseTextStyle, ChartSeries, ChartXAxis} from '../../types'; import { - CHART_SERIES_WITH_VOLUME_ON_X_AXIS, calculateCos, formatAxisTickLabel, getClosestPointsRange, @@ -73,23 +72,6 @@ function getLabelSettings({ return {height: Math.min(maxHeight, labelsHeight), rotation}; } -function getAxisMin(axis?: ChartXAxis, series?: ChartSeries[]) { - const min = axis?.min; - - if ( - typeof min === 'undefined' && - series?.some((s) => CHART_SERIES_WITH_VOLUME_ON_X_AXIS.includes(s.type)) - ) { - return series.reduce((minValue, s) => { - // @ts-expect-error - const minYValue = s.data.reduce((res, d) => Math.min(res, get(d, 'x', 0)), 0); - return Math.min(minValue, minYValue); - }, 0); - } - - return min; -} - export const getPreparedXAxis = ({ xAxis, series, @@ -145,7 +127,8 @@ export const getPreparedXAxis = ({ align: get(xAxis, 'title.align', xAxisTitleDefaults.align), maxRowCount: get(xAxis, 'title.maxRowCount', xAxisTitleDefaults.maxRowCount), }, - min: getAxisMin(xAxis, series), + min: get(xAxis, 'min'), + max: get(xAxis, 'max'), maxPadding: get(xAxis, 'maxPadding', 0.01), grid: { enabled: get(xAxis, 'grid.enabled', true), diff --git a/src/hooks/useChartOptions/y-axis.ts b/src/hooks/useChartOptions/y-axis.ts index ad8f3fe5..6cc224fa 100644 --- a/src/hooks/useChartOptions/y-axis.ts +++ b/src/hooks/useChartOptions/y-axis.ts @@ -11,22 +11,17 @@ import { } from '../../constants'; import type {BaseTextStyle, ChartSeries, ChartYAxis} from '../../types'; import { - CHART_SERIES_WITH_VOLUME_ON_Y_AXIS, formatAxisTickLabel, getClosestPointsRange, + getDefaultMinYAxisValue, getHorisontalSvgTextHeight, getLabelsSize, getScaleTicks, - getWaterfallPointSubtotal, isAxisRelatedSeries, wrapText, } from '../../utils'; import {createYScale} from '../useAxisScales'; -import type { - PreparedSeries, - PreparedWaterfallSeries, - PreparedWaterfallSeriesData, -} from '../useSeries/types'; +import type {PreparedSeries} from '../useSeries/types'; import type {PreparedAxis} from './types'; @@ -58,41 +53,6 @@ const getAxisLabelMaxWidth = (args: {axis: PreparedAxis; series: ChartSeries[]}) }).maxWidth; }; -function getAxisMin(axis?: ChartYAxis, series?: ChartSeries[]) { - const min = axis?.min; - - if ( - typeof min === 'undefined' && - series?.some((s) => CHART_SERIES_WITH_VOLUME_ON_Y_AXIS.includes(s.type)) - ) { - return series.reduce((minValue, s) => { - switch (s.type) { - case 'waterfall': { - const minSubTotal = s.data.reduce( - (res, d) => - Math.min( - res, - getWaterfallPointSubtotal( - d as PreparedWaterfallSeriesData, - s as PreparedWaterfallSeries, - ) || 0, - ), - 0, - ); - return Math.min(minValue, minSubTotal); - } - default: { - // @ts-expect-error - const minYValue = s.data.reduce((res, d) => Math.min(res, get(d, 'y', 0)), 0); - return Math.min(minValue, minYValue); - } - } - }, 0); - } - - return min; -} - export const getPreparedYAxis = ({ series, yAxis, @@ -172,7 +132,8 @@ export const getPreparedYAxis = ({ align: get(axisItem, 'title.align', yAxisTitleDefaults.align), maxRowCount: titleMaxRowsCount, }, - min: getAxisMin(axisItem, series), + min: get(axisItem, 'min') ?? getDefaultMinYAxisValue(series), + max: get(axisItem, 'max'), maxPadding: get(axisItem, 'maxPadding', 0.05), grid: { enabled: get( diff --git a/src/types/chart/axis.ts b/src/types/chart/axis.ts index ca845e31..5a9d5802 100644 --- a/src/types/chart/axis.ts +++ b/src/types/chart/axis.ts @@ -57,6 +57,8 @@ export interface ChartAxis { }; /** The minimum value of the axis. If undefined the min value is automatically calculate. */ min?: number; + /** The maximum value of the axis. If undefined the max value is automatically calculate. */ + max?: number; /** The grid lines settings. */ grid?: { /** Enable or disable the grid lines. @@ -140,11 +142,8 @@ export interface AxisCrosshair extends Omit<AxisPlotLine, 'value'> { } export interface ChartYAxis extends ChartAxis { - /** Axis location. - * Possible values - 'left' and 'right'. - * */ + /** Axis location. Possible values - `'left'` and `'right'`. */ position?: 'left' | 'right'; - /** Property for splitting charts. Determines which area the axis is located in. - * */ + /** Property for splitting charts. Determines which area the axis is located in. */ plotIndex?: number; } diff --git a/src/utils/chart/index.ts b/src/utils/chart/index.ts index b67a2ec2..dd91b3aa 100644 --- a/src/utils/chart/index.ts +++ b/src/utils/chart/index.ts @@ -6,11 +6,17 @@ import isNil from 'lodash/isNil'; import sortBy from 'lodash/sortBy'; import {DEFAULT_AXIS_LABEL_FONT_SIZE} from '../../constants'; -import type {PreparedAxis, PreparedWaterfallSeries, StackedSeries} from '../../hooks'; +import type { + PreparedAxis, + PreparedWaterfallSeries, + PreparedWaterfallSeriesData, + StackedSeries, +} from '../../hooks'; import {getSeriesStackId} from '../../hooks/useSeries/utils'; import {formatNumber, getNumberUnitRate} from '../../libs/format-number'; import type {BaseTextStyle, ChartSeries, ChartSeriesData} from '../../types'; +import {getWaterfallPointSubtotal} from './series/waterfall'; import {getDefaultDateFormat} from './time'; import type {AxisDirection} from './types'; @@ -132,6 +138,45 @@ export function getDefaultMaxXAxisValue(series: UnknownSeries[]) { return undefined; } +export function getDefaultMinXAxisValue(series: UnknownSeries[]) { + if (series?.some((s) => CHART_SERIES_WITH_VOLUME_ON_X_AXIS.includes(s.type))) { + return series.reduce((minValue, s) => { + // @ts-expect-error + const minYValue = s.data.reduce((res, d) => Math.min(res, get(d, 'x', 0)), 0); + return Math.min(minValue, minYValue); + }, 0); + } + + return undefined; +} + +export function getDefaultMinYAxisValue(series?: UnknownSeries[]) { + if (series?.some((s) => CHART_SERIES_WITH_VOLUME_ON_Y_AXIS.includes(s.type))) { + return series.reduce((minValue, s) => { + switch (s.type) { + case 'waterfall': { + const minSubTotal = (s.data as PreparedWaterfallSeriesData[]).reduce( + (res, d) => + Math.min( + res, + getWaterfallPointSubtotal(d, s as PreparedWaterfallSeries) || 0, + ), + 0, + ); + return Math.min(minValue, minSubTotal); + } + default: { + // @ts-expect-error + const minYValue = s.data.reduce((res, d) => Math.min(res, get(d, 'y', 0)), 0); + return Math.min(minValue, minYValue); + } + } + }, 0); + } + + return undefined; +} + export const getDomainDataYBySeries = (series: UnknownSeries[]) => { const groupedSeries = group(series, (item) => item.type);