From 141505c69e93c52f3da1af7fadd9ef00e72e1eca Mon Sep 17 00:00:00 2001 From: Irina Kuzmina Date: Thu, 28 Sep 2023 14:36:44 +0300 Subject: [PATCH] feat(D3 plugin): rotation and maxWidth options for Y axis labels (#318) * feat(D3 plugin): rotation and maxWidth options for Y axis labels * add comment + fixme for yAxis ticks generation * remove maxWidth from external types --- .../__stories__/bar-x/Playground.stories.tsx | 13 +++- src/plugins/d3/renderer/components/AxisX.tsx | 2 + src/plugins/d3/renderer/components/AxisY.tsx | 74 ++++++++++++++----- .../d3/renderer/components/styles.scss | 1 + .../d3/renderer/constants/defaults/axis.ts | 1 + .../renderer/hooks/useChartOptions/types.ts | 2 + .../renderer/hooks/useChartOptions/x-axis.ts | 14 +++- .../renderer/hooks/useChartOptions/y-axis.ts | 23 ++---- .../renderer/utils/axis-generators/bottom.ts | 28 ++++--- src/plugins/d3/renderer/utils/index.ts | 13 ++-- src/plugins/d3/renderer/utils/math.ts | 10 +++ src/plugins/d3/renderer/utils/text.ts | 15 ++-- 12 files changed, 130 insertions(+), 66 deletions(-) diff --git a/src/plugins/d3/__stories__/bar-x/Playground.stories.tsx b/src/plugins/d3/__stories__/bar-x/Playground.stories.tsx index 660e89f2..2289f5b2 100644 --- a/src/plugins/d3/__stories__/bar-x/Playground.stories.tsx +++ b/src/plugins/d3/__stories__/bar-x/Playground.stories.tsx @@ -36,7 +36,18 @@ function prepareData(): ChartKitWidgetData { rotation: 30, }, }, - yAxis: [{title: {text: 'Number of games released'}}], + yAxis: [ + { + title: {text: 'Number of games released'}, + labels: { + enabled: true, + rotation: -90, + }, + ticks: { + pixelInterval: 120, + }, + }, + ], }; } diff --git a/src/plugins/d3/renderer/components/AxisX.tsx b/src/plugins/d3/renderer/components/AxisX.tsx index c804e215..37c01d45 100644 --- a/src/plugins/d3/renderer/components/AxisX.tsx +++ b/src/plugins/d3/renderer/components/AxisX.tsx @@ -57,6 +57,8 @@ export const AxisX = React.memo(({axis, width, height, scale}: Props) => { labelsPaddings: axis.labels.padding, labelsMargin: axis.labels.margin, labelsStyle: axis.labels.style, + labelsMaxWidth: axis.labels.maxWidth, + labelsLineHeight: axis.labels.lineHeight, count: getTicksCount({axis, range: width}), maxTickCount: getMaxTickCount({axis, width}), rotation: axis.labels.rotation, diff --git a/src/plugins/d3/renderer/components/AxisY.tsx b/src/plugins/d3/renderer/components/AxisY.tsx index 4899d133..e7a80155 100644 --- a/src/plugins/d3/renderer/components/AxisY.tsx +++ b/src/plugins/d3/renderer/components/AxisY.tsx @@ -13,10 +13,11 @@ import { setEllipsisForOverflowTexts, getTicksCount, getScaleTicks, + calculateSin, + calculateCos, } from '../utils'; const b = block('d3-axis'); -const MAX_WIDTH = 80; type Props = { axises: PreparedAxis[]; @@ -25,6 +26,31 @@ type Props = { scale: ChartScale; }; +function transformLabel(node: Element, axis: PreparedAxis) { + let topOffset = axis.labels.lineHeight / 2; + let leftOffset = -axis.labels.margin; + if (axis.labels.rotation) { + if (axis.labels.rotation > 0) { + leftOffset -= axis.labels.lineHeight * calculateSin(axis.labels.rotation); + topOffset = axis.labels.lineHeight * calculateCos(axis.labels.rotation); + + if (axis.labels.rotation % 360 === 90) { + topOffset = (node?.getBoundingClientRect().width || 0) / 2; + } + } else { + topOffset = 0; + + if (axis.labels.rotation % 360 === -90) { + topOffset = -(node?.getBoundingClientRect().width || 0) / 2; + } + } + + return `translate(${leftOffset}px, ${topOffset}px) rotate(${axis.labels.rotation}deg)`; + } + + return `translate(${leftOffset}px, ${topOffset}px)`; +} + export const AxisY = ({axises, width, height, scale}: Props) => { const ref = React.useRef(null); @@ -68,10 +94,20 @@ export const AxisY = ({axises, width, height, scale}: Props) => { if (axis.labels.enabled) { const tickTexts = svgElement .selectAll('.tick text') + // The offset must be applied before the labels are rotated. + // Therefore, we reset the values and make an offset in transform attribute. + // FIXME: give up axisLeft(d3) and switch to our own generation method + .attr('x', null) + .attr('dy', null) .style('font-size', axis.labels.style.fontSize) - .style('transform', 'translateY(-1px)'); - - tickTexts.call(setEllipsisForOverflowTexts, MAX_WIDTH); + .style('transform', function () { + return transformLabel(this, axis); + }); + const textMaxWidth = + !axis.labels.rotation || Math.abs(axis.labels.rotation) % 360 !== 90 + ? axis.labels.maxWidth + : (height - axis.labels.padding * (tickTexts.size() - 1)) / tickTexts.size(); + tickTexts.call(setEllipsisForOverflowTexts, textMaxWidth); } const transformStyle = svgElement.select('.tick').attr('transform'); @@ -84,20 +120,22 @@ export const AxisY = ({axises, width, height, scale}: Props) => { // remove overlapping ticks // Note: this method do not prepared for rotated labels - let elementY = 0; - svgElement - .selectAll('.tick') - .filter(function (_d, index) { - const node = this as unknown as Element; - const r = node.getBoundingClientRect(); - - if (r.bottom > elementY && index !== 0) { - return true; - } - elementY = r.top - axis.labels.padding; - return false; - }) - .remove(); + if (!axis.labels.rotation) { + let elementY = 0; + svgElement + .selectAll('.tick') + .filter(function (_d, index) { + const node = this as unknown as Element; + const r = node.getBoundingClientRect(); + + if (r.bottom > elementY && index !== 0) { + return true; + } + elementY = r.top - axis.labels.padding; + return false; + }) + .remove(); + } if (axis.title.text) { const textY = axis.title.margin + axis.labels.margin + axis.labels.width; diff --git a/src/plugins/d3/renderer/components/styles.scss b/src/plugins/d3/renderer/components/styles.scss index dd37ae6a..68d40194 100644 --- a/src/plugins/d3/renderer/components/styles.scss +++ b/src/plugins/d3/renderer/components/styles.scss @@ -5,6 +5,7 @@ & .tick text { color: var(--g-color-text-secondary); + alignment-baseline: after-edge; } & .tick line { diff --git a/src/plugins/d3/renderer/constants/defaults/axis.ts b/src/plugins/d3/renderer/constants/defaults/axis.ts index 96e4fcd6..d4de29c0 100644 --- a/src/plugins/d3/renderer/constants/defaults/axis.ts +++ b/src/plugins/d3/renderer/constants/defaults/axis.ts @@ -2,6 +2,7 @@ export const axisLabelsDefaults = { margin: 10, padding: 10, fontSize: 11, + maxWidth: 80, }; const axisTitleDefaults = { diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts index dd32ebbf..48600f1f 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts @@ -16,6 +16,8 @@ type PreparedAxisLabels = Omit< rotation: number; height: number; width: number; + lineHeight: number; + maxWidth: number; }; export type PreparedChart = { diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts index e3c6f6a0..2e6ff34c 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts @@ -8,6 +8,7 @@ import { } from '../../constants'; import type {PreparedAxis} from './types'; import { + calculateCos, formatAxisTickLabel, getClosestPointsRange, getHorisontalSvgTextHeight, @@ -54,7 +55,6 @@ function getLabelSettings({ const defaultRotation = overlapping && autoRotation ? -45 : 0; const rotation = axis.labels.rotation || defaultRotation; - const labelsHeight = rotation ? getLabelsMaxHeight({ labels, @@ -64,9 +64,10 @@ function getLabelSettings({ }, rotation, }) - : getHorisontalSvgTextHeight({text: 'Tmp', style: axis.labels.style}); + : axis.labels.lineHeight; + const maxHeight = rotation ? calculateCos(rotation) * axis.labels.maxWidth : labelsHeight; - return {height: labelsHeight, rotation}; + return {height: Math.min(maxHeight, labelsHeight), rotation}; } export const getPreparedXAxis = ({ @@ -82,6 +83,9 @@ export const getPreparedXAxis = ({ const titleStyle: BaseTextStyle = { fontSize: get(xAxis, 'title.style.fontSize', xAxisTitleDefaults.fontSize), }; + const labelsStyle = { + fontSize: get(xAxis, 'labels.style.fontSize', DEFAULT_AXIS_LABEL_FONT_SIZE), + }; const preparedXAxis: PreparedAxis = { type: get(xAxis, 'type', 'linear'), @@ -92,9 +96,11 @@ export const getPreparedXAxis = ({ dateFormat: get(xAxis, 'labels.dateFormat'), numberFormat: get(xAxis, 'labels.numberFormat'), rotation: get(xAxis, 'labels.rotation', 0), - style: {fontSize: get(xAxis, 'labels.style.fontSize', DEFAULT_AXIS_LABEL_FONT_SIZE)}, + style: labelsStyle, width: 0, height: 0, + lineHeight: getHorisontalSvgTextHeight({text: 'Tmp', style: labelsStyle}), + maxWidth: get(xAxis, 'labels.maxWidth', axisLabelsDefaults.maxWidth), }, lineColor: get(xAxis, 'lineColor'), categories: get(xAxis, 'categories'), diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts index 911ba73d..5d593875 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts @@ -1,12 +1,7 @@ import type {AxisDomain, AxisScale} from 'd3'; import get from 'lodash/get'; -import type { - BaseTextStyle, - ChartKitWidgetData, - ChartKitWidgetSeries, -} from '../../../../../types/widget-data'; - +import type {BaseTextStyle, ChartKitWidgetData, ChartKitWidgetSeries} from '../../../../../types'; import { axisLabelsDefaults, DEFAULT_AXIS_LABEL_FONT_SIZE, @@ -50,18 +45,10 @@ const getAxisLabelMaxWidth = (args: {axis: PreparedAxis; series: ChartKitWidgetS 'font-size': axis.labels.style.fontSize, 'font-weight': axis.labels.style.fontWeight || '', }, + rotation: axis.labels.rotation, }); }; -const applyLabelsMaxWidth = (args: { - series: ChartKitWidgetSeries[]; - preparedYAxis: PreparedAxis; -}) => { - const {series, preparedYAxis} = args; - - preparedYAxis.labels.width = getAxisLabelMaxWidth({axis: preparedYAxis, series}); -}; - export const getPreparedYAxis = ({ series, yAxis, @@ -89,9 +76,11 @@ export const getPreparedYAxis = ({ dateFormat: get(yAxis1, 'labels.dateFormat'), numberFormat: get(yAxis1, 'labels.numberFormat'), style: y1LabelsStyle, - rotation: 0, + rotation: get(yAxis1, 'labels.rotation', 0), width: 0, height: 0, + lineHeight: getHorisontalSvgTextHeight({text: 'TmpLabel', style: y1LabelsStyle}), + maxWidth: get(yAxis1, 'labels.maxWidth', axisLabelsDefaults.maxWidth), }, lineColor: get(yAxis1, 'lineColor'), categories: get(yAxis1, 'categories'), @@ -115,7 +104,7 @@ export const getPreparedYAxis = ({ }; if (labelsEnabled) { - applyLabelsMaxWidth({series, preparedYAxis: preparedY1Axis}); + preparedY1Axis.labels.width = getAxisLabelMaxWidth({axis: preparedY1Axis, series}); } return [preparedY1Axis]; diff --git a/src/plugins/d3/renderer/utils/axis-generators/bottom.ts b/src/plugins/d3/renderer/utils/axis-generators/bottom.ts index 63ec83ed..2700711e 100644 --- a/src/plugins/d3/renderer/utils/axis-generators/bottom.ts +++ b/src/plugins/d3/renderer/utils/axis-generators/bottom.ts @@ -3,6 +3,7 @@ import {select} from 'd3'; import {BaseTextStyle} from '../../../../../types'; import {getXAxisItems, getXAxisOffset, getXTickPosition} from '../axis'; import {getLabelsMaxHeight, setEllipsisForOverflowText} from '../text'; +import {calculateCos, calculateSin} from '../math'; type AxisBottomArgs = { scale: AxisScale; @@ -13,6 +14,8 @@ type AxisBottomArgs = { labelsPaddings?: number; labelsMargin?: number; labelsStyle?: BaseTextStyle; + labelsMaxWidth?: number; + labelsLineHeight: number; size: number; rotation: number; }; @@ -41,16 +44,6 @@ function addDomain( .attr('d', `M0,0V0H${size}`); } -function calculateCos(deg: number, precision = 2) { - const factor = Math.pow(10, precision); - return Math.floor(Math.cos((Math.PI / 180) * deg) * factor) / factor; -} - -function calculateSin(deg: number, precision = 2) { - const factor = Math.pow(10, precision); - return Math.floor(Math.sin((Math.PI / 180) * deg) * factor) / factor; -} - export function axisBottom(args: AxisBottomArgs) { const { scale, @@ -58,7 +51,9 @@ export function axisBottom(args: AxisBottomArgs) { labelFormat, labelsPaddings = 0, labelsMargin = 0, + labelsMaxWidth = Infinity, labelsStyle, + labelsLineHeight, size: tickSize, count: ticksCount, maxTickCount, @@ -81,7 +76,10 @@ export function axisBottom(args: AxisBottomArgs) { let transform = `translate(0, ${labelHeight + labelsMargin}px)`; if (rotation) { const labelsOffsetTop = labelHeight * calculateCos(rotation) + labelsMargin; - const labelsOffsetLeft = calculateSin(rotation) * labelHeight; + let labelsOffsetLeft = calculateSin(rotation) * labelHeight; + if (Math.abs(rotation) % 360 === 90) { + labelsOffsetLeft += ((rotation > 0 ? -1 : 1) * labelHeight) / 2; + } transform = `translate(${-labelsOffsetLeft}px, ${labelsOffsetTop}px) rotate(${rotation}deg)`; } @@ -122,7 +120,13 @@ export function axisBottom(args: AxisBottomArgs) { const labels = selection.selectAll('.tick text'); // FIXME: handle rotated overlapping labels (with a smarter approach) - if (!rotation) { + if (rotation) { + const maxWidth = + labelsMaxWidth * calculateCos(rotation) + labelsLineHeight * calculateSin(rotation); + labels.each(function () { + setEllipsisForOverflowText(select(this), maxWidth); + }); + } else { // remove overlapping labels let elementX = 0; selection diff --git a/src/plugins/d3/renderer/utils/index.ts b/src/plugins/d3/renderer/utils/index.ts index 94968f39..2331c3ea 100644 --- a/src/plugins/d3/renderer/utils/index.ts +++ b/src/plugins/d3/renderer/utils/index.ts @@ -180,19 +180,16 @@ export const getHorisontalSvgTextHeight = (args: { style?: Partial; }) => { const {text, style} = args; - const textSelection = select(document.body).append('text').text(text); + const container = select(document.body).append('svg'); + const textSelection = container.append('text').text(text); const fontSize = get(style, 'fontSize', DEFAULT_AXIS_LABEL_FONT_SIZE); - let height = 0; if (fontSize) { - textSelection.style('font-size', fontSize); + textSelection.style('font-size', fontSize).style('alignment-baseline', 'after-edge'); } - textSelection - .each(function () { - height = this.getBoundingClientRect().height; - }) - .remove(); + const height = textSelection.node()?.getBoundingClientRect().height || 0; + container.remove(); return height; }; diff --git a/src/plugins/d3/renderer/utils/math.ts b/src/plugins/d3/renderer/utils/math.ts index f6ee3060..7c6ba4d5 100644 --- a/src/plugins/d3/renderer/utils/math.ts +++ b/src/plugins/d3/renderer/utils/math.ts @@ -49,3 +49,13 @@ export const calculateNumericProperty = (args: {value?: string | number | null; return value; }; + +export function calculateCos(deg: number, precision = 2) { + const factor = Math.pow(10, precision); + return Math.floor(Math.cos((Math.PI / 180) * deg) * factor) / factor; +} + +export function calculateSin(deg: number, precision = 2) { + const factor = Math.pow(10, precision); + return Math.floor(Math.sin((Math.PI / 180) * deg) * factor) / factor; +} diff --git a/src/plugins/d3/renderer/utils/text.ts b/src/plugins/d3/renderer/utils/text.ts index b1b356cf..7e9362e0 100644 --- a/src/plugins/d3/renderer/utils/text.ts +++ b/src/plugins/d3/renderer/utils/text.ts @@ -10,11 +10,12 @@ export function setEllipsisForOverflowText( selection.text(null).append('title').text(text); const tSpan = selection.append('tspan').text(text).style('alignment-baseline', 'inherit'); - let textLength = tSpan.node()?.getComputedTextLength() || 0; + let textLength = tSpan.node()?.getBoundingClientRect()?.width || 0; + while (textLength > maxWidth && text.length > 1) { text = text.slice(0, -1); tSpan.text(text + '…'); - textLength = tSpan.node()?.getComputedTextLength() || 0; + textLength = tSpan.node()?.getBoundingClientRect()?.width || 0; } } @@ -90,15 +91,17 @@ function renderLabels( export function getLabelsMaxWidth({ labels, style, - transform, + rotation, }: { labels: string[]; style?: Record; - transform?: string; + rotation?: number; }) { const svg = select(document.body).append('svg'); - const attrs: Record = transform ? {transform: transform} : {}; - svg.call(renderLabels, {labels, style, attrs}); + const textSelection = renderLabels(svg, {labels, style}); + if (rotation) { + textSelection.attr('text-anchor', 'end').style('transform', `rotate(${rotation}deg)`); + } const maxWidth = (svg.select('g').node() as Element)?.getBoundingClientRect()?.width || 0; svg.remove();