From 6c44f44e59779f4bc17b0150f4a6940798697f54 Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Wed, 30 Aug 2023 22:58:24 +0300 Subject: [PATCH 1/4] feat(D3 plugin): bar-x dataLabels option --- .../d3/__stories__/bar-x/category.stories.tsx | 7 +- .../d3/__stories__/bar-x/stacked.stories.tsx | 15 +- .../renderer/hooks/useSeries/prepareSeries.ts | 14 ++ .../d3/renderer/hooks/useSeries/types.ts | 6 + .../d3/renderer/hooks/useShapes/bar-x.tsx | 158 ++++++++++++------ .../d3/renderer/hooks/useShapes/index.tsx | 29 ++-- .../d3/renderer/hooks/useShapes/styles.scss | 6 + src/types/widget-data/bar-x.ts | 9 +- src/types/widget-data/base.ts | 4 + 9 files changed, 177 insertions(+), 71 deletions(-) diff --git a/src/plugins/d3/__stories__/bar-x/category.stories.tsx b/src/plugins/d3/__stories__/bar-x/category.stories.tsx index 6c0482df..9b01bc2f 100644 --- a/src/plugins/d3/__stories__/bar-x/category.stories.tsx +++ b/src/plugins/d3/__stories__/bar-x/category.stories.tsx @@ -29,16 +29,19 @@ const Template: Story = () => { data: [ { category: 'A', - x: 10, + label: 10, y: 100, }, { category: 'B', - x: 12, + label: 12, y: 80, }, ], name: 'AB', + dataLabels: { + enabled: true, + }, }, { type: 'bar-x', diff --git a/src/plugins/d3/__stories__/bar-x/stacked.stories.tsx b/src/plugins/d3/__stories__/bar-x/stacked.stories.tsx index 5d0a7256..c1936a97 100644 --- a/src/plugins/d3/__stories__/bar-x/stacked.stories.tsx +++ b/src/plugins/d3/__stories__/bar-x/stacked.stories.tsx @@ -48,6 +48,14 @@ const Template: Story = () => { }, ], name: 'Sales', + dataLabels: { + enabled: true, + inside: true, + style: { + fontWeight: 'normal', + fontColor: '#fff', + }, + }, }, { type: 'bar-x', @@ -62,12 +70,11 @@ const Template: Story = () => { category: 'B', y: 25, }, - { - category: 'C', - y: 0, - }, ], name: 'Discount', + dataLabels: { + enabled: true, + }, }, ], }, diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts index de6e5bbf..d1dadbbb 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts @@ -18,6 +18,12 @@ import get from 'lodash/get'; import {DEFAULT_PALETTE} from '../../constants'; import {DEFAULT_LEGEND_SYMBOL_SIZE} from './constants'; import {getRandomCKId} from '../../../../../utils'; +import {BaseTextStyle} from '../../../../../types/widget-data'; + +const DEFAULT_DATALABELS_STYLE: BaseTextStyle = { + fontSize: '11px', + fontWeight: 'bold', +}; function prepareLegendSymbol(series: ChartKitWidgetSeries): PreparedLegendSymbol { switch (series.type) { @@ -84,6 +90,14 @@ function prepareBarXSeries(args: PrepareBarXSeriesArgs): PreparedSeries[] { data: singleSeries.data, stacking: singleSeries.stacking, stackId: singleSeries.stacking === 'normal' ? commonStackId : getRandomCKId(), + dataLabels: { + enabled: singleSeries.dataLabels?.enabled || false, + inside: + typeof singleSeries.dataLabels?.inside === 'boolean' + ? singleSeries.dataLabels?.inside + : false, + style: Object.assign({}, DEFAULT_DATALABELS_STYLE, singleSeries.dataLabels?.style), + }, }; }, []); } diff --git a/src/plugins/d3/renderer/hooks/useSeries/types.ts b/src/plugins/d3/renderer/hooks/useSeries/types.ts index 240afcc7..91c71721 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/types.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/types.ts @@ -1,6 +1,7 @@ import { BarXSeries, BarXSeriesData, + BaseTextStyle, PieSeries, PieSeriesData, RectLegendSymbolOptions, @@ -33,6 +34,11 @@ export type PreparedBarXSeries = { type: BarXSeries['type']; data: BarXSeriesData[]; stackId: string; + dataLabels: { + enabled: boolean; + inside: boolean; + style: BaseTextStyle; + }; } & BasePreparedSeries; export type PreparedPieSeries = BasePreparedSeries & diff --git a/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx b/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx index da553e9d..437ea5b6 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx @@ -4,14 +4,15 @@ import {ChartScale} from '../useAxisScales'; import {OnSeriesMouseLeave, OnSeriesMouseMove} from '../useTooltip/types'; import {BarXSeriesData} from '../../../../../types/widget-data'; import {block} from '../../../../../utils/cn'; -import {group, pointer, ScaleBand, ScaleLinear, ScaleTime} from 'd3'; +import {group, pointer, ScaleBand, ScaleLinear, ScaleTime, select} from 'd3'; import {PreparedBarXSeries} from '../useSeries/types'; const DEFAULT_BAR_RECT_WIDTH = 50; const DEFAULT_LINEAR_BAR_RECT_WIDTH = 20; const MIN_RECT_GAP = 1; +const DEFAULT_LABEL_PADDING = 7; -const b = block('d3-bar'); +const b = block('d3-bar-x'); type Args = { top: number; @@ -82,7 +83,7 @@ function minDiff(arr: number[]) { return result; } -export function prepareBarXSeries(args: Args) { +export function BarXSeriesShapes(args: Args) { const { top, left, @@ -96,60 +97,115 @@ export function prepareBarXSeries(args: Args) { svgContainer, } = args; - const stackedSeriesMap = group(series, (item) => item.stackId); + const ref = React.useRef(null); - const seriesData = series.map(({data}) => data).flat(2); - const minPointDistance = minDiff(seriesData.map((item) => Number(item.x))); - - const result: React.ReactElement[] = []; + React.useEffect(() => { + if (!ref.current) { + return; + } - Array.from(stackedSeriesMap).forEach(([stackId, stackedSeries]) => { - const stackHeights: Record = {}; - stackedSeries.forEach((item, seriesIndex) => { - item.data.forEach((point, i) => { - const rectProps = getRectProperties({ - point, - xAxis, - xScale, - yAxis, - yScale, - minPointDistance, + 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, + }; }); - if (!stackHeights[rectProps.x]) { - stackHeights[rectProps.x] = 0; - } - - const rectY = rectProps.y - stackHeights[rectProps.x]; - stackHeights[rectProps.x] += rectProps.height + 1; - - if (!rectProps.height) { - return; + svgElement + .append('g') + .attr('fill', item.color) + .selectAll('rect') + .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) + .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) { + svgElement + .append('g') + .style('font-size', item.dataLabels.style.fontSize) + .style('font-weight', item.dataLabels.style.fontWeight) + .selectAll('text') + .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; + }) + .style('fill', item.dataLabels.style.fontColor) + .attr('text-anchor', 'middle'); } - - result.push( - , - ); }); }); - }); + }, [ + onSeriesMouseMove, + onSeriesMouseLeave, + svgContainer, + xAxis, + xScale, + yAxis, + yScale, + series, + left, + top, + ]); - return result; + return ; } diff --git a/src/plugins/d3/renderer/hooks/useShapes/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/index.tsx index e83badf8..48e78eb5 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -8,7 +8,7 @@ import type {ChartOptions} from '../useChartOptions/types'; import type {ChartScale} from '../useAxisScales'; import type {PreparedBarXSeries, PreparedPieSeries, PreparedSeries} from '../'; import type {OnSeriesMouseMove, OnSeriesMouseLeave} from '../useTooltip/types'; -import {prepareBarXSeries} from './bar-x'; +import {BarXSeriesShapes} from './bar-x'; import {prepareScatterSeries} from './scatter'; import {PieSeriesComponent} from './pie'; @@ -55,18 +55,21 @@ export const useShapes = (args: Args) => { case 'bar-x': { if (xScale && yScale) { acc.push( - ...prepareBarXSeries({ - top, - left, - series: chartSeries as PreparedBarXSeries[], - xAxis, - xScale, - yAxis, - yScale, - onSeriesMouseMove, - onSeriesMouseLeave, - svgContainer, - }), + , ); } break; diff --git a/src/plugins/d3/renderer/hooks/useShapes/styles.scss b/src/plugins/d3/renderer/hooks/useShapes/styles.scss index f9e3dc92..9da1ec72 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/styles.scss +++ b/src/plugins/d3/renderer/hooks/useShapes/styles.scss @@ -24,3 +24,9 @@ font-weight: bold; } } + +.chartkit-d3-bar-x { + &__label { + fill: var(--g-color-text-complementary); + } +} diff --git a/src/types/widget-data/bar-x.ts b/src/types/widget-data/bar-x.ts index 826071bf..ac58666f 100644 --- a/src/types/widget-data/bar-x.ts +++ b/src/types/widget-data/bar-x.ts @@ -9,6 +9,9 @@ export type BarXSeriesData = BaseSeriesData & { y?: number; /** Corresponding value of axis category */ category?: string; + + /** Data label value of the bar-x column. If not specified, the y value is used. */ + label?: string | number; }; export type BarXSeries = BaseSeries & { @@ -42,7 +45,11 @@ export type BarXSeries = BaseSeries & { grouping?: boolean; dataLabels?: ChartKitWidgetSeriesOptions['dataLabels'] & { - /** Whether to align the data label inside the box or to the actual value point */ + /** + * Whether to align the data label inside or outside the box + * + * @default false + * */ inside?: boolean; }; diff --git a/src/types/widget-data/base.ts b/src/types/widget-data/base.ts index 0e53909b..42648ad6 100644 --- a/src/types/widget-data/base.ts +++ b/src/types/widget-data/base.ts @@ -12,6 +12,8 @@ export type BaseSeries = { * @default true */ enabled?: boolean; + + style?: Partial; }; }; @@ -26,4 +28,6 @@ export type BaseSeriesData = { export type BaseTextStyle = { fontSize: string; + fontWeight: string; + fontColor: string; }; From f1b07c9f53f7d3675a9c5ebcc00c8e86ae572b65 Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Wed, 30 Aug 2023 23:03:35 +0300 Subject: [PATCH 2/4] fix BarXSeriesShapes props --- .../d3/renderer/hooks/useShapes/index.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/plugins/d3/renderer/hooks/useShapes/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/index.tsx index 48e78eb5..430561ea 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -56,19 +56,11 @@ export const useShapes = (args: Args) => { if (xScale && yScale) { acc.push( , ); } From 7cb968c81a7359d6cd57b077d281eec808459ace Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Wed, 30 Aug 2023 23:17:26 +0300 Subject: [PATCH 3/4] fix types --- src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx | 12 +++++++++--- src/types/widget-data/base.ts | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx b/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx index 437ea5b6..68ad1041 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx @@ -172,10 +172,9 @@ export function BarXSeriesShapes(args: Args) { }); if (item.dataLabels.enabled) { - svgElement + const selection = svgElement .append('g') .style('font-size', item.dataLabels.style.fontSize) - .style('font-weight', item.dataLabels.style.fontWeight) .selectAll('text') .data(shapes) .join('text') @@ -189,8 +188,15 @@ export function BarXSeriesShapes(args: Args) { return d.y - DEFAULT_LABEL_PADDING; }) - .style('fill', item.dataLabels.style.fontColor) .attr('text-anchor', 'middle'); + + 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); + } } }); }); diff --git a/src/types/widget-data/base.ts b/src/types/widget-data/base.ts index 42648ad6..49e81cbd 100644 --- a/src/types/widget-data/base.ts +++ b/src/types/widget-data/base.ts @@ -28,6 +28,6 @@ export type BaseSeriesData = { export type BaseTextStyle = { fontSize: string; - fontWeight: string; - fontColor: string; + fontWeight?: string; + fontColor?: string; }; From 9214c059d71a9e450182ccbc5accaff017f8c76b Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Thu, 31 Aug 2023 14:08:20 +0300 Subject: [PATCH 4/4] fix: remove extra g element from bar-x selections --- src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx b/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx index 68ad1041..cc0b53a2 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx @@ -145,9 +145,7 @@ export function BarXSeriesShapes(args: Args) { }); svgElement - .append('g') - .attr('fill', item.color) - .selectAll('rect') + .selectAll('allRects') .data(shapes) .join('rect') .attr('class', b('segment')) @@ -155,6 +153,7 @@ export function BarXSeriesShapes(args: Args) { .attr('y', (d) => d.y) .attr('height', (d) => d.height) .attr('width', (d) => d.width) + .attr('fill', item.color) .on('mousemove', (e, point) => { const [x, y] = pointer(e, svgContainer); onSeriesMouseMove?.({ @@ -173,9 +172,7 @@ export function BarXSeriesShapes(args: Args) { if (item.dataLabels.enabled) { const selection = svgElement - .append('g') - .style('font-size', item.dataLabels.style.fontSize) - .selectAll('text') + .selectAll('allLabels') .data(shapes) .join('text') .text((d) => String(d.data.label || d.data.y)) @@ -188,7 +185,8 @@ export function BarXSeriesShapes(args: Args) { return d.y - DEFAULT_LABEL_PADDING; }) - .attr('text-anchor', 'middle'); + .attr('text-anchor', 'middle') + .style('font-size', item.dataLabels.style.fontSize); if (item.dataLabels.style.fontWeight) { selection.style('font-weight', item.dataLabels.style.fontWeight);