Skip to content

Commit

Permalink
feat(D3 plugin): improve treemap labels (#530)
Browse files Browse the repository at this point in the history
* feat(D3 plugin): improve treemap labels

* fix
  • Loading branch information
kuzmadom authored Oct 21, 2024
1 parent d2a14be commit f4ce403
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 23 deletions.
75 changes: 75 additions & 0 deletions src/plugins/d3/__stories__/treemap/HtmlLabels.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 <Loader />;
}

const styledLabel = (label: string) =>
`<span style="padding: 2px; background-color: #0a3069;color: #fff;">${label}</span>`;
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 (
<Container spaceRow={5}>
<Row space={1}>
<Col s={4}>
<ExampleWrapper>
<ChartKit type="d3" data={getWidgetData()} />
</ExampleWrapper>
</Col>
</Row>
</Container>
);
};

export const TreemapWithHtmlLabelsStory: StoryObj<typeof TreemapWithHtmlLabels> = {
name: 'Html in labels',
};

export default {
title: 'Plugins/D3/Treemap',
component: TreemapWithHtmlLabels,
};
8 changes: 6 additions & 2 deletions src/plugins/d3/__stories__/treemap/Playground.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand Down
3 changes: 2 additions & 1 deletion src/plugins/d3/renderer/hooks/useSeries/prepare-treemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/plugins/d3/renderer/hooks/useSeries/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ export type PreparedTreemapSeries = {
padding: number;
allowOverlap: boolean;
html: boolean;
align: Required<Required<TreemapSeries>['dataLabels']>['align'];
};
layoutAlgorithm: `${LayoutAlgorithm}`;
} & BasePreparedSeries &
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type ShapeProps = {

export const TreemapSeriesShape = (props: ShapeProps) => {
const {dispatcher, preparedData, seriesOptions, htmlLayout} = props;
const ref = React.useRef<SVGGElement>(null);
const ref = React.useRef<SVGGElement | null>(null);

React.useEffect(() => {
if (!ref.current) {
Expand Down
93 changes: 78 additions & 15 deletions src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TreemapSeriesData>[]): 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<TreemapSeriesData>[];
html: boolean;
padding: number;
align: PreparedTreemapSeries['dataLabels']['align'];
}) {
const {data, html, padding, align} = args;

return data.reduce<LabelItem[]>((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: {
Expand All @@ -39,7 +85,13 @@ export function prepareTreemapData(args: {
const {series, width, height} = args;
const dataWithRootNode = getSeriesDataWithRootNode(series);
const hierarchy = stratify<TreemapSeriesData>()
.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<TreemapSeriesData>();
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion src/plugins/d3/renderer/validation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 0 additions & 2 deletions src/types/widget-data/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
/**
Expand Down
9 changes: 8 additions & 1 deletion src/types/widget-data/treemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {ChartKitWidgetLegend, RectLegendSymbolOptions} from './legend';

export type TreemapSeriesData<T = any> = BaseSeriesData<T> & {
/** 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. */
Expand Down Expand Up @@ -38,4 +38,11 @@ export type TreemapSeries<T = any> = 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';
};
};

0 comments on commit f4ce403

Please sign in to comment.