From 025701e12bf4df7b88a35c9440bbd913cc9c8205 Mon Sep 17 00:00:00 2001 From: Irina Kuzmina Date: Wed, 3 Jul 2024 15:04:20 +0300 Subject: [PATCH] feat(D3 plugin): multiline axis title (#499) * feat(D3 plugin): multiline axis title * fix zero value * return maxRowCount=3 for multiline story --- .../d3/__stories__/line/Line.stories.tsx | 8 -- .../__stories__/other/AxisTitle.stories.tsx | 116 ++++++++++++++++++ src/plugins/d3/examples/line/AxisTitle.tsx | 60 --------- src/plugins/d3/renderer/components/AxisX.tsx | 35 ++++-- src/plugins/d3/renderer/components/AxisY.tsx | 47 +++++-- src/plugins/d3/renderer/components/Chart.tsx | 3 +- .../d3/renderer/components/styles.scss | 4 + .../d3/renderer/constants/defaults/axis.ts | 1 + .../renderer/hooks/useChartOptions/types.ts | 1 + .../renderer/hooks/useChartOptions/x-axis.ts | 15 ++- .../renderer/hooks/useChartOptions/y-axis.ts | 16 ++- src/plugins/d3/renderer/utils/axis.ts | 25 ++++ src/plugins/d3/renderer/utils/text.ts | 85 +++++++++++-- src/types/widget-data/axis.ts | 3 + 14 files changed, 317 insertions(+), 102 deletions(-) create mode 100644 src/plugins/d3/__stories__/other/AxisTitle.stories.tsx delete mode 100644 src/plugins/d3/examples/line/AxisTitle.tsx diff --git a/src/plugins/d3/__stories__/line/Line.stories.tsx b/src/plugins/d3/__stories__/line/Line.stories.tsx index 06bb2880..12ddf2ee 100644 --- a/src/plugins/d3/__stories__/line/Line.stories.tsx +++ b/src/plugins/d3/__stories__/line/Line.stories.tsx @@ -5,7 +5,6 @@ import {StoryObj} from '@storybook/react'; import {D3Plugin} from '../..'; import {Loader} from '../../../../components/Loader/Loader'; import {settings} from '../../../../libs'; -import {AxisTitle} from '../../examples/line/AxisTitle'; import {LineWithLogarithmicAxis} from '../../examples/line/LogarithmicAxis'; const ChartStory = ({Chart}: {Chart: React.FC}) => { @@ -39,13 +38,6 @@ export const LogarithmicAxis: StoryObj = { }, }; -export const AxisTitleStory: StoryObj = { - name: 'Axis title', - args: { - Chart: AxisTitle, - }, -}; - export default { title: 'Plugins/D3/Line', component: ChartStory, diff --git a/src/plugins/d3/__stories__/other/AxisTitle.stories.tsx b/src/plugins/d3/__stories__/other/AxisTitle.stories.tsx new file mode 100644 index 00000000..432cbb46 --- /dev/null +++ b/src/plugins/d3/__stories__/other/AxisTitle.stories.tsx @@ -0,0 +1,116 @@ +import React from 'react'; + +import {Col, Container, Row, Text} 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 {ChartKitWidgetAxis, ChartKitWidgetData} from '../../../../types'; +import {ExampleWrapper} from '../../examples/ExampleWrapper'; +import {D3Plugin} from '../../index'; + +const AxisTitle = () => { + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + settings.set({plugins: [D3Plugin]}); + setLoading(false); + }, []); + + if (loading) { + return ; + } + + const longText = `One dollar and eighty-seven cents. That was all. + And sixty cents of it was in pennies. Pennies saved one and two at a time by bulldozing + the grocer and the vegetable man and the butcher until one's cheeks burned with the silent + imputation of parsimony that such close dealing implied. Three times Della counted it. + One dollar and eighty - seven cents.`; + const getWidgetData = (title: ChartKitWidgetAxis['title']): ChartKitWidgetData => ({ + yAxis: [ + { + title, + }, + ], + xAxis: { + title, + }, + series: { + data: [ + { + type: 'line', + name: 'Line series', + data: [ + {x: 1, y: 10}, + {x: 2, y: 100}, + ], + }, + ], + }, + }); + + return ( + + + Text alignment + + + + + + + + + + + + + + + + + + + + Long text + + + + + + + + + + + + + + + ); +}; + +export const AxisTitleStory: StoryObj = { + name: 'Axis title', +}; + +export default { + title: 'Plugins/D3/other', + component: AxisTitle, +}; diff --git a/src/plugins/d3/examples/line/AxisTitle.tsx b/src/plugins/d3/examples/line/AxisTitle.tsx deleted file mode 100644 index b72cad57..00000000 --- a/src/plugins/d3/examples/line/AxisTitle.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; - -import {Col, Container, Row, Text} from '@gravity-ui/uikit'; - -import {ChartKit} from '../../../../components/ChartKit'; -import type {ChartKitWidgetAxis, ChartKitWidgetData} from '../../../../types'; -import {ExampleWrapper} from '../ExampleWrapper'; - -export const AxisTitle = () => { - const getWidgetData = (title: ChartKitWidgetAxis['title']): ChartKitWidgetData => ({ - yAxis: [ - { - title, - }, - ], - xAxis: { - title, - }, - series: { - data: [ - { - type: 'line', - name: 'Line series', - data: [ - {x: 1, y: 10}, - {x: 2, y: 100}, - ], - }, - ], - }, - }); - - return ( - - - Axis title alignment - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/src/plugins/d3/renderer/components/AxisX.tsx b/src/plugins/d3/renderer/components/AxisX.tsx index 2e683e07..3b5229a8 100644 --- a/src/plugins/d3/renderer/components/AxisX.tsx +++ b/src/plugins/d3/renderer/components/AxisX.tsx @@ -7,11 +7,12 @@ import {block} from '../../../../utils/cn'; import type {ChartScale, PreparedAxis, PreparedSplit} from '../hooks'; import { formatAxisTickLabel, + getAxisTitleRows, getClosestPointsRange, getMaxTickCount, getScaleTicks, getTicksCount, - setEllipsisForOverflowText, + handleOverflowingText, } from '../utils'; import {axisBottom} from '../utils/axis-generators'; @@ -42,9 +43,15 @@ function getLabelFormatter({axis, scale}: {axis: PreparedAxis; scale: ChartScale }; } -export function getTitlePosition(axis: PreparedAxis, axisSize: number) { +export function getTitlePosition(args: {axis: PreparedAxis; width: number; rowCount: number}) { + const {axis, width, rowCount} = args; + if (rowCount < 1) { + return {x: 0, y: 0}; + } + let x; - const y = axis.title.height + axis.title.margin + axis.labels.height + axis.labels.margin; + const y = + axis.title.height / rowCount + axis.title.margin + axis.labels.height + axis.labels.margin; switch (axis.title.align) { case 'left': { @@ -52,11 +59,11 @@ export function getTitlePosition(axis: PreparedAxis, axisSize: number) { break; } case 'right': { - x = axisSize - axis.title.width / 2; + x = width - axis.title.width / 2; break; } case 'center': { - x = axisSize / 2; + x = width / 2; break; } } @@ -110,17 +117,27 @@ export const AxisX = React.memo(function AxisX(props: Props) { // add an axis header if necessary if (axis.title.text) { + const titleRows = getAxisTitleRows({axis, textMaxWidth: width}); svgElement .append('text') .attr('class', b('title')) - .attr('text-anchor', 'middle') .attr('transform', () => { - const {x, y} = getTitlePosition(axis, width); + const {x, y} = getTitlePosition({axis, width, rowCount: titleRows.length}); return `translate(${x}, ${y})`; }) .attr('font-size', axis.title.style.fontSize) - .text(axis.title.text) - .call(setEllipsisForOverflowText, width); + .attr('text-anchor', 'middle') + .selectAll('tspan') + .data(titleRows) + .join('tspan') + .attr('x', 0) + .attr('y', (d) => d.y) + .text((d) => d.text) + .each((_d, index, nodes) => { + if (index === axis.title.maxRowCount - 1) { + handleOverflowingText(nodes[index] as SVGTSpanElement, width); + } + }); } }, [axis, width, totalHeight, scale, split]); diff --git a/src/plugins/d3/renderer/components/AxisY.tsx b/src/plugins/d3/renderer/components/AxisY.tsx index fe91d3e7..b9c8fdf6 100644 --- a/src/plugins/d3/renderer/components/AxisY.tsx +++ b/src/plugins/d3/renderer/components/AxisY.tsx @@ -10,12 +10,14 @@ import { calculateSin, formatAxisTickLabel, getAxisHeight, + getAxisTitleRows, getClosestPointsRange, getScaleTicks, getTicksCount, + handleOverflowingText, parseTransformStyle, - setEllipsisForOverflowText, setEllipsisForOverflowTexts, + wrapText, } from '../utils'; const b = block('d3-axis'); @@ -93,13 +95,24 @@ function getAxisGenerator(args: { return axisGenerator; } -function getTitlePosition(axis: PreparedAxis, axisSize: number) { - const x = -(axis.title.margin + axis.labels.margin + axis.labels.width); +function getTitlePosition(args: {axis: PreparedAxis; axisHeight: number; rowCount: number}) { + const {axis, axisHeight, rowCount} = args; + if (rowCount < 1) { + return {x: 0, y: 0}; + } + + const x = -( + axis.title.height - + axis.title.height / rowCount + + axis.title.margin + + axis.labels.margin + + axis.labels.width + ); let y; switch (axis.title.align) { case 'left': { - y = axisSize - axis.title.width / 2; + y = axisHeight - axis.title.width / 2; break; } case 'right': { @@ -107,7 +120,7 @@ function getTitlePosition(axis: PreparedAxis, axisSize: number) { break; } case 'center': { - y = axisSize / 2; + y = axisHeight / 2; break; } } @@ -229,16 +242,26 @@ export const AxisY = (props: Props) => { .attr('text-anchor', 'middle') .attr('font-size', (d) => d.title.style.fontSize) .attr('transform', (d) => { - const {x, y} = getTitlePosition(d, height); + const titleRows = wrapText({ + text: d.title.text, + style: d.title.style, + width: height, + }); + const rowCount = Math.min(titleRows.length, d.title.maxRowCount); + const {x, y} = getTitlePosition({axis: d, axisHeight: height, rowCount}); const angle = d.position === 'left' ? -90 : 90; return `translate(${x}, ${y}) rotate(${angle})`; }) - .text((d) => d.title.text) - .each((_d, index, node) => { - return setEllipsisForOverflowText( - select(node[index]) as Selection, - height, - ); + .selectAll('tspan') + .data((d) => getAxisTitleRows({axis: d, textMaxWidth: height})) + .join('tspan') + .attr('x', 0) + .attr('y', (d) => d.y) + .text((d) => d.text) + .each((_d, index, nodes) => { + if (index === nodes.length - 1) { + handleOverflowingText(nodes[index] as SVGTSpanElement, height); + } }); }, [axes, width, height, scale, split]); diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index 4b2b1e2c..e35acfa2 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -50,8 +50,9 @@ export const Chart = (props: Props) => { getPreparedYAxis({ series: data.series.data, yAxis: data.yAxis, + height, }), - [data], + [data, height], ); const { diff --git a/src/plugins/d3/renderer/components/styles.scss b/src/plugins/d3/renderer/components/styles.scss index cf014ac6..87283677 100644 --- a/src/plugins/d3/renderer/components/styles.scss +++ b/src/plugins/d3/renderer/components/styles.scss @@ -21,6 +21,10 @@ alignment-baseline: after-edge; fill: var(--g-color-text-secondary); } + + &__title tspan { + alignment-baseline: after-edge; + } } .chartkit-d3-legend { diff --git a/src/plugins/d3/renderer/constants/defaults/axis.ts b/src/plugins/d3/renderer/constants/defaults/axis.ts index 1e961940..8cd0ae28 100644 --- a/src/plugins/d3/renderer/constants/defaults/axis.ts +++ b/src/plugins/d3/renderer/constants/defaults/axis.ts @@ -18,6 +18,7 @@ const axisTitleDefaults: AxisTitleDefaults = { fontSize: '14px', }, align: 'center', + maxRowCount: 1, }; export const xAxisTitleDefaults: AxisTitleDefaults = { diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts index 0332591d..b8a54ffc 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts @@ -35,6 +35,7 @@ export type PreparedAxis = Omit & { margin: number; style: BaseTextStyle; align: ChartKitWidgetAxisTitleAlignment; + maxRowCount: number; }; min?: number; grid: { diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts index c433dcc6..10767a02 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts @@ -18,6 +18,7 @@ import { getTicksCount, getXAxisItems, hasOverlappingLabels, + wrapText, } from '../../utils'; import {createXScale} from '../useAxisScales'; @@ -100,7 +101,16 @@ export const getPreparedXAxis = ({ ...xAxisTitleDefaults.style, ...get(xAxis, 'title.style'), }; - const titleSize = getLabelsSize({labels: [titleText], style: titleStyle}); + const titleMaxRowsCount = get(xAxis, 'title.maxRowCount', xAxisTitleDefaults.maxRowCount); + const estimatedTitleRows = wrapText({ + text: titleText, + style: titleStyle, + width, + }).slice(0, titleMaxRowsCount); + const titleSize = getLabelsSize({ + labels: [titleText], + style: titleStyle, + }); const labelsStyle = { fontSize: get(xAxis, 'labels.style.fontSize', DEFAULT_AXIS_LABEL_FONT_SIZE), }; @@ -127,9 +137,10 @@ export const getPreparedXAxis = ({ text: titleText, style: titleStyle, margin: get(xAxis, 'title.margin', xAxisTitleDefaults.margin), - height: titleSize.maxHeight, + height: titleSize.maxHeight * estimatedTitleRows.length, width: titleSize.maxWidth, align: get(xAxis, 'title.align', xAxisTitleDefaults.align), + maxRowCount: get(xAxis, 'title.maxRowCount', xAxisTitleDefaults.maxRowCount), }, min: getAxisMin(xAxis, series), maxPadding: get(xAxis, 'maxPadding', 0.01), diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts index c4254a88..fa585ce2 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts @@ -16,6 +16,7 @@ import { getLabelsSize, getScaleTicks, getWaterfallPointSubtotal, + wrapText, } from '../../utils'; import {createYScale} from '../useAxisScales'; import type {PreparedSeries, PreparedWaterfallSeries} from '../useSeries/types'; @@ -84,9 +85,11 @@ function getAxisMin(axis?: ChartKitWidgetYAxis, series?: ChartKitWidgetSeries[]) export const getPreparedYAxis = ({ series, yAxis, + height, }: { series: ChartKitWidgetSeries[]; yAxis: ChartKitWidgetYAxis[] | undefined; + height: number; }): PreparedAxis[] => { const axisByPlot: ChartKitWidgetYAxis[][] = []; const axisItems = yAxis || [{} as ChartKitWidgetYAxis]; @@ -109,6 +112,16 @@ export const getPreparedYAxis = ({ ...yAxisTitleDefaults.style, ...get(axisItem, 'title.style'), }; + const titleMaxRowsCount = get( + axisItem, + 'title.maxRowCount', + yAxisTitleDefaults.maxRowCount, + ); + const estimatedTitleRows = wrapText({ + text: titleText, + style: titleStyle, + width: height, + }).slice(0, titleMaxRowsCount); const titleSize = getLabelsSize({labels: [titleText], style: titleStyle}); const axisType = get(axisItem, 'type', DEFAULT_AXIS_TYPE); const preparedAxis: PreparedAxis = { @@ -138,8 +151,9 @@ export const getPreparedYAxis = ({ margin: get(axisItem, 'title.margin', yAxisTitleDefaults.margin), style: titleStyle, width: titleSize.maxWidth, - height: titleSize.maxHeight, + height: titleSize.maxHeight * estimatedTitleRows.length, align: get(axisItem, 'title.align', yAxisTitleDefaults.align), + maxRowCount: titleMaxRowsCount, }, min: getAxisMin(axisItem, series), maxPadding: get(axisItem, 'maxPadding', 0.05), diff --git a/src/plugins/d3/renderer/utils/axis.ts b/src/plugins/d3/renderer/utils/axis.ts index fa372213..cf52ec21 100644 --- a/src/plugins/d3/renderer/utils/axis.ts +++ b/src/plugins/d3/renderer/utils/axis.ts @@ -2,6 +2,9 @@ import type {AxisDomain, AxisScale, ScaleBand} from 'd3'; import type {PreparedAxis, PreparedSplit} from '../hooks'; +import type {TextRow} from './text'; +import {wrapText} from './text'; + export function getTicksCount({axis, range}: {axis: PreparedAxis; range: number}) { let ticksCount: number | undefined; @@ -75,3 +78,25 @@ export function getAxisHeight(args: {split: PreparedSplit; boundsHeight: number} return boundsHeight; } + +export function getAxisTitleRows(args: {axis: PreparedAxis; textMaxWidth: number}) { + const {axis, textMaxWidth} = args; + if (axis.title.maxRowCount < 1) { + return []; + } + + const textRows = wrapText({ + text: axis.title.text, + style: axis.title.style, + width: textMaxWidth, + }); + + return textRows.reduce((acc, row, index) => { + if (index < axis.title.maxRowCount) { + acc.push(row); + } else { + acc[axis.title.maxRowCount - 1].text += row.text; + } + return acc; + }, []); +} diff --git a/src/plugins/d3/renderer/utils/text.ts b/src/plugins/d3/renderer/utils/text.ts index 6a203e5e..53e3951b 100644 --- a/src/plugins/d3/renderer/utils/text.ts +++ b/src/plugins/d3/renderer/utils/text.ts @@ -3,21 +3,44 @@ import {select} from 'd3-selection'; import {BaseTextStyle} from '../../../../types'; +export function handleOverflowingText(tSpan: SVGTSpanElement | null, maxWidth: number) { + if (!tSpan) { + return; + } + + const svg = tSpan.closest('svg'); + if (!svg) { + return; + } + + const textNode = tSpan.closest('text'); + const angle = + Array.from(textNode?.transform.baseVal || []).find((item) => item.angle)?.angle || 0; + + const revertRotation = svg.createSVGTransform(); + revertRotation.setRotate(-angle, 0, 0); + textNode?.transform.baseVal.appendItem(revertRotation); + + let text = tSpan.textContent || ''; + let textLength = tSpan.getBoundingClientRect()?.width || 0; + + while (textLength > maxWidth && text.length > 1) { + text = text.slice(0, -1); + tSpan.textContent = text + '…'; + textLength = tSpan.getBoundingClientRect()?.width || 0; + } + + textNode?.transform.baseVal.removeItem(textNode?.transform.baseVal.length - 1); +} + export function setEllipsisForOverflowText( selection: Selection, maxWidth: number, ) { - let text = selection.text(); + const text = selection.text(); selection.text(null).append('title').text(text); const tSpan = selection.append('tspan').text(text).style('alignment-baseline', 'inherit'); - - let textLength = tSpan.node()?.getBoundingClientRect()?.width || 0; - - while (textLength > maxWidth && text.length > 1) { - text = text.slice(0, -1); - tSpan.text(text + '…'); - textLength = tSpan.node()?.getBoundingClientRect()?.width || 0; - } + handleOverflowingText(tSpan.node(), maxWidth); } export function setEllipsisForOverflowTexts( @@ -119,3 +142,47 @@ export function getLabelsSize({ return {maxHeight: height, maxWidth: width}; } + +export type TextRow = {text: string; y: number}; + +export function wrapText(args: {text: string; style?: BaseTextStyle; width: number}): TextRow[] { + const {text, style, width} = args; + + const height = getLabelsSize({ + labels: [text], + style: style, + }).maxHeight; + // @ts-ignore + const segmenter = new Intl.Segmenter([], {granularity: 'word'}); + const segments = Array.from(segmenter.segment(text)); + + return segments.reduce((acc, s) => { + const item = s as {isWordLike: boolean; segment: string}; + if (!acc.length) { + acc.push({ + text: '', + y: acc.length * height, + }); + } + + let lastRow = acc[acc.length - 1]; + + if ( + item.isWordLike && + getLabelsSize({ + labels: [lastRow.text + item.segment], + style, + }).maxWidth > width + ) { + lastRow = { + text: '', + y: acc.length * height, + }; + acc.push(lastRow); + } + + lastRow.text += item.segment; + + return acc; + }, []); +} diff --git a/src/types/widget-data/axis.ts b/src/types/widget-data/axis.ts index d0165cd1..66103034 100644 --- a/src/types/widget-data/axis.ts +++ b/src/types/widget-data/axis.ts @@ -51,6 +51,9 @@ export type ChartKitWidgetAxis = { margin?: number; /** Alignment of the title. */ align?: ChartKitWidgetAxisTitleAlignment; + /** Allows limiting of the contents of a title block to the specified number of lines. + * Defaults to 1. */ + maxRowCount?: number; }; /** The minimum value of the axis. If undefined the min value is automatically calculate. */ min?: number;