Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(D3 plugin): improve treemap labels #530

Merged
merged 2 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
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';
};
};
Loading