From f4ce4034820170cbbf1df7f6382e00ceee35524d Mon Sep 17 00:00:00 2001 From: Irina Kuzmina Date: Mon, 21 Oct 2024 12:49:48 +0300 Subject: [PATCH] feat(D3 plugin): improve treemap labels (#530) * feat(D3 plugin): improve treemap labels * fix --- .../treemap/HtmlLabels.stories.tsx | 75 +++++++++++++++ .../treemap/Playground.stories.tsx | 8 +- .../hooks/useSeries/prepare-treemap.ts | 3 +- .../d3/renderer/hooks/useSeries/types.ts | 1 + .../hooks/useShapes/treemap/index.tsx | 2 +- .../hooks/useShapes/treemap/prepare-data.ts | 93 ++++++++++++++++--- src/plugins/d3/renderer/validation/index.ts | 5 +- src/types/widget-data/base.ts | 2 - src/types/widget-data/treemap.ts | 9 +- 9 files changed, 175 insertions(+), 23 deletions(-) create mode 100644 src/plugins/d3/__stories__/treemap/HtmlLabels.stories.tsx diff --git a/src/plugins/d3/__stories__/treemap/HtmlLabels.stories.tsx b/src/plugins/d3/__stories__/treemap/HtmlLabels.stories.tsx new file mode 100644 index 00000000..1a5788f7 --- /dev/null +++ b/src/plugins/d3/__stories__/treemap/HtmlLabels.stories.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +import {Col, Container, Row} from '@gravity-ui/uikit'; +import type {StoryObj} from '@storybook/react'; + +import {ChartKit} from '../../../../components/ChartKit'; +import {Loader} from '../../../../components/Loader/Loader'; +import {settings} from '../../../../libs'; +import type {ChartKitWidgetData} from '../../../../types'; +import {TreemapSeries} from '../../../../types'; +import {ExampleWrapper} from '../../examples/ExampleWrapper'; +import {D3Plugin} from '../../index'; + +const TreemapWithHtmlLabels = () => { + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + settings.set({plugins: [D3Plugin]}); + setLoading(false); + }, []); + + if (loading) { + return ; + } + + const styledLabel = (label: string) => + `${label}`; + const treemapSeries: TreemapSeries = { + type: 'treemap', + name: 'Example', + dataLabels: { + enabled: true, + html: true, + align: 'right', + }, + layoutAlgorithm: 'binary', + levels: [ + {index: 1, padding: 3}, + {index: 2, padding: 1}, + ], + data: [ + {name: styledLabel('One'), value: 15}, + {name: styledLabel('Two'), id: 'Two'}, + {name: [styledLabel('Two'), '1'], value: 2, parentId: 'Two'}, + {name: [styledLabel('Two'), '2'], value: 8, parentId: 'Two'}, + ], + }; + + const getWidgetData = (): ChartKitWidgetData => ({ + series: { + data: [treemapSeries], + }, + }); + + return ( + + + + + + + + + + ); +}; + +export const TreemapWithHtmlLabelsStory: StoryObj = { + name: 'Html in labels', +}; + +export default { + title: 'Plugins/D3/Treemap', + component: TreemapWithHtmlLabels, +}; diff --git a/src/plugins/d3/__stories__/treemap/Playground.stories.tsx b/src/plugins/d3/__stories__/treemap/Playground.stories.tsx index cf8fdea8..c7b145ce 100644 --- a/src/plugins/d3/__stories__/treemap/Playground.stories.tsx +++ b/src/plugins/d3/__stories__/treemap/Playground.stories.tsx @@ -17,13 +17,17 @@ const prepareData = (): ChartKitWidgetData => { enabled: true, }, layoutAlgorithm: 'binary', - levels: [{index: 1}, {index: 2}, {index: 3}], + levels: [ + {index: 1, padding: 5}, + {index: 2, padding: 3}, + {index: 3, padding: 1}, + ], data: [ {name: 'One', value: 15}, {name: 'Two', value: 10}, {name: 'Three', value: 15}, {name: 'Four'}, - {name: 'Four-1', value: 5, parentId: 'Four'}, + {name: ['Four', '1'], value: 5, parentId: 'Four'}, {name: 'Four-2', parentId: 'Four'}, {name: 'Four-3', value: 4, parentId: 'Four'}, {name: 'Four-2-1', value: 5, parentId: 'Four-2'}, diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-treemap.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-treemap.ts index aaf6e1ea..460be2a7 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepare-treemap.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-treemap.ts @@ -32,7 +32,8 @@ export function prepareTreemap(args: PrepareTreemapSeriesArgs) { style: Object.assign({}, DEFAULT_DATALABELS_STYLE, s.dataLabels?.style), padding: get(s, 'dataLabels.padding', DEFAULT_DATALABELS_PADDING), allowOverlap: get(s, 'dataLabels.allowOverlap', false), - html: get(series, 'dataLabels.html', false), + html: get(s, 'dataLabels.html', false), + align: get(s, 'dataLabels.align', 'left'), }, id, type: s.type, diff --git a/src/plugins/d3/renderer/hooks/useSeries/types.ts b/src/plugins/d3/renderer/hooks/useSeries/types.ts index 62b0a98b..de997dee 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/types.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/types.ts @@ -269,6 +269,7 @@ export type PreparedTreemapSeries = { padding: number; allowOverlap: boolean; html: boolean; + align: Required['dataLabels']>['align']; }; layoutAlgorithm: `${LayoutAlgorithm}`; } & BasePreparedSeries & diff --git a/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx index b3cc61fa..a36d7e63 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx @@ -23,7 +23,7 @@ type ShapeProps = { export const TreemapSeriesShape = (props: ShapeProps) => { const {dispatcher, preparedData, seriesOptions, htmlLayout} = props; - const ref = React.useRef(null); + const ref = React.useRef(null); React.useEffect(() => { if (!ref.current) { diff --git a/src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts b/src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts index f05dbf0f..aa31bf81 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts @@ -11,24 +11,70 @@ import type {HierarchyRectangularNode} from 'd3'; import {LayoutAlgorithm} from '../../../../../../constants'; import type {TreemapSeriesData} from '../../../../../../types'; +import {HtmlItem} from '../../../types'; +import {getLabelsSize} from '../../../utils'; import type {PreparedTreemapSeries} from '../../useSeries/types'; import type {PreparedTreemapData, TreemapLabelData} from './types'; const DEFAULT_PADDING = 1; -function getLabelData(data: HierarchyRectangularNode[]): TreemapLabelData[] { - return data.map((d) => { - const text = d.data.name; - - return { - text, - x: d.x0, - y: d.y0, - width: d.x1 - d.x0, - nodeData: d.data, - }; - }); +type LabelItem = HtmlItem | TreemapLabelData; + +function getLabels(args: { + data: HierarchyRectangularNode[]; + html: boolean; + padding: number; + align: PreparedTreemapSeries['dataLabels']['align']; +}) { + const {data, html, padding, align} = args; + + return data.reduce((acc, d) => { + const texts = Array.isArray(d.data.name) ? d.data.name : [d.data.name]; + + texts.forEach((text, index) => { + const {maxHeight: lineHeight, maxWidth: labelWidth} = + getLabelsSize({labels: [text], html}) ?? {}; + const left = d.x0 + padding; + const right = d.x1 - padding; + const width = Math.max(0, right - left); + let x = left; + const y = index * lineHeight + d.y0 + padding; + + switch (align) { + case 'left': { + x = left; + break; + } + case 'center': { + x = Math.max(left, left + (width - labelWidth) / 2); + break; + } + case 'right': { + x = Math.max(left, right - labelWidth); + break; + } + } + + const item: LabelItem = html + ? { + content: text, + x, + y, + } + : { + text, + x, + y, + width, + nodeData: d.data, + }; + + acc.push(item); + }); + + return acc; + }, []); } export function prepareTreemapData(args: { @@ -39,7 +85,13 @@ export function prepareTreemapData(args: { const {series, width, height} = args; const dataWithRootNode = getSeriesDataWithRootNode(series); const hierarchy = stratify() - .id((d) => d.id || d.name) + .id((d) => { + if (d.id) { + return d.id; + } + + return Array.isArray(d.name) ? d.name.join() : d.name; + }) .parentId((d) => d.parentId)(dataWithRootNode) .sum((d) => d.value || 0); const treemapInstance = treemap(); @@ -72,9 +124,20 @@ export function prepareTreemapData(args: { return levelOptions?.padding ?? DEFAULT_PADDING; })(hierarchy); const leaves = root.leaves(); - const labelData: TreemapLabelData[] = series.dataLabels?.enabled ? getLabelData(leaves) : []; + let labelData: TreemapLabelData[] = []; + const htmlElements: HtmlItem[] = []; + + if (series.dataLabels?.enabled) { + const {html, padding, align} = series.dataLabels; + const labels = getLabels({html, padding, align, data: leaves}); + if (html) { + htmlElements.push(...(labels as HtmlItem[])); + } else { + labelData = labels as TreemapLabelData[]; + } + } - return {labelData, leaves, series, htmlElements: []}; + return {labelData, leaves, series, htmlElements}; } function getSeriesDataWithRootNode(series: PreparedTreemapSeries) { diff --git a/src/plugins/d3/renderer/validation/index.ts b/src/plugins/d3/renderer/validation/index.ts index 009a00e8..0b85ed8a 100644 --- a/src/plugins/d3/renderer/validation/index.ts +++ b/src/plugins/d3/renderer/validation/index.ts @@ -158,7 +158,10 @@ const validateTreemapSeries = ({series}: {series: TreemapSeries}) => { } }); series.data.forEach((d) => { - const idOrName = d.id || d.name; + let idOrName = d.id; + if (!idOrName) { + idOrName = Array.isArray(d.name) ? d.name.join() : d.name; + } if (parentIds[idOrName] && typeof d.value === 'number') { throw new ChartKitError({ diff --git a/src/types/widget-data/base.ts b/src/types/widget-data/base.ts index ca3de71a..7d51831d 100644 --- a/src/types/widget-data/base.ts +++ b/src/types/widget-data/base.ts @@ -3,8 +3,6 @@ export type BaseSeries = { visible?: boolean; /** * Options for the series data labels, appearing next to each data point. - * - * Note: now this option is supported only for `pie` charts. * */ dataLabels?: { /** diff --git a/src/types/widget-data/treemap.ts b/src/types/widget-data/treemap.ts index 23633e1e..ded6af3e 100644 --- a/src/types/widget-data/treemap.ts +++ b/src/types/widget-data/treemap.ts @@ -5,7 +5,7 @@ import {ChartKitWidgetLegend, RectLegendSymbolOptions} from './legend'; export type TreemapSeriesData = BaseSeriesData & { /** The name of the node (used in legend, tooltip etc). */ - name: string; + name: string | string[]; /** The value of the node. All nodes should have this property except nodes that have children. */ value?: number; /** An id for the node. Used to group children. */ @@ -38,4 +38,11 @@ export type TreemapSeries = BaseSeries & { color?: string; }[]; layoutAlgorithm?: `${LayoutAlgorithm}`; + /** + * Options for the series data labels, appearing next to each data point. + * */ + dataLabels?: BaseSeries['dataLabels'] & { + /** Horizontal alignment of the data label inside the tile. */ + align?: 'left' | 'center' | 'right'; + }; };