Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(D3 plugin): rotation and maxWidth options for Y axis labels #318

Merged
merged 3 commits into from
Sep 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading