From 83b92b99faa28c03e9744934addf47d11abcbd50 Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Thu, 9 Jan 2025 16:04:15 +0300 Subject: [PATCH 1/2] fix: make labels take up less pie chart space --- src/hooks/useShapes/area/prepare-data.ts | 4 + src/hooks/useShapes/bar-x/prepare-data.ts | 1 + src/hooks/useShapes/bar-y/prepare-data.ts | 1 + src/hooks/useShapes/line/prepare-data.ts | 1 + src/hooks/useShapes/pie/index.tsx | 4 +- src/hooks/useShapes/pie/prepare-data.ts | 322 ++++++++++++-------- src/hooks/useShapes/pie/types.ts | 2 +- src/hooks/useShapes/treemap/prepare-data.ts | 1 + src/types/chart-ui.ts | 1 + 9 files changed, 204 insertions(+), 133 deletions(-) diff --git a/src/hooks/useShapes/area/prepare-data.ts b/src/hooks/useShapes/area/prepare-data.ts index 91ec00b..1026340 100644 --- a/src/hooks/useShapes/area/prepare-data.ts +++ b/src/hooks/useShapes/area/prepare-data.ts @@ -137,10 +137,14 @@ export const prepareAreaData = (args: { const labelItems = points.map((p) => getLabelData(p, s, xMax)); if (s.dataLabels.html) { const htmlLabels = labelItems.map((l) => { + const style = l.style ?? s.dataLabels.style; + const labelSize = getLabelsSize({labels: [l.text], style, html: true}); + return { x: l.x - l.size.width / 2, y: l.y, content: l.text, + size: {width: labelSize.maxWidth, height: labelSize.maxHeight}, }; }); htmlElements.push(...htmlLabels); diff --git a/src/hooks/useShapes/bar-x/prepare-data.ts b/src/hooks/useShapes/bar-x/prepare-data.ts index 3beb0ac..0d363c2 100644 --- a/src/hooks/useShapes/bar-x/prepare-data.ts +++ b/src/hooks/useShapes/bar-x/prepare-data.ts @@ -179,6 +179,7 @@ export const prepareBarXData = (args: { x: label.x, y: label.y, content: label.text, + size: label.size, }); } else { barData.label = getLabelData(barData); diff --git a/src/hooks/useShapes/bar-y/prepare-data.ts b/src/hooks/useShapes/bar-y/prepare-data.ts index d477f32..d6efbcf 100644 --- a/src/hooks/useShapes/bar-y/prepare-data.ts +++ b/src/hooks/useShapes/bar-y/prepare-data.ts @@ -93,6 +93,7 @@ function setLabel(prepared: PreparedBarYData) { x, y: y - height / 2, content, + size: {width, height}, }); } else { prepared.label = { diff --git a/src/hooks/useShapes/line/prepare-data.ts b/src/hooks/useShapes/line/prepare-data.ts index 777b95c..86c7d60 100644 --- a/src/hooks/useShapes/line/prepare-data.ts +++ b/src/hooks/useShapes/line/prepare-data.ts @@ -45,6 +45,7 @@ function getHtmlLabel(point: PointData, series: PreparedLineSeries, xMax: number x: Math.min(xMax - size.maxWidth, Math.max(0, point.x)), y: Math.max(0, point.y - series.dataLabels.padding - size.maxHeight), content, + size: {width: size.maxWidth, height: size.maxHeight}, }; } diff --git a/src/hooks/useShapes/pie/index.tsx b/src/hooks/useShapes/pie/index.tsx index eeba300..82d1e10 100644 --- a/src/hooks/useShapes/pie/index.tsx +++ b/src/hooks/useShapes/pie/index.tsx @@ -217,10 +217,12 @@ export function PieSeriesShapes(args: PreparePieSeriesArgs) { }; }, [dispatcher, preparedData, seriesOptions]); + const htmlElements = preparedData.map((d) => d.htmlLabels).flat(); + return ( - + ); } diff --git a/src/hooks/useShapes/pie/prepare-data.ts b/src/hooks/useShapes/pie/prepare-data.ts index c334652..b76f737 100644 --- a/src/hooks/useShapes/pie/prepare-data.ts +++ b/src/hooks/useShapes/pie/prepare-data.ts @@ -1,7 +1,7 @@ import type {PieArcDatum} from 'd3'; import {arc, group, line as lineGenerator} from 'd3'; -import type {PieSeries} from '../../../types'; +import type {HtmlItem, PieSeries} from '../../../types'; import { calculateNumericProperty, getLabelsSize, @@ -10,7 +10,7 @@ import { } from '../../../utils'; import type {PreparedPieSeries} from '../../useSeries/types'; -import type {PieLabelData, PreparedPieData, SegmentData} from './types'; +import type {PieConnectorData, PieLabelData, PreparedPieData, SegmentData} from './types'; import {getCurveFactory, pieGenerator} from './utils'; const FULL_CIRCLE = Math.PI * 2; @@ -43,9 +43,9 @@ const getCenter = ( export function preparePieData(args: Args): PreparedPieData[] { const {series: preparedSeries, boundsWidth, boundsHeight} = args; const maxRadius = Math.min(boundsWidth, boundsHeight) / 2; - const groupedPieSeries = group(preparedSeries, (pieSeries) => pieSeries.stackId); - return Array.from(groupedPieSeries).map(([stackId, items]) => { + + const prepareItem = (stackId: string, items: PreparedPieSeries[]) => { const series = items[0]; const { center, @@ -66,6 +66,7 @@ export function preparePieData(args: Args): PreparedPieData[] { radius, segments: [], labels: [], + htmlLabels: [], connectors: [], borderColor, borderWidth, @@ -77,7 +78,6 @@ export function preparePieData(args: Args): PreparedPieData[] { opacity: series.states.hover.halo.opacity, size: series.states.hover.halo.size, }, - htmlElements: [], }; const segments = items.map((item) => { @@ -93,165 +93,225 @@ 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}); - const minSegmentRadius = maxRadius - connectorPadding - distance - labelHeight; + const minSegmentRadius = maxRadius - distance - connectorPadding - labelHeight; if (data.radius > minSegmentRadius) { data.radius = minSegmentRadius; data.innerRadius = calculateNumericProperty({value: seriesInnerRadius, base: data.radius}) ?? 0; } - const connectorStartPointGenerator = arc>() - .innerRadius(data.radius) - .outerRadius(data.radius); - const connectorMidPointRadius = data.radius + distance / 2; - const connectorMidPointGenerator = arc>() - .innerRadius(connectorMidPointRadius) - .outerRadius(connectorMidPointRadius); - const connectorArcRadius = data.radius + distance; - const connectorEndPointGenerator = arc>() - .innerRadius(connectorArcRadius) - .outerRadius(connectorArcRadius); - const labelArcRadius = connectorArcRadius + connectorPadding; - const labelArcGenerator = arc>() - .innerRadius(labelArcRadius) - .outerRadius(labelArcRadius); - - const labels: PieLabelData[] = []; - items.forEach((d, index) => { - const prevLabel = labels[labels.length - 1]; - const text = String(d.data.label || d.data.value); - const shouldUseHtml = dataLabels.html; - const labelSize = getLabelsSize({labels: [text], style, html: shouldUseHtml}); - const labelWidth = labelSize.maxWidth; - const relatedSegment = data.segments[index]; - - const getLabelPosition = (angle: number) => { - let [x, y] = labelArcGenerator.centroid({ - ...relatedSegment, - startAngle: angle, - endAngle: angle, - }); + } - y = y < 0 ? y - labelHeight : y; + return data; + }; - if (shouldUseHtml) { - x = x < 0 ? x - labelWidth : x; - } + const prepareLabels = (args: {data: PreparedPieData; series: PreparedPieSeries[]}) => { + const {data, series} = args; + const {dataLabels} = series[0]; - x = Math.max(-boundsWidth / 2, x); + const labels: PieLabelData[] = []; + const htmlLabels: HtmlItem[] = []; + const connectors: PieConnectorData[] = []; - return [x, y]; - }; + if (!dataLabels.enabled) { + return {labels, htmlLabels, connectors}; + } - const getConnectorPoints = (angle: number) => { - const connectorStartPoint = - connectorStartPointGenerator.centroid(relatedSegment); - const connectorEndPoint = connectorEndPointGenerator.centroid({ - ...relatedSegment, - startAngle: angle, - endAngle: angle, - }); + let line = lineGenerator(); + const curveFactory = getCurveFactory(data); + if (curveFactory) { + line = line.curve(curveFactory); + } - if (dataLabels.connectorShape === 'straight-line') { - return [connectorStartPoint, connectorEndPoint]; - } + const {style, connectorPadding, distance} = dataLabels; + const {maxHeight: labelHeight} = getLabelsSize({labels: ['Some Label'], style}); + const connectorStartPointGenerator = arc>() + .innerRadius(data.radius) + .outerRadius(data.radius); + const connectorMidPointRadius = data.radius + distance / 2; + const connectorMidPointGenerator = arc>() + .innerRadius(connectorMidPointRadius) + .outerRadius(connectorMidPointRadius); + const connectorArcRadius = data.radius + distance; + const connectorEndPointGenerator = arc>() + .innerRadius(connectorArcRadius) + .outerRadius(connectorArcRadius); + const labelArcRadius = connectorArcRadius + connectorPadding; + const labelArcGenerator = arc>() + .innerRadius(labelArcRadius) + .outerRadius(labelArcRadius); + + series.forEach((d, index) => { + const prevLabel = labels[labels.length - 1]; + const text = String(d.data.label || d.data.value); + const shouldUseHtml = dataLabels.html; + const labelSize = getLabelsSize({labels: [text], style, html: shouldUseHtml}); + const labelWidth = labelSize.maxWidth; + const relatedSegment = data.segments[index]; + + const getLabelPosition = (angle: number) => { + let [x, y] = labelArcGenerator.centroid({ + ...relatedSegment, + startAngle: angle, + endAngle: angle, + }); + + if (shouldUseHtml) { + x = x < 0 ? x - labelWidth : x; + y = y - labelSize.maxHeight; + } else { + y = y < 0 ? y - labelHeight : y; + } - const connectorMidPoint = connectorMidPointGenerator.centroid(relatedSegment); - return [connectorStartPoint, connectorMidPoint, connectorEndPoint]; - }; + x = Math.max(-boundsWidth / 2, x); - const midAngle = Math.max( - prevLabel?.angle || 0, - relatedSegment.startAngle + - (relatedSegment.endAngle - relatedSegment.startAngle) / 2, - ); - const [x, y] = getLabelPosition(midAngle); - const label: PieLabelData = { - text, - x, - y, - style, - size: {width: labelWidth, height: labelHeight}, - maxWidth: labelWidth, - textAnchor: midAngle < Math.PI ? 'start' : 'end', - series: {id: d.id}, - active: true, - segment: relatedSegment.data, - angle: midAngle, - }; + return [x, y]; + }; - let overlap = false; - if (prevLabel) { - overlap = isLabelsOverlapping(prevLabel, label, dataLabels.padding); + const getConnectorPoints = (angle: number) => { + const connectorStartPoint = connectorStartPointGenerator.centroid(relatedSegment); + const connectorEndPoint = connectorEndPointGenerator.centroid({ + ...relatedSegment, + startAngle: angle, + endAngle: angle, + }); - if (overlap) { - let shouldAdjustAngle = true; + if (dataLabels.connectorShape === 'straight-line') { + return [connectorStartPoint, connectorEndPoint]; + } - const step = Math.PI / 180; - while (shouldAdjustAngle) { - const newAngle = label.angle + step; - if ( - newAngle > FULL_CIRCLE && - newAngle % FULL_CIRCLE > labels[0].angle - ) { - shouldAdjustAngle = false; - } else { - label.angle = newAngle; - const [newX, newY] = getLabelPosition(newAngle); + const connectorMidPoint = connectorMidPointGenerator.centroid(relatedSegment); + return [connectorStartPoint, connectorMidPoint, connectorEndPoint]; + }; + + const midAngle = Math.max( + prevLabel?.angle || 0, + relatedSegment.startAngle + + (relatedSegment.endAngle - relatedSegment.startAngle) / 2, + ); + const [x, y] = getLabelPosition(midAngle); + const label: PieLabelData = { + text, + x, + y, + style, + size: {width: labelWidth, height: labelHeight}, + maxWidth: labelWidth, + textAnchor: midAngle < Math.PI ? 'start' : 'end', + series: {id: d.id}, + active: true, + segment: relatedSegment.data, + angle: midAngle, + }; + + let overlap = false; + if (prevLabel) { + overlap = isLabelsOverlapping(prevLabel, label, dataLabels.padding); + + if (overlap) { + let shouldAdjustAngle = true; - label.x = newX; - label.y = newY; + const step = Math.PI / 180; + while (shouldAdjustAngle) { + const newAngle = label.angle + step; + if (newAngle > FULL_CIRCLE && newAngle % FULL_CIRCLE > labels[0].angle) { + shouldAdjustAngle = false; + } else { + label.angle = newAngle; + const [newX, newY] = getLabelPosition(newAngle); - if (!isLabelsOverlapping(prevLabel, label, dataLabels.padding)) { - shouldAdjustAngle = false; - overlap = false; - } + label.x = newX; + label.y = newY; + + if (!isLabelsOverlapping(prevLabel, label, dataLabels.padding)) { + shouldAdjustAngle = false; + overlap = false; } } } } + } - if (dataLabels.allowOverlap || !overlap) { - const left = getLeftPosition(label); + if (dataLabels.allowOverlap || !overlap) { + const left = getLeftPosition(label); - if (Math.abs(left) > boundsWidth / 2) { - label.maxWidth = label.size.width - (Math.abs(left) - boundsWidth / 2); - } else { - const right = left + label.size.width; - if (right > boundsWidth / 2) { - label.maxWidth = label.size.width - (right - boundsWidth / 2); - } - } - - if (shouldUseHtml) { - data.htmlElements.push({ - x: boundsWidth / 2 + label.x, - y: boundsHeight / 2 + label.y, - content: label.text, - }); - } else { - labels.push(label); + if (Math.abs(left) > boundsWidth / 2) { + label.maxWidth = label.size.width - (Math.abs(left) - boundsWidth / 2); + } else { + const right = left + label.size.width; + if (right > boundsWidth / 2) { + label.maxWidth = label.size.width - (right - boundsWidth / 2); } + } - const connector = { - path: line(getConnectorPoints(midAngle)), - color: relatedSegment.data.color, - }; - data.connectors.push(connector); + if (shouldUseHtml) { + htmlLabels.push({ + x: boundsWidth / 2 + label.x, + y: boundsHeight / 2 + label.y, + content: label.text, + size: label.size, + }); + } else { + labels.push(label); } - }); - data.labels = labels; + const connector = { + path: line(getConnectorPoints(midAngle)), + color: relatedSegment.data.color, + }; + connectors.push(connector); + } + }); + + return { + labels, + htmlLabels, + connectors, + }; + }; + + return Array.from(groupedPieSeries).map(([stackId, items]) => { + const data = prepareItem(stackId, items); + const preparedLabels = prepareLabels({ + data, + series: items, + }); + const allPreparedLabels = [...preparedLabels.labels, ...preparedLabels.htmlLabels]; + + const top = Math.min( + data.center[1] - data.radius, + ...allPreparedLabels.map((l) => l.y + data.center[1]), + ); + const bottom = Math.max( + data.center[1] + data.radius, + ...allPreparedLabels.map((l) => data.center[1] + l.y + l.size.height), + ); + + const topAdjustment = Math.floor(top - data.halo.size); + if (topAdjustment > 0) { + // should adjust top position and height + data.radius += topAdjustment / 2; + data.center[1] -= topAdjustment / 2; + } + + const bottomAdjustment = Math.floor(boundsHeight - bottom - data.halo.size); + if (bottomAdjustment > 0) { + // should adjust position and radius + data.radius += bottomAdjustment / 2; + data.center[1] += bottomAdjustment / 2; } + const {labels, htmlLabels, connectors} = prepareLabels({ + data, + series: items, + }); + + data.labels = labels; + data.htmlLabels = htmlLabels; + data.connectors = connectors; + return data; }); } diff --git a/src/hooks/useShapes/pie/types.ts b/src/hooks/useShapes/pie/types.ts index 8a772b9..0a2bc2e 100644 --- a/src/hooks/useShapes/pie/types.ts +++ b/src/hooks/useShapes/pie/types.ts @@ -42,5 +42,5 @@ export type PreparedPieData = { opacity: number; size: number; }; - htmlElements: HtmlItem[]; + htmlLabels: HtmlItem[]; }; diff --git a/src/hooks/useShapes/treemap/prepare-data.ts b/src/hooks/useShapes/treemap/prepare-data.ts index 7b14abe..85739a2 100644 --- a/src/hooks/useShapes/treemap/prepare-data.ts +++ b/src/hooks/useShapes/treemap/prepare-data.ts @@ -60,6 +60,7 @@ function getLabels(args: { content: text, x, y, + size: {width, height: lineHeight}, } : { text, diff --git a/src/types/chart-ui.ts b/src/types/chart-ui.ts index 6815417..0298bd6 100644 --- a/src/types/chart-ui.ts +++ b/src/types/chart-ui.ts @@ -15,6 +15,7 @@ export type HtmlItem = { x: number; y: number; content: string; + size: {width: number; height: number}; }; export type ShapeDataWithHtmlItems = { From 088251735c77b4c5cc4946dc6602b25b3dae336a Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Thu, 9 Jan 2025 17:44:40 +0300 Subject: [PATCH 2/2] fix: args --- src/hooks/useShapes/pie/prepare-data.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/hooks/useShapes/pie/prepare-data.ts b/src/hooks/useShapes/pie/prepare-data.ts index b76f737..46ace65 100644 --- a/src/hooks/useShapes/pie/prepare-data.ts +++ b/src/hooks/useShapes/pie/prepare-data.ts @@ -107,8 +107,11 @@ export function preparePieData(args: Args): PreparedPieData[] { return data; }; - const prepareLabels = (args: {data: PreparedPieData; series: PreparedPieSeries[]}) => { - const {data, series} = args; + const prepareLabels = (prepareLabelsArgs: { + data: PreparedPieData; + series: PreparedPieSeries[]; + }) => { + const {data, series} = prepareLabelsArgs; const {dataLabels} = series[0]; const labels: PieLabelData[] = [];