From 7180e394c64ddcdbb09da7a39ce2f8d882d2880b Mon Sep 17 00:00:00 2001 From: Irina Kuzmina Date: Wed, 22 May 2024 16:26:44 +0300 Subject: [PATCH] feat(D3 plugin): negative Y values for bar-x chart (#483) * feat(D3): negative Y values for bar-x chart * Add bar-y and area chart examples with negative values * fix area chart * Fix bar-y * fix types --- .../d3/__stories__/Showcase.stories.tsx | 27 +++++++--- .../d3/examples/area/NegativeValues.tsx | 32 ++++++++++++ .../d3/examples/bar-x/NegativeValues.tsx | 50 +++++++++++++++++++ .../d3/examples/bar-y/NegativeValues.tsx | 49 ++++++++++++++++++ .../d3/renderer/hooks/useAxisScales/index.ts | 18 +++++-- .../renderer/hooks/useChartOptions/y-axis.ts | 10 ++-- .../renderer/hooks/useShapes/area/index.tsx | 2 +- .../hooks/useShapes/area/prepare-data.ts | 24 +++++++-- .../hooks/useShapes/bar-x/prepare-data.ts | 9 ++-- .../hooks/useShapes/bar-y/prepare-data.ts | 9 ++-- src/plugins/d3/renderer/utils/index.ts | 13 +++++ 11 files changed, 217 insertions(+), 26 deletions(-) create mode 100644 src/plugins/d3/examples/area/NegativeValues.tsx create mode 100644 src/plugins/d3/examples/bar-x/NegativeValues.tsx create mode 100644 src/plugins/d3/examples/bar-y/NegativeValues.tsx diff --git a/src/plugins/d3/__stories__/Showcase.stories.tsx b/src/plugins/d3/__stories__/Showcase.stories.tsx index 48945fcc..f198a285 100644 --- a/src/plugins/d3/__stories__/Showcase.stories.tsx +++ b/src/plugins/d3/__stories__/Showcase.stories.tsx @@ -7,17 +7,20 @@ import {StoryObj} from '@storybook/react'; import {Loader} from '../../../components/Loader/Loader'; import {settings} from '../../../libs'; import {Basic as BasicArea} from '../examples/area/Basic'; +import {NegativeValues as AreaNegativeValues} from '../examples/area/NegativeValues'; import {PercentStackingArea} from '../examples/area/PercentStacking'; import {StackedArea} from '../examples/area/StackedArea'; import {TwoYAxis as AreaTwoYAxis} from '../examples/area/TwoYAxis'; import {BasicBarXChart} from '../examples/bar-x/Basic'; import {DataLabels as BarXDataLabels} from '../examples/bar-x/DataLabels'; import {GroupedColumns} from '../examples/bar-x/GroupedColumns'; +import {NegativeValues as BarXNegativeValues} from '../examples/bar-x/NegativeValues'; import {PercentStackColumns} from '../examples/bar-x/PercentStack'; import {StackedColumns} from '../examples/bar-x/StackedColumns'; import {TwoYAxis as BarXTwoYAxis} from '../examples/bar-x/TwoYAxis'; import {Basic as BasicBarY} from '../examples/bar-y/Basic'; import {GroupedColumns as GroupedColumnsBarY} from '../examples/bar-y/GroupedColumns'; +import {NegativeValues as BarYNegativeValues} from '../examples/bar-y/NegativeValues'; import {PercentStackingBars} from '../examples/bar-y/PercentStacking'; import {StackedColumns as StackedColumnsBarY} from '../examples/bar-y/StackedColumns'; import {LineAndBarXCombinedChart} from '../examples/combined/LineAndBarX'; @@ -62,11 +65,11 @@ const ShowcaseStory = () => { With data labels - + Lines with different shapes - + Line with two Y axis @@ -87,10 +90,14 @@ const ShowcaseStory = () => { Stacked percentage areas - + Dual Y axis + + With negative values + + Bar-x charts @@ -116,10 +123,14 @@ const ShowcaseStory = () => { Bar-x chart with data labels - + Dual Y axis + + Bar-x chart with negative values + + Bar-y charts @@ -141,6 +152,10 @@ const ShowcaseStory = () => { Stacked percentage bars + + Bar-y chart with negative values + + Pie charts @@ -159,11 +174,11 @@ const ShowcaseStory = () => { Scatter charts - + Basic scatter - + Scatter chart with two Y axis diff --git a/src/plugins/d3/examples/area/NegativeValues.tsx b/src/plugins/d3/examples/area/NegativeValues.tsx new file mode 100644 index 00000000..32284ad3 --- /dev/null +++ b/src/plugins/d3/examples/area/NegativeValues.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import {ChartKit} from '../../../../components/ChartKit'; +import type {ChartKitWidgetData} from '../../../../types'; +import {ExampleWrapper} from '../ExampleWrapper'; + +export const NegativeValues = () => { + const data = [ + {x: 0, y: 10}, + {x: 1, y: 20}, + {x: 2, y: -30}, + {x: 3, y: 100}, + ]; + + const widgetData: ChartKitWidgetData = { + series: { + data: [ + { + type: 'area', + data: data, + name: 'Min temperature', + }, + ], + }, + }; + + return ( + + + + ); +}; diff --git a/src/plugins/d3/examples/bar-x/NegativeValues.tsx b/src/plugins/d3/examples/bar-x/NegativeValues.tsx new file mode 100644 index 00000000..5074b07c --- /dev/null +++ b/src/plugins/d3/examples/bar-x/NegativeValues.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +import {dateTime} from '@gravity-ui/date-utils'; + +import {ChartKit} from '../../../../components/ChartKit'; +import type {ChartKitWidgetData} from '../../../../types'; +import {ExampleWrapper} from '../ExampleWrapper'; +import marsWeatherData from '../mars-weather'; + +export const NegativeValues = () => { + const data = marsWeatherData.map((d) => ({ + x: dateTime({input: d.terrestrial_date, format: 'YYYY-MM-DD'}).valueOf(), + y: d.min_temp, + })); + + const widgetData: ChartKitWidgetData = { + series: { + data: [ + { + type: 'bar-x', + data: data, + name: 'Min temperature', + }, + ], + }, + yAxis: [ + { + title: { + text: 'Min temperature', + }, + }, + ], + xAxis: { + type: 'datetime', + title: { + text: 'Terrestrial date', + }, + ticks: {pixelInterval: 200}, + }, + title: { + text: 'Mars weather', + }, + }; + + return ( + + + + ); +}; diff --git a/src/plugins/d3/examples/bar-y/NegativeValues.tsx b/src/plugins/d3/examples/bar-y/NegativeValues.tsx new file mode 100644 index 00000000..c09e54dc --- /dev/null +++ b/src/plugins/d3/examples/bar-y/NegativeValues.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import {dateTime} from '@gravity-ui/date-utils'; + +import {ChartKit} from '../../../../components/ChartKit'; +import type {ChartKitWidgetData} from '../../../../types'; +import {ExampleWrapper} from '../ExampleWrapper'; +import marsWeatherData from '../mars-weather'; + +export const NegativeValues = () => { + const data = marsWeatherData.map((d) => ({ + y: dateTime({input: d.terrestrial_date, format: 'YYYY-MM-DD'}).valueOf(), + x: d.min_temp, + })); + + const widgetData: ChartKitWidgetData = { + series: { + data: [ + { + type: 'bar-y', + data: data, + name: 'Min temperature', + }, + ], + }, + xAxis: { + title: { + text: 'Min temperature', + }, + }, + yAxis: [ + { + type: 'datetime', + title: { + text: 'Terrestrial date', + }, + }, + ], + title: { + text: 'Mars weather', + }, + }; + + return ( + + + + ); +}; diff --git a/src/plugins/d3/renderer/hooks/useAxisScales/index.ts b/src/plugins/d3/renderer/hooks/useAxisScales/index.ts index 7664bfa6..768e0408 100644 --- a/src/plugins/d3/renderer/hooks/useAxisScales/index.ts +++ b/src/plugins/d3/renderer/hooks/useAxisScales/index.ts @@ -7,7 +7,9 @@ import get from 'lodash/get'; import {ChartKitWidgetAxis, ChartKitWidgetSeries} from '../../../../../types'; import {DEFAULT_AXIS_TYPE} from '../../constants'; import { + CHART_SERIES_WITH_VOLUME, getDataCategoryValue, + getDefaultMaxXAxisValue, getDomainDataXBySeries, getDomainDataYBySeries, getOnlyVisibleSeries, @@ -71,9 +73,14 @@ export function createYScale(axis: PreparedAxis, series: PreparedSeries[], bound const range = [boundsHeight, boundsHeight * axis.maxPadding]; if (isNumericalArrayData(domain)) { - const [domainYMin, yMax] = extent(domain) as [number, number]; + const [domainYMin, domainMax] = extent(domain) as [number, number]; const yMinValue = typeof yMin === 'number' ? yMin : domainYMin; - return scaleLinear().domain([yMinValue, yMax]).range(range).nice(); + let yMaxValue = domainMax; + if (series.some((s) => CHART_SERIES_WITH_VOLUME.includes(s.type))) { + yMaxValue = Math.max(yMaxValue, 0); + } + + return scaleLinear().domain([yMinValue, yMaxValue]).range(range).nice(); } break; @@ -135,6 +142,7 @@ export function createXScale( boundsWidth: number, ) { const xMin = get(axis, 'min'); + const xMax = getDefaultMaxXAxisValue(series); const xType = get(axis, 'type', DEFAULT_AXIS_TYPE); const xCategories = get(axis, 'categories'); const xTimestamps = get(axis, 'timestamps'); @@ -148,9 +156,11 @@ export function createXScale( const domain = getDomainDataXBySeries(series); if (isNumericalArrayData(domain)) { - const [domainXMin, xMax] = extent(domain) as [number, number]; + const [domainXMin, domainXMax] = extent(domain) as [number, number]; const xMinValue = typeof xMin === 'number' ? xMin : domainXMin; - return scaleLinear().domain([xMinValue, xMax]).range(xRange).nice(); + const xMaxValue = + typeof xMax === 'number' ? Math.max(xMax, domainXMax) : domainXMax; + return scaleLinear().domain([xMinValue, xMaxValue]).range(xRange).nice(); } break; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts index 04608e13..46908420 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts @@ -9,6 +9,7 @@ import { yAxisTitleDefaults, } from '../../constants'; import { + CHART_SERIES_WITH_VOLUME, formatAxisTickLabel, getClosestPointsRange, getHorisontalSvgTextHeight, @@ -51,9 +52,11 @@ const getAxisLabelMaxWidth = (args: {axis: PreparedAxis; series: ChartKitWidgetS function getAxisMin(axis?: ChartKitWidgetAxis, series?: ChartKitWidgetSeries[]) { const min = axis?.min; - const seriesWithVolume = ['bar-x', 'area', 'waterfall']; - if (typeof min === 'undefined' && series?.some((s) => seriesWithVolume.includes(s.type))) { + if ( + typeof min === 'undefined' && + series?.some((s) => CHART_SERIES_WITH_VOLUME.includes(s.type)) + ) { return series.reduce((minValue, s) => { switch (s.type) { case 'waterfall': { @@ -68,7 +71,8 @@ function getAxisMin(axis?: ChartKitWidgetAxis, series?: ChartKitWidgetSeries[]) return Math.min(minValue, minSubTotal); } default: { - return minValue; + const minYValue = s.data.reduce((res, d) => Math.min(res, get(d, 'y', 0)), 0); + return Math.min(minValue, minYValue); } } }, 0); diff --git a/src/plugins/d3/renderer/hooks/useShapes/area/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/area/index.tsx index 82da7ea1..9f02dafd 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/area/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/area/index.tsx @@ -32,7 +32,7 @@ type Args = { export const AreaSeriesShapes = (args: Args) => { const {dispatcher, preparedData, seriesOptions} = args; - const ref = React.useRef(null); + const ref = React.useRef(null); React.useEffect(() => { if (!ref.current) { diff --git a/src/plugins/d3/renderer/hooks/useShapes/area/prepare-data.ts b/src/plugins/d3/renderer/hooks/useShapes/area/prepare-data.ts index c76cc2f6..85a554f1 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/area/prepare-data.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/area/prepare-data.ts @@ -2,7 +2,7 @@ import {group, sort} from 'd3'; import type {AreaSeriesData} from '../../../../../../types'; import type {LabelData} from '../../../types'; -import {getLabelsSize, getLeftPosition} from '../../../utils'; +import {getDataCategoryValue, getLabelsSize, getLeftPosition} from '../../../utils'; import type {ChartScale} from '../../useAxisScales'; import type {PreparedAxis} from '../../useChartOptions/types'; import type {PreparedAreaSeries} from '../../useSeries/types'; @@ -40,9 +40,14 @@ function getLabelData(point: PointData, series: PreparedAreaSeries, xMax: number } function getXValues(series: PreparedAreaSeries[], xAxis: PreparedAxis, xScale: ChartScale) { + const categories = xAxis.categories || []; const xValues = series.reduce>((acc, s) => { s.data.forEach((d) => { - const key = String(d.x); + const key = String( + xAxis.type === 'category' + ? getDataCategoryValue({axisDirection: 'x', categories, data: d}) + : d.x, + ); if (!acc.has(key)) { acc.set(key, getXValue({point: d, xAxis, xScale})); } @@ -51,7 +56,7 @@ function getXValues(series: PreparedAreaSeries[], xAxis: PreparedAxis, xScale: C }, new Map()); if (xAxis.type === 'category') { - return (xAxis.categories || []).reduce<[string, number][]>((acc, category) => { + return categories.reduce<[string, number][]>((acc, category) => { const xValue = xValues.get(category); if (typeof xValue === 'number') { acc.push([category, xValue]); @@ -89,9 +94,18 @@ export const prepareAreaData = (args: { const yAxisIndex = s.yAxis; const seriesYAxis = yAxis[yAxisIndex]; const seriesYScale = yScale[yAxisIndex]; - const [yMin, _yMax] = seriesYScale.range(); + const yMin = getYValue({point: {y: 0}, yAxis: seriesYAxis, yScale: seriesYScale}); const seriesData = s.data.reduce>((m, d) => { - return m.set(String(d.x), d); + const key = String( + xAxis.type === 'category' + ? getDataCategoryValue({ + axisDirection: 'x', + categories: xAxis.categories || [], + data: d, + }) + : d.x, + ); + return m.set(key, d); }, new Map()); const points = xValues.reduce((pointsAcc, [x, xValue]) => { const accumulatedYValue = accumulatedYValues.get(x) || 0; diff --git a/src/plugins/d3/renderer/hooks/useShapes/bar-x/prepare-data.ts b/src/plugins/d3/renderer/hooks/useShapes/bar-x/prepare-data.ts index c1d229bb..37cc1e6b 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/bar-x/prepare-data.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/bar-x/prepare-data.ts @@ -153,12 +153,13 @@ export const prepareBarXData = (args: { } const x = xCenter - currentGroupWidth / 2 + (rectWidth + rectGap) * groupItemIndex; - const y = seriesYScale(yValue.data.y as number); - const height = plotHeight - y; - + const yDataValue = yValue.data.y as number; + const y = seriesYScale(yDataValue); + const base = seriesYScale(0); + const height = yDataValue > 0 ? base - y : y - base; const barData: PreparedBarXData = { x, - y: y - stackHeight, + y: yDataValue > 0 ? y - stackHeight : seriesYScale(0), width: rectWidth, height, opacity: get(yValue.data, 'opacity', null), diff --git a/src/plugins/d3/renderer/hooks/useShapes/bar-y/prepare-data.ts b/src/plugins/d3/renderer/hooks/useShapes/bar-y/prepare-data.ts index 820ae8ab..1eea8013 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/bar-y/prepare-data.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/bar-y/prepare-data.ts @@ -122,7 +122,8 @@ export const prepareBarYData = (args: { const stacks = Object.values(val); const currentBarHeight = barHeight * stacks.length + rectGap * (stacks.length - 1); stacks.forEach((measureValues, groupItemIndex) => { - let stackSum = 0; + const base = xLinearScale(0); + let stackSum = base; const stackItems: PreparedBarYData[] = []; const sortedData = sortKey @@ -140,10 +141,12 @@ export const prepareBarYData = (args: { } const y = center - currentBarHeight / 2 + (barHeight + rectGap) * groupItemIndex; - const width = xLinearScale(data.x as number); + const xValue = Number(data.x); + const width = + xValue > 0 ? xLinearScale(xValue) - base : base - xLinearScale(xValue); stackItems.push({ - x: stackSum, + x: xValue > 0 ? stackSum : stackSum - width, y, width, height: barHeight, diff --git a/src/plugins/d3/renderer/utils/index.ts b/src/plugins/d3/renderer/utils/index.ts index 93f719b8..03faab94 100644 --- a/src/plugins/d3/renderer/utils/index.ts +++ b/src/plugins/d3/renderer/utils/index.ts @@ -25,6 +25,11 @@ export * from './symbol'; export * from './series'; const CHARTS_WITHOUT_AXIS: ChartKitWidgetSeries['type'][] = ['pie', 'treemap']; +export const CHART_SERIES_WITH_VOLUME: ChartKitWidgetSeries['type'][] = [ + 'bar-x', + 'area', + 'waterfall', +]; export type AxisDirection = 'x' | 'y'; @@ -71,6 +76,14 @@ export const getDomainDataXBySeries = (series: UnknownSeries[]) => { }, []); }; +export function getDefaultMaxXAxisValue(series: UnknownSeries[]) { + if (series.some((s) => s.type === 'bar-y')) { + return 0; + } + + return undefined; +} + export const getDomainDataYBySeries = (series: UnknownSeries[]) => { const groupedSeries = group(series, (item) => item.type);