Skip to content

Commit

Permalink
feat(D3 plugin): rotation and maxWidth options for Y axis labels (#318)
Browse files Browse the repository at this point in the history
* feat(D3 plugin): rotation and maxWidth options for Y axis labels

* add comment + fixme for yAxis ticks generation

* remove maxWidth from external types
  • Loading branch information
kuzmadom authored Sep 28, 2023
1 parent 50da93a commit 141505c
Show file tree
Hide file tree
Showing 12 changed files with 130 additions and 66 deletions.
13 changes: 12 additions & 1 deletion src/plugins/d3/__stories__/bar-x/Playground.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
],
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/plugins/d3/renderer/components/AxisX.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 56 additions & 18 deletions src/plugins/d3/renderer/components/AxisY.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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<SVGGElement>(null);

Expand Down Expand Up @@ -68,10 +94,20 @@ export const AxisY = ({axises, width, height, scale}: Props) => {
if (axis.labels.enabled) {
const tickTexts = svgElement
.selectAll<SVGTextElement, string>('.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');
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/plugins/d3/renderer/components/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

& .tick text {
color: var(--g-color-text-secondary);
alignment-baseline: after-edge;
}

& .tick line {
Expand Down
1 change: 1 addition & 0 deletions src/plugins/d3/renderer/constants/defaults/axis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const axisLabelsDefaults = {
margin: 10,
padding: 10,
fontSize: 11,
maxWidth: 80,
};

const axisTitleDefaults = {
Expand Down
2 changes: 2 additions & 0 deletions src/plugins/d3/renderer/hooks/useChartOptions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ type PreparedAxisLabels = Omit<
rotation: number;
height: number;
width: number;
lineHeight: number;
maxWidth: number;
};

export type PreparedChart = {
Expand Down
14 changes: 10 additions & 4 deletions src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '../../constants';
import type {PreparedAxis} from './types';
import {
calculateCos,
formatAxisTickLabel,
getClosestPointsRange,
getHorisontalSvgTextHeight,
Expand Down Expand Up @@ -54,7 +55,6 @@ function getLabelSettings({

const defaultRotation = overlapping && autoRotation ? -45 : 0;
const rotation = axis.labels.rotation || defaultRotation;

const labelsHeight = rotation
? getLabelsMaxHeight({
labels,
Expand All @@ -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 = ({
Expand All @@ -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'),
Expand All @@ -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'),
Expand Down
23 changes: 6 additions & 17 deletions src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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'),
Expand All @@ -115,7 +104,7 @@ export const getPreparedYAxis = ({
};

if (labelsEnabled) {
applyLabelsMaxWidth({series, preparedYAxis: preparedY1Axis});
preparedY1Axis.labels.width = getAxisLabelMaxWidth({axis: preparedY1Axis, series});
}

return [preparedY1Axis];
Expand Down
28 changes: 16 additions & 12 deletions src/plugins/d3/renderer/utils/axis-generators/bottom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AxisDomain>;
Expand All @@ -13,6 +14,8 @@ type AxisBottomArgs = {
labelsPaddings?: number;
labelsMargin?: number;
labelsStyle?: BaseTextStyle;
labelsMaxWidth?: number;
labelsLineHeight: number;
size: number;
rotation: number;
};
Expand Down Expand Up @@ -41,24 +44,16 @@ 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,
ticks: {
labelFormat,
labelsPaddings = 0,
labelsMargin = 0,
labelsMaxWidth = Infinity,
labelsStyle,
labelsLineHeight,
size: tickSize,
count: ticksCount,
maxTickCount,
Expand All @@ -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)`;
}

Expand Down Expand Up @@ -122,7 +120,13 @@ export function axisBottom(args: AxisBottomArgs) {
const labels = selection.selectAll<SVGTextElement, unknown>('.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
Expand Down
13 changes: 5 additions & 8 deletions src/plugins/d3/renderer/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,19 +180,16 @@ export const getHorisontalSvgTextHeight = (args: {
style?: Partial<BaseTextStyle>;
}) => {
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;
};
Expand Down
10 changes: 10 additions & 0 deletions src/plugins/d3/renderer/utils/math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading

0 comments on commit 141505c

Please sign in to comment.