Skip to content

Commit

Permalink
feat(D3 plugin): add the option to use html to display labels (#524)
Browse files Browse the repository at this point in the history
* feat(D3 plugin): add the option to use html to display labels
  • Loading branch information
kuzmadom authored Oct 4, 2024
1 parent 92f6c11 commit 43dcd43
Show file tree
Hide file tree
Showing 12 changed files with 218 additions and 41 deletions.
77 changes: 77 additions & 0 deletions src/plugins/d3/__stories__/pie/HtmlLabels.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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 {ExampleWrapper} from '../../examples/ExampleWrapper';
import {D3Plugin} from '../../index';

const PieWithHtmlLabels = () => {
const [loading, setLoading] = React.useState(true);

React.useEffect(() => {
settings.set({plugins: [D3Plugin]});
setLoading(false);
}, []);

if (loading) {
return <Loader />;
}

const getPieSegmentData = (name: string, color: string) => {
const labelStyle = `background: ${color};color: #fff;padding: 4px;border-radius: 4px;`;
return {
name: name,
value: Math.random() * 10,
label: `<span style="${labelStyle}">${name}</span>`,
color: color,
};
};

const getWidgetData = (): ChartKitWidgetData => ({
series: {
data: [
{
type: 'pie',
dataLabels: {
enabled: true,
html: true,
connectorPadding: 8,
},
data: [
getPieSegmentData('One', '#4fc4b7'),
getPieSegmentData('Two', '#59abc9'),
getPieSegmentData('Three', '#8ccce3'),
],
},
],
},
title: {text: 'Pie with html labels'},
legend: {enabled: true},
});

return (
<Container spaceRow={5}>
<Row space={1}>
<Col s={4}>
<ExampleWrapper>
<ChartKit type="d3" data={getWidgetData()} />
</ExampleWrapper>
</Col>
</Row>
</Container>
);
};

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

export default {
title: 'Plugins/D3/Pie',
component: PieWithHtmlLabels,
};
9 changes: 9 additions & 0 deletions src/plugins/d3/renderer/components/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type Props = {
export const Chart = (props: Props) => {
const {width, height, data} = props;
const svgRef = React.useRef<SVGSVGElement | null>(null);
const htmlLayerRef = React.useRef<HTMLDivElement | null>(null);
const dispatcher = React.useMemo(() => {
return getD3Dispatcher();
}, []);
Expand Down Expand Up @@ -99,6 +100,7 @@ export const Chart = (props: Props) => {
yAxis,
yScale,
split: preparedSplit,
htmlLayout: htmlLayerRef.current,
});

const clickHandler = data.chart?.events?.click;
Expand Down Expand Up @@ -228,6 +230,13 @@ export const Chart = (props: Props) => {
/>
)}
</svg>
<div
className={b('html-layer')}
ref={htmlLayerRef}
style={{
transform: `translate(${boundsOffsetLeft}px, ${boundsOffsetTop}px)`,
}}
/>
<Tooltip
dispatcher={dispatcher}
tooltip={tooltip}
Expand Down
8 changes: 8 additions & 0 deletions src/plugins/d3/renderer/components/styles.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
.chartkit-d3 {
position: absolute;

&__html-layer {
display: contents;

& > * {
transform: inherit;
}
}
}

.chartkit-d3-axis {
Expand Down
1 change: 1 addition & 0 deletions src/plugins/d3/renderer/hooks/useSeries/prepare-pie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function preparePieSeries(args: PreparePieSeriesArgs) {
connectorShape: get(series, 'dataLabels.connectorShape', 'polyline'),
distance: get(series, 'dataLabels.distance', 25),
connectorCurve: get(series, 'dataLabels.connectorCurve', 'basic'),
html: get(series, 'dataLabels.html', false),
},
label: dataItem.label,
value: dataItem.value,
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 @@ -161,6 +161,7 @@ export type PreparedPieSeries = {
connectorShape: ConnectorShape;
distance: number;
connectorCurve: ConnectorCurve;
html: boolean;
};
states: {
hover: {
Expand Down
3 changes: 3 additions & 0 deletions src/plugins/d3/renderer/hooks/useShapes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type Args = {
xScale?: ChartScale;
yScale?: ChartScale[];
split: PreparedSplit;
htmlLayout: HTMLElement | null;
};

export const useShapes = (args: Args) => {
Expand All @@ -76,6 +77,7 @@ export const useShapes = (args: Args) => {
yAxis,
yScale,
split,
htmlLayout,
} = args;

const shapesComponents = React.useMemo(() => {
Expand Down Expand Up @@ -229,6 +231,7 @@ export const useShapes = (args: Args) => {
dispatcher={dispatcher}
preparedData={preparedData}
seriesOptions={seriesOptions}
htmlLayout={htmlLayout}
/>,
);
shapesData.push(...preparedData);
Expand Down
50 changes: 35 additions & 15 deletions src/plugins/d3/renderer/hooks/useShapes/pie/index.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
import React from 'react';

import {arc, color, line as lineGenerator, select} from 'd3';
import {Portal} from '@gravity-ui/uikit';
import {arc, color, select} from 'd3';
import type {BaseType, Dispatch, PieArcDatum} from 'd3';
import get from 'lodash/get';

import {TooltipDataChunkPie} from '../../../../../../types';
import {block} from '../../../../../../utils/cn';
import {HtmlItem} from '../../../types';
import {setEllipsisForOverflowTexts} from '../../../utils';
import {PreparedSeriesOptions} from '../../useSeries/types';
import {PreparedLineData} from '../line/types';
import {setActiveState} from '../utils';

import {PieLabelData, PreparedPieData, SegmentData} from './types';
import {getCurveFactory} from './utils';

const b = block('d3-pie');

type PreparePieSeriesArgs = {
dispatcher: Dispatch<object>;
preparedData: PreparedPieData[];
seriesOptions: PreparedSeriesOptions;
htmlLayout: HTMLElement | null;
};

export function getHaloVisibility(d: PieArcDatum<SegmentData>) {
Expand All @@ -28,8 +30,15 @@ export function getHaloVisibility(d: PieArcDatum<SegmentData>) {
}

export function PieSeriesShapes(args: PreparePieSeriesArgs) {
const {dispatcher, preparedData, seriesOptions} = args;
const ref = React.useRef<SVGGElement>(null);
const {dispatcher, preparedData, seriesOptions, htmlLayout} = args;
const ref = React.useRef<SVGGElement | null>(null);

const htmlItems = React.useMemo(() => {
return preparedData.reduce<HtmlItem[]>((result, d) => {
result.push(...d.htmlElements);
return result;
}, []);
}, [preparedData]);

React.useEffect(() => {
if (!ref.current) {
Expand Down Expand Up @@ -94,6 +103,7 @@ export function PieSeriesShapes(args: PreparePieSeriesArgs) {
.attr('fill', (d) => d.data.color)
.attr('opacity', (d) => d.data.opacity);

// render Labels
shapesSelection
.selectAll<SVGTextElement, PieLabelData>('text')
.data((pieData) => pieData.labels)
Expand All @@ -113,19 +123,12 @@ export function PieSeriesShapes(args: PreparePieSeriesArgs) {
// Add the polyline between chart and labels
shapesSelection
.selectAll(connectorSelector)
.data((pieData) => pieData.labels)
.data((pieData) => pieData.connectors)
.enter()
.append('path')
.attr('class', b('connector'))
.attr('d', (d) => {
let line = lineGenerator();
const curveFactory = getCurveFactory(d.segment.pie);
if (curveFactory) {
line = line.curve(curveFactory);
}
return line(d.connector.points);
})
.attr('stroke', (d) => d.connector.color)
.attr('d', (d) => d.path)
.attr('stroke', (d) => d.color)
.attr('stroke-width', 1)
.attr('stroke-linejoin', 'round')
.attr('stroke-linecap', 'round')
Expand Down Expand Up @@ -223,5 +226,22 @@ export function PieSeriesShapes(args: PreparePieSeriesArgs) {
};
}, [dispatcher, preparedData, seriesOptions]);

return <g ref={ref} className={b()} style={{zIndex: 9}} />;
return (
<React.Fragment>
<g ref={ref} className={b()} style={{zIndex: 9}} />
{htmlLayout && (
<Portal container={htmlLayout}>
{htmlItems.map((item, index) => {
return (
<div
key={index}
dangerouslySetInnerHTML={{__html: item.content}}
style={{position: 'absolute', left: item.x, top: item.y}}
/>
);
})}
</Portal>
)}
</React.Fragment>
);
}
47 changes: 35 additions & 12 deletions src/plugins/d3/renderer/hooks/useShapes/pie/prepare-data.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {PieArcDatum, arc, group} from 'd3';
import {PieArcDatum, arc, group, line as lineGenerator} from 'd3';

import {PieSeries} from '../../../../../../types';
import {
Expand All @@ -10,7 +10,7 @@ import {
import {PreparedPieSeries} from '../../useSeries/types';

import {PieLabelData, PreparedPieData, SegmentData} from './types';
import {pieGenerator} from './utils';
import {getCurveFactory, pieGenerator} from './utils';

const FULL_CIRCLE = Math.PI * 2;

Expand Down Expand Up @@ -65,6 +65,7 @@ export function preparePieData(args: Args): PreparedPieData[] {
radius,
segments: [],
labels: [],
connectors: [],
borderColor,
borderWidth,
borderRadius,
Expand All @@ -75,6 +76,7 @@ export function preparePieData(args: Args): PreparedPieData[] {
opacity: series.states.hover.halo.opacity,
size: series.states.hover.halo.size,
},
htmlElements: [],
};

const segments = items.map<SegmentData>((item) => {
Expand All @@ -90,6 +92,12 @@ export function preparePieData(args: Args): PreparedPieData[] {
});
data.segments = pieGenerator(segments);

let line = lineGenerator();
const curveFactory = getCurveFactory(data);
if (curveFactory) {
line = line.curve(curveFactory);
}

if (dataLabels.enabled) {
const {style, connectorPadding, distance} = dataLabels;
const {maxHeight: labelHeight} = getLabelsSize({labels: ['Some Label'], style});
Expand Down Expand Up @@ -119,7 +127,8 @@ export function preparePieData(args: Args): PreparedPieData[] {
items.forEach((d, index) => {
const prevLabel = labels[labels.length - 1];
const text = String(d.data.label || d.data.value);
const labelSize = getLabelsSize({labels: [text], style});
const shouldUseHtml = dataLabels.html;
const labelSize = getLabelsSize({labels: [text], style, html: shouldUseHtml});
const labelWidth = labelSize.maxWidth;
const relatedSegment = data.segments[index];

Expand All @@ -129,11 +138,15 @@ export function preparePieData(args: Args): PreparedPieData[] {
startAngle: angle,
endAngle: angle,
});
x = Math.max(-boundsWidth / 2, x);
if (y < 0) {
y -= labelHeight;

y = y < 0 ? y - labelHeight : y;

if (shouldUseHtml) {
x = x < 0 ? x - labelWidth : x;
}

x = Math.max(-boundsWidth / 2, x);

return [x, y];
};

Expand Down Expand Up @@ -170,12 +183,9 @@ export function preparePieData(args: Args): PreparedPieData[] {
textAnchor: midAngle < Math.PI ? 'start' : 'end',
series: {id: d.id},
active: true,
connector: {
points: getConnectorPoints(midAngle),
color: relatedSegment.data.color,
},
segment: relatedSegment.data,
angle: midAngle,
html: shouldUseHtml,
};

let overlap = false;
Expand All @@ -199,7 +209,6 @@ export function preparePieData(args: Args): PreparedPieData[] {

label.x = newX;
label.y = newY;
label.connector.points = getConnectorPoints(newAngle);

if (!isLabelsOverlapping(prevLabel, label, dataLabels.padding)) {
shouldAdjustAngle = false;
Expand All @@ -222,7 +231,21 @@ export function preparePieData(args: Args): PreparedPieData[] {
}
}

labels.push(label);
if (shouldUseHtml) {
data.htmlElements.push({
x: boundsWidth / 2 + label.x,
y: boundsHeight / 2 + label.y,
content: label.text,
});
} else {
labels.push(label);
}

const connector = {
path: line(getConnectorPoints(midAngle)),
color: relatedSegment.data.color,
};
data.connectors.push(connector);
}
});

Expand Down
Loading

0 comments on commit 43dcd43

Please sign in to comment.