diff --git a/src/plugins/d3/__stories__/bar-x/datetime.stories.tsx b/src/plugins/d3/__stories__/bar-x/DatetimeAxis.stories.tsx similarity index 98% rename from src/plugins/d3/__stories__/bar-x/datetime.stories.tsx rename to src/plugins/d3/__stories__/bar-x/DatetimeAxis.stories.tsx index a294c088..ca77d8bd 100644 --- a/src/plugins/d3/__stories__/bar-x/datetime.stories.tsx +++ b/src/plugins/d3/__stories__/bar-x/DatetimeAxis.stories.tsx @@ -18,7 +18,7 @@ const Template: Story = () => { labels: {enabled: true}, }, legend: {enabled: true}, - tooltip: {enabled: false}, + tooltip: {enabled: true}, yAxis: [ { type: 'linear', diff --git a/src/plugins/d3/__stories__/bar-x/Grouped.stories.tsx b/src/plugins/d3/__stories__/bar-x/Grouped.stories.tsx new file mode 100644 index 00000000..28af6065 --- /dev/null +++ b/src/plugins/d3/__stories__/bar-x/Grouped.stories.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import {Meta, Story} from '@storybook/react'; +import {object, withKnobs} from '@storybook/addon-knobs'; +import {Button} from '@gravity-ui/uikit'; +import {settings} from '../../../../libs'; +import {ChartKit} from '../../../../components/ChartKit'; +import type {ChartKitWidgetData, ChartKitRef} from '../../../../types'; +import {D3Plugin} from '../..'; + +const Template: Story = () => { + const [shown, setShown] = React.useState(false); + const chartkitRef = React.useRef(); + const data: ChartKitWidgetData = { + tooltip: {enabled: true}, + title: {text: 'Grouped'}, + xAxis: { + type: 'category', + categories: ['A', 'B', 'C'], + labels: {enabled: true}, + }, + yAxis: [ + { + type: 'linear', + labels: {enabled: true}, + min: 0, + }, + ], + series: { + data: [ + { + type: 'bar-x', + visible: true, + data: [ + { + x: 'A', + y: 10, + }, + { + x: 'B', + y: 80, + }, + { + x: 'C', + y: 25, + }, + ], + name: 'Min', + dataLabels: { + enabled: true, + }, + }, + { + type: 'bar-x', + visible: true, + data: [ + { + x: 'A', + y: 110, + }, + { + x: 'B', + y: 80, + }, + { + x: 'C', + y: 200, + }, + ], + name: 'Mid', + dataLabels: { + enabled: true, + }, + }, + { + type: 'bar-x', + visible: true, + data: [ + { + x: 'A', + y: 410, + }, + { + x: 'B', + y: 580, + }, + { + x: 'C', + y: 205, + }, + ], + name: 'Max', + dataLabels: { + enabled: true, + }, + }, + ], + }, + }; + + if (!shown) { + settings.set({plugins: [D3Plugin]}); + return ; + } + + return ( +
+ ('data', data)} /> +
+ ); +}; + +export const Grouped = Template.bind({}); + +const meta: Meta = { + title: 'Plugins/D3/Bar-X', + decorators: [withKnobs], +}; + +export default meta; diff --git a/src/plugins/d3/__stories__/bar-x/GroupedAndStacked.stories.tsx b/src/plugins/d3/__stories__/bar-x/GroupedAndStacked.stories.tsx new file mode 100644 index 00000000..1d234bd8 --- /dev/null +++ b/src/plugins/d3/__stories__/bar-x/GroupedAndStacked.stories.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import {Meta, Story} from '@storybook/react'; +import {object, withKnobs} from '@storybook/addon-knobs'; +import {Button} from '@gravity-ui/uikit'; +import {settings} from '../../../../libs'; +import {ChartKit} from '../../../../components/ChartKit'; +import type {ChartKitWidgetData, ChartKitRef} from '../../../../types'; +import {D3Plugin} from '../..'; + +const Template: Story = () => { + const [shown, setShown] = React.useState(false); + const chartkitRef = React.useRef(); + const data: ChartKitWidgetData = { + tooltip: {enabled: true}, + title: {text: 'Grouped and stacked'}, + xAxis: { + type: 'category', + categories: ['A', 'B', 'C'], + labels: {enabled: true}, + }, + yAxis: [ + { + type: 'linear', + labels: {enabled: true}, + min: 0, + }, + ], + series: { + data: [ + { + type: 'bar-x', + visible: true, + stacking: 'normal', + data: [ + { + x: 'A', + y: 100, + }, + { + x: 'B', + y: 805, + }, + { + x: 'C', + y: 250, + }, + ], + stackId: 'stack1', + name: 'Base1', + }, + { + type: 'bar-x', + visible: true, + stacking: 'normal', + data: [ + { + x: 'A', + y: 40, + }, + { + x: 'B', + y: 80, + }, + { + x: 'C', + y: 25, + }, + ], + stackId: 'stack1', + name: 'Extended1', + }, + { + type: 'bar-x', + visible: true, + stacking: 'normal', + data: [ + { + x: 'A', + y: 110, + }, + { + x: 'B', + y: 80, + }, + { + x: 'C', + y: 200, + }, + ], + stackId: 'stack2', + name: 'Base2', + }, + { + type: 'bar-x', + visible: true, + stacking: 'normal', + data: [ + { + x: 'A', + y: 110, + }, + { + x: 'B', + y: 80, + }, + { + x: 'C', + y: 200, + }, + ], + stackId: 'stack2', + name: 'Extended2', + }, + ], + }, + }; + + if (!shown) { + settings.set({plugins: [D3Plugin]}); + return ; + } + + return ( +
+ ('data', data)} /> +
+ ); +}; + +export const GroupedAndStacked = Template.bind({}); + +const meta: Meta = { + title: 'Plugins/D3/Bar-X', + decorators: [withKnobs], +}; + +export default meta; diff --git a/src/plugins/d3/__stories__/bar-x/linear.stories.tsx b/src/plugins/d3/__stories__/bar-x/LinearAxis.stories.tsx similarity index 93% rename from src/plugins/d3/__stories__/bar-x/linear.stories.tsx rename to src/plugins/d3/__stories__/bar-x/LinearAxis.stories.tsx index 8b2a1008..9d7c516c 100644 --- a/src/plugins/d3/__stories__/bar-x/linear.stories.tsx +++ b/src/plugins/d3/__stories__/bar-x/LinearAxis.stories.tsx @@ -19,12 +19,10 @@ const Template: Story = () => { visible: true, data: [ { - category: 'A', x: 10, y: 100, }, { - category: 'B', x: 12, y: 80, }, @@ -36,7 +34,6 @@ const Template: Story = () => { visible: true, data: [ { - category: 'C', x: 95.5, y: 120, }, @@ -62,7 +59,7 @@ const Template: Story = () => { }, ], legend: {enabled: true}, - tooltip: {enabled: false}, + tooltip: {enabled: true}, }; if (!shown) { diff --git a/src/plugins/d3/__stories__/bar-x/stacked.stories.tsx b/src/plugins/d3/__stories__/bar-x/Stacked.stories.tsx similarity index 93% rename from src/plugins/d3/__stories__/bar-x/stacked.stories.tsx rename to src/plugins/d3/__stories__/bar-x/Stacked.stories.tsx index c1936a97..241024f3 100644 --- a/src/plugins/d3/__stories__/bar-x/stacked.stories.tsx +++ b/src/plugins/d3/__stories__/bar-x/Stacked.stories.tsx @@ -35,15 +35,15 @@ const Template: Story = () => { stacking: 'normal', data: [ { - category: 'A', + x: 'A', y: 100, }, { - category: 'B', + x: 'B', y: 80, }, { - category: 'C', + x: 'C', y: 120, }, ], @@ -63,11 +63,11 @@ const Template: Story = () => { stacking: 'normal', data: [ { - category: 'A', + x: 'A', y: 5, }, { - category: 'B', + x: 'B', y: 25, }, ], diff --git a/src/plugins/d3/__stories__/bar-x/category.stories.tsx "b/src/plugins/d3/__stories__/bar-x/\320\241ategoryAxis.stories.tsx" similarity index 100% rename from src/plugins/d3/__stories__/bar-x/category.stories.tsx rename to "src/plugins/d3/__stories__/bar-x/\320\241ategoryAxis.stories.tsx" diff --git a/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx b/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx index 44b284bc..435203af 100644 --- a/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx +++ b/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx @@ -1,8 +1,8 @@ import React from 'react'; import get from 'lodash/get'; - -import type {ChartKitWidgetSeriesData, TooltipHoveredData} from '../../../../../types/widget-data'; - +import {dateTime} from '@gravity-ui/date-utils'; +import type {ChartKitWidgetSeriesData, TooltipHoveredData} from '../../../../../types'; +import {formatNumber} from '../../../../shared'; import type {PreparedAxis} from '../../hooks'; import {getDataCategoryValue} from '../../utils'; @@ -12,21 +12,32 @@ type Props = { yAxis: PreparedAxis; }; -const getXRowData = (xAxis: PreparedAxis, data: ChartKitWidgetSeriesData) => { - const categories = get(xAxis, 'categories', [] as string[]); +const DEFAULT_DATE_FORMAT = 'DD.MM.YY'; + +const getRowData = (fieldName: 'x' | 'y', axis: PreparedAxis, data: ChartKitWidgetSeriesData) => { + const categories = get(axis, 'categories', [] as string[]); - return xAxis.type === 'category' - ? getDataCategoryValue({axisDirection: 'x', categories, data}) - : (data as {x: number}).x; + switch (axis.type) { + case 'category': { + return getDataCategoryValue({axisDirection: fieldName, categories, data}); + } + case 'datetime': { + const value = get(data, fieldName); + return dateTime({input: value}).format(DEFAULT_DATE_FORMAT); + } + case 'linear': + default: { + const value = get(data, fieldName) as unknown as number; + return formatNumber(value); + } + } }; -const getYRowData = (yAxis: PreparedAxis, data: ChartKitWidgetSeriesData) => { - const categories = get(yAxis, 'categories', [] as string[]); +const getXRowData = (xAxis: PreparedAxis, data: ChartKitWidgetSeriesData) => + getRowData('x', xAxis, data); - return yAxis.type === 'category' - ? getDataCategoryValue({axisDirection: 'y', categories, data}) - : (data as {y: number}).y; -}; +const getYRowData = (yAxis: PreparedAxis, data: ChartKitWidgetSeriesData) => + getRowData('y', yAxis, data); export const DefaultContent = ({hovered, xAxis, yAxis}: Props) => { const {data, series} = hovered; diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts index d1dadbbb..3d7435ec 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts @@ -71,32 +71,37 @@ type PrepareBarXSeriesArgs = { }; function prepareBarXSeries(args: PrepareBarXSeriesArgs): PreparedSeries[] { - const {colorScale, series, legend} = args; + const {colorScale, series: seriesList, legend} = args; const commonStackId = getRandomCKId(); - return series.map((singleSeries) => { - const name = singleSeries.name || ''; - const color = singleSeries.color || colorScale(name); + return seriesList.map((series) => { + const name = series.name || ''; + const color = series.color || colorScale(name); + + let stackId = series.stackId; + if (!stackId) { + stackId = series.stacking === 'normal' ? commonStackId : getRandomCKId(); + } return { - type: singleSeries.type, + type: series.type, color: color, name: name, - visible: get(singleSeries, 'visible', true), + visible: get(series, 'visible', true), legend: { - enabled: get(singleSeries, 'legend.enabled', legend.enabled), - symbol: prepareLegendSymbol(singleSeries), + enabled: get(series, 'legend.enabled', legend.enabled), + symbol: prepareLegendSymbol(series), }, - data: singleSeries.data, - stacking: singleSeries.stacking, - stackId: singleSeries.stacking === 'normal' ? commonStackId : getRandomCKId(), + data: series.data, + stacking: series.stacking, + stackId, dataLabels: { - enabled: singleSeries.dataLabels?.enabled || false, + enabled: series.dataLabels?.enabled || false, inside: - typeof singleSeries.dataLabels?.inside === 'boolean' - ? singleSeries.dataLabels?.inside + typeof series.dataLabels?.inside === 'boolean' + ? series.dataLabels?.inside : false, - style: Object.assign({}, DEFAULT_DATALABELS_STYLE, singleSeries.dataLabels?.style), + style: Object.assign({}, DEFAULT_DATALABELS_STYLE, series.dataLabels?.style), }, }; }, []); diff --git a/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx b/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx index 345e63f0..bffda88b 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx @@ -1,9 +1,9 @@ -import React from 'react'; -import {group, pointer, select} from 'd3'; +import {max, pointer, select} from 'd3'; import type {ScaleBand, ScaleLinear, ScaleTime} from 'd3'; +import React from 'react'; import get from 'lodash/get'; -import type {BarXSeriesData} from '../../../../../types/widget-data'; +import type {BarXSeriesData} from '../../../../../types'; import {block} from '../../../../../utils/cn'; import {getDataCategoryValue} from '../../utils'; @@ -12,9 +12,11 @@ import type {ChartOptions} from '../useChartOptions/types'; import type {OnSeriesMouseLeave, OnSeriesMouseMove} from '../useTooltip/types'; import type {PreparedBarXSeries} from '../useSeries/types'; -const DEFAULT_BAR_RECT_WIDTH = 50; -const DEFAULT_LINEAR_BAR_RECT_WIDTH = 20; +const RECT_PADDING = 0.1; const MIN_RECT_GAP = 1; +const MAX_RECT_WIDTH = 50; +const GROUP_PADDING = 0.1; +const MIN_GROUP_GAP = 1; const DEFAULT_LABEL_PADDING = 7; const b = block('d3-bar-x'); @@ -32,60 +34,117 @@ type Args = { svgContainer: SVGSVGElement | null; }; -const getRectProperties = (args: { - point: BarXSeriesData; +type ShapeData = { + x: number; + y: number; + width: number; + height: number; + data: BarXSeriesData; + series: PreparedBarXSeries; +}; + +function prepareData(args: { + series: PreparedBarXSeries[]; xAxis: ChartOptions['xAxis']; xScale: ChartScale; yAxis: ChartOptions['yAxis']; yScale: ChartScale; - minPointDistance: number; -}) => { - const {point, xAxis, xScale, yAxis, yScale, minPointDistance} = args; - let cx: string | number | undefined; - let cy: string | number | undefined; - let width: number; - let height: number; +}) { + const {series, xAxis, xScale, yScale} = args; + const categories = get(xAxis, 'categories', [] as string[]); + + const data: Record< + string | number, + Record + > = {}; + series.forEach((s) => { + s.data.forEach((d) => { + const xValue = + xAxis.type === 'category' + ? getDataCategoryValue({axisDirection: 'x', categories, data: d}) + : d.x; + + if (xValue) { + if (!data[xValue]) { + data[xValue] = {}; + } + + const xGroup = data[xValue]; + + if (!xGroup[s.stackId]) { + xGroup[s.stackId] = []; + } + + xGroup[s.stackId].push({data: d, series: s}); + } + }); + }); + + let bandWidth = Infinity; if (xAxis.type === 'category') { const xBandScale = xScale as ScaleBand; - const maxWidth = xBandScale.bandwidth() - MIN_RECT_GAP; - const categories = get(xAxis, 'categories', [] as string[]); - const dataCategory = getDataCategoryValue({axisDirection: 'x', categories, data: point}); - width = Math.min(maxWidth, DEFAULT_BAR_RECT_WIDTH); - cx = (xBandScale(dataCategory) || 0) + xBandScale.step() / 2 - width / 2; + bandWidth = xBandScale.bandwidth(); } else { const xLinearScale = xScale as ScaleLinear | ScaleTime; - const [min, max] = xLinearScale.domain(); - const range = xLinearScale.range(); - const maxWidth = - ((range[1] - range[0]) * minPointDistance) / (Number(max) - Number(min)) - MIN_RECT_GAP; - - width = Math.min(Math.max(maxWidth, 1), DEFAULT_LINEAR_BAR_RECT_WIDTH); - cx = xLinearScale(point.x as number) - width / 2; - } - - if (yAxis[0].type === 'linear') { - const yLinearScale = yScale as ScaleLinear; - cy = yLinearScale(point.y as number); - height = yLinearScale(yLinearScale.domain()[0]) - cy; - } else { - throw Error(`The "${yAxis[0].type}" type for the Y axis is not supported`); + const xValues = series.reduce((acc, s) => { + s.data.forEach((dataItem) => acc.push(Number(dataItem.x))); + return acc; + }, []); + + xValues.sort().forEach((xValue, index) => { + if (index > 0 && xValue !== xValues[index - 1]) { + const dist = xLinearScale(xValue) - xLinearScale(xValues[index - 1]); + if (dist < bandWidth) { + bandWidth = dist; + } + } + }); } - return {x: cx, y: cy, width, height}; -}; - -function minDiff(arr: number[]) { - let result = Infinity; + const maxGroupSize = max(Object.values(data), (d) => Object.values(d).length) || 1; + const groupGap = Math.max(bandWidth * GROUP_PADDING, MIN_GROUP_GAP); + const maxGroupWidth = bandWidth - groupGap; + const rectGap = Math.max((maxGroupWidth / maxGroupSize) * RECT_PADDING, MIN_RECT_GAP); + const rectWidth = Math.min(maxGroupWidth / maxGroupSize - rectGap, MAX_RECT_WIDTH); + + const result: ShapeData[] = []; + + Object.entries(data).forEach(([xValue, val]) => { + const stacks = Object.values(val); + const currentGroupWidth = rectWidth * stacks.length + rectGap * (stacks.length - 1); + stacks.forEach((yValues, groupItemIndex) => { + let stackHeight = 0; + yValues.forEach((yValue) => { + let xCenter; + if (xAxis.type === 'category') { + const xBandScale = xScale as ScaleBand; + xCenter = (xBandScale(xValue as string) || 0) + xBandScale.bandwidth() / 2; + } else { + const xLinearScale = xScale as + | ScaleLinear + | ScaleTime; + xCenter = xLinearScale(Number(xValue)); + } + const x = xCenter - currentGroupWidth / 2 + (rectWidth + rectGap) * groupItemIndex; + + const yLinearScale = yScale as ScaleLinear; + const y = yLinearScale(yValue.data.y as number); + const height = yLinearScale(yLinearScale.domain()[0]) - y; + + result.push({ + x, + y: y - stackHeight, + width: rectWidth, + height, + data: yValue.data, + series: yValue.series, + }); - for (let i = 0; i < arr.length - 1; i++) { - for (let j = i + 1; j < arr.length; j++) { - const diff = Math.abs(arr[i] - arr[j]); - if (diff < result) { - result = diff; - } - } - } + stackHeight += height + 1; + }); + }); + }); return result; } @@ -114,97 +173,60 @@ export function BarXSeriesShapes(args: Args) { const svgElement = select(ref.current); svgElement.selectAll('*').remove(); - const xValues = - xAxis.type === 'category' - ? [] - : series.reduce((acc, {data}) => { - data.forEach((dataItem) => acc.push(Number(dataItem.x))); - return acc; - }, []); - const minPointDistance = minDiff(xValues); - - const stackedSeriesMap = group(series, (item) => item.stackId); - Array.from(stackedSeriesMap).forEach(([, stackedSeries]) => { - const stackHeights: Record = {}; - stackedSeries.forEach((item) => { - const shapes = item.data.map((dataItem) => { - const rectProps = getRectProperties({ - point: dataItem, - xAxis, - xScale, - yAxis, - yScale, - minPointDistance, - }); - - if (!stackHeights[rectProps.x]) { - stackHeights[rectProps.x] = 0; - } - - const rectY = rectProps.y - stackHeights[rectProps.x]; - stackHeights[rectProps.x] += rectProps.height + 1; - - return { - ...rectProps, - y: rectY, - data: dataItem, - }; - }); + const shapes = prepareData({ + series, + xAxis, + xScale, + yAxis, + yScale, + }); - svgElement - .selectAll('allRects') - .data(shapes) - .join('rect') - .attr('class', b('segment')) - .attr('x', (d) => d.x) - .attr('y', (d) => d.y) - .attr('height', (d) => d.height) - .attr('width', (d) => d.width) - .attr('fill', (d) => d.data.color || item.color) - .on('mousemove', (e, point) => { - const [x, y] = pointer(e, svgContainer); - onSeriesMouseMove?.({ - hovered: { - data: point.data, - series: item, - }, - pointerPosition: [x - left, y - top], - }); - }) - .on('mouseleave', () => { - if (onSeriesMouseLeave) { - onSeriesMouseLeave(); - } - }); - - if (item.dataLabels.enabled) { - const selection = svgElement - .selectAll('allLabels') - .data(shapes) - .join('text') - .text((d) => String(d.data.label || d.data.y)) - .attr('class', b('label')) - .attr('x', (d) => d.x + d.width / 2) - .attr('y', (d) => { - if (item.dataLabels.inside) { - return d.y + d.height / 2; - } - - return d.y - DEFAULT_LABEL_PADDING; - }) - .attr('text-anchor', 'middle') - .style('font-size', item.dataLabels.style.fontSize); - - if (item.dataLabels.style.fontWeight) { - selection.style('font-weight', item.dataLabels.style.fontWeight); - } - - if (item.dataLabels.style.fontColor) { - selection.style('fill', item.dataLabels.style.fontColor); - } + svgElement + .selectAll('allRects') + .data(shapes) + .join('rect') + .attr('class', b('segment')) + .attr('x', (d) => d.x) + .attr('y', (d) => d.y) + .attr('height', (d) => d.height) + .attr('width', (d) => d.width) + .attr('fill', (d) => d.data.color || d.series.color) + .on('mousemove', (e, d) => { + const [x, y] = pointer(e, svgContainer); + onSeriesMouseMove?.({ + hovered: { + data: d.data, + series: d.series, + }, + pointerPosition: [x - left, y - top], + }); + }) + .on('mouseleave', () => { + if (onSeriesMouseLeave) { + onSeriesMouseLeave(); } }); - }); + + const dataLabels = shapes.filter((s) => s.series.dataLabels.enabled); + + svgElement + .selectAll('allLabels') + .data(dataLabels) + .join('text') + .text((d) => String(d.data.label || d.data.y)) + .attr('class', b('label')) + .attr('x', (d) => d.x + d.width / 2) + .attr('y', (d) => { + if (d.series.dataLabels.inside) { + return d.y + d.height / 2; + } + + return d.y - DEFAULT_LABEL_PADDING; + }) + .attr('text-anchor', 'middle') + .style('font-size', (d) => d.series.dataLabels.style.fontSize) + .style('font-weight', (d) => d.series.dataLabels.style.fontWeight || null) + .style('fill', (d) => d.series.dataLabels.style.fontColor || null); }, [ onSeriesMouseMove, onSeriesMouseLeave,