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): auto rotate overlapping labels #293

Merged
merged 5 commits into from
Sep 19, 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
108 changes: 47 additions & 61 deletions src/plugins/d3/renderer/components/AxisX.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import {axisBottom, ScaleLinear, select} from 'd3';
import {select} from 'd3';
import type {AxisScale, AxisDomain} from 'd3';

import {block} from '../../../../utils/cn';
Expand All @@ -8,13 +8,14 @@ import type {ChartScale, PreparedAxis} from '../hooks';
import {
formatAxisTickLabel,
getClosestPointsRange,
parseTransformStyle,
setEllipsisForOverflowText,
getTicksCount,
getScaleTicks,
getMaxTickCount,
} from '../utils';
import {axisBottom} from '../utils/axis-generators';

const b = block('d3-axis');
const EMPTY_SPACE_BETWEEN_LABELS = 10;

type Props = {
axis: PreparedAxis;
Expand All @@ -24,8 +25,24 @@ type Props = {
chartWidth: number;
};

// FIXME: add overflow ellipsis for the labels that out of boundaries
export const AxisX = ({axis, width, height, scale, chartWidth}: Props) => {
function getLabelFormatter({axis, scale}: {axis: PreparedAxis; scale: ChartScale}) {
const ticks = getScaleTicks(scale as AxisScale<AxisDomain>);
const tickStep = getClosestPointsRange(axis, ticks);

return (value: any) => {
if (!axis.labels.enabled) {
return '';
}

return formatAxisTickLabel({
axis,
value,
step: tickStep,
});
};
}

export const AxisX = React.memo(({axis, width, height, scale, chartWidth}: Props) => {
const ref = React.useRef<SVGGElement>(null);

React.useEffect(() => {
Expand All @@ -35,71 +52,40 @@ export const AxisX = ({axis, width, height, scale, chartWidth}: Props) => {

const svgElement = select(ref.current);
svgElement.selectAll('*').remove();
const tickSize = axis.grid.enabled ? height * -1 : 0;
const ticks =
axis.type === 'category' ? [] : (scale as ScaleLinear<number, number>).ticks();
const tickStep = getClosestPointsRange(axis, ticks);
let xAxisGenerator = axisBottom(scale as AxisScale<AxisDomain>)
.tickSize(tickSize)
.tickPadding(axis.labels.padding)
.tickFormat((value) => {
if (!axis.labels.enabled) {
return '';
}

return formatAxisTickLabel({
axis,
value,
step: tickStep,
});
});

const ticksCount = getTicksCount({axis, range: width});
if (ticksCount) {
xAxisGenerator = xAxisGenerator.ticks(ticksCount);
}

const xAxisGenerator = axisBottom({
scale: scale as AxisScale<AxisDomain>,
ticks: {
size: axis.grid.enabled ? height * -1 : 0,
labelFormat: getLabelFormatter({axis, scale}),
labelsPaddings: axis.labels.padding,
labelsMargin: axis.labels.margin,
labelsStyle: axis.labels.style,
count: getTicksCount({axis, range: width}),
maxTickCount: getMaxTickCount({axis, width}),
autoRotation: axis.labels.autoRotation,
},
domain: {
size: width,
color: axis.lineColor,
},
});

svgElement.call(xAxisGenerator).attr('class', b());
svgElement
.select('.domain')
.attr('d', `M0,0V0H${width}`)
.style('stroke', axis.lineColor || '');

if (axis.labels.enabled) {
svgElement.selectAll('.tick text').style('font-size', axis.labels.style.fontSize);
}

const transformStyle = svgElement.select('.tick').attr('transform');
const {x} = parseTransformStyle(transformStyle);

if (x === 0) {
// Remove tick that has the same x coordinate like domain
svgElement.select('.tick').remove();
svgElement.style('font-size', axis.labels.style.fontSize);
}

// remove overlapping labels
let elementX = 0;
svgElement
.selectAll('.tick')
.filter(function () {
const node = this as unknown as Element;
const r = node.getBoundingClientRect();

if (r.left < elementX) {
return true;
}
elementX = r.right + EMPTY_SPACE_BETWEEN_LABELS;
return false;
})
.remove();

// add an ellipsis to the labels on the right that go beyond the boundaries of the chart
svgElement.selectAll('.tick text').each(function () {
const node = this as unknown as SVGTextElement;
const textRect = node.getBoundingClientRect();
const textRect = node.getBBox();
const matrix = node.transform.baseVal.consolidate()?.matrix || ({} as SVGMatrix);
const right = matrix.a * textRect.right + matrix.c * textRect.bottom + matrix.e;

if (textRect.right > chartWidth) {
const maxWidth = textRect.width - (textRect.right - chartWidth) * 2;
if (right > chartWidth) {
const maxWidth = textRect.width - (right - chartWidth) * 2;
select(node).call(setEllipsisForOverflowText, maxWidth);
}
});
Expand All @@ -122,4 +108,4 @@ export const AxisX = ({axis, width, height, scale, chartWidth}: Props) => {
}, [axis, width, height, scale]);

return <g ref={ref} />;
};
});
12 changes: 6 additions & 6 deletions src/plugins/d3/renderer/components/AxisY.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import {axisLeft, ScaleLinear, select} from 'd3';
import {axisLeft, select} from 'd3';
import type {AxisScale, AxisDomain} from 'd3';

import {block} from '../../../../utils/cn';
Expand All @@ -12,10 +12,10 @@ import {
setEllipsisForOverflowText,
setEllipsisForOverflowTexts,
getTicksCount,
getScaleTicks,
} from '../utils';

const b = block('d3-axis');
const EMPTY_SPACE_BETWEEN_LABELS = 10;
const MAX_WIDTH = 80;

type Props = {
Expand All @@ -37,11 +37,11 @@ export const AxisY = ({axises, width, height, scale}: Props) => {
const svgElement = select(ref.current);
svgElement.selectAll('*').remove();
const tickSize = axis.grid.enabled ? width * -1 : 0;
const step = getClosestPointsRange(axis, (scale as ScaleLinear<number, number>).ticks());
const step = getClosestPointsRange(axis, getScaleTicks(scale as AxisScale<AxisDomain>));

let yAxisGenerator = axisLeft(scale as AxisScale<AxisDomain>)
.tickSize(tickSize)
.tickPadding(axis.labels.padding)
.tickPadding(axis.labels.margin)
.tickFormat((value) => {
if (!axis.labels.enabled) {
return '';
Expand Down Expand Up @@ -94,13 +94,13 @@ export const AxisY = ({axises, width, height, scale}: Props) => {
if (r.bottom > elementY && index !== 0) {
return true;
}
elementY = r.top - EMPTY_SPACE_BETWEEN_LABELS;
elementY = r.top - axis.labels.padding;
return false;
})
.remove();

if (axis.title.text) {
const textY = axis.title.height + axis.labels.padding;
const textY = axis.title.height + axis.labels.margin;

svgElement
.append('text')
Expand Down
3 changes: 1 addition & 2 deletions src/plugins/d3/renderer/components/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
useShapes,
useTooltip,
} from '../hooks';
import {isAxisRelatedSeries} from '../utils';
import {AxisY} from './AxisY';
import {AxisX} from './AxisX';
import {Legend} from './Legend';
Expand Down Expand Up @@ -49,13 +48,13 @@ export const Chart = (props: Props) => {
preparedYAxis: yAxis,
});
const {boundsWidth, boundsHeight} = useChartDimensions({
hasAxisRelatedSeries: data.series.data.some(isAxisRelatedSeries),
width,
height,
margin: chart.margin,
preparedLegend,
preparedXAxis: xAxis,
preparedYAxis: yAxis,
preparedSeries: preparedSeries,
});
const {xScale, yScale} = useAxisScales({
boundsWidth,
Expand Down
5 changes: 5 additions & 0 deletions src/plugins/d3/renderer/constants/defaults/axis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const axisLabelsDefaults = {
margin: 10,
padding: 10,
fontSize: 11,
};
1 change: 1 addition & 0 deletions src/plugins/d3/renderer/constants/defaults/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './axis';
export * from './legend';
1 change: 0 additions & 1 deletion src/plugins/d3/renderer/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,4 @@ export const DEFAULT_PALETTE = [
];

export const DEFAULT_AXIS_LABEL_FONT_SIZE = '11px';
export const DEFAULT_AXIS_LABEL_PADDING = 10;
export const DEFAULT_AXIS_TITLE_FONT_SIZE = '14px';
100 changes: 77 additions & 23 deletions src/plugins/d3/renderer/hooks/useChartDimensions/index.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,76 @@
import type {ChartMargin} from '../../../../../types/widget-data';
import {AxisDomain, AxisScale} from 'd3';
import React from 'react';

import type {PreparedAxis, PreparedLegend} from '../../hooks';
import {getHorisontalSvgTextHeight} from '../../utils';
import type {ChartMargin} from '../../../../../types';
import type {PreparedAxis, PreparedLegend, PreparedSeries} from '../../hooks';
import {createXScale} from '../../hooks';
import {
formatAxisTickLabel,
getClosestPointsRange,
getHorisontalSvgTextHeight,
getLabelsMaxHeight,
getMaxTickCount,
getTicksCount,
getXAxisItems,
hasOverlappingLabels,
isAxisRelatedSeries,
} from '../../utils';
import {getBoundsWidth} from './utils';

export {getBoundsWidth} from './utils';

type Args = {
hasAxisRelatedSeries: boolean;
width: number;
height: number;
margin: ChartMargin;
preparedLegend: PreparedLegend;
preparedXAxis: PreparedAxis;
preparedYAxis: PreparedAxis[];
preparedSeries: PreparedSeries[];
};

const getHeightOccupiedByXAxis = (preparedXAxis: PreparedAxis) => {
const getHeightOccupiedByXAxis = ({
preparedXAxis,
preparedSeries,
width,
}: {
preparedXAxis: PreparedAxis;
preparedSeries: PreparedSeries[];
width: number;
}) => {
let height = preparedXAxis.title.height;

if (preparedXAxis.labels.enabled) {
height +=
preparedXAxis.labels.padding +
getHorisontalSvgTextHeight({text: 'Tmp', style: preparedXAxis.labels.style});
const scale = createXScale(preparedXAxis, preparedSeries, width);
const tickCount = getTicksCount({axis: preparedXAxis, range: width});
const ticks = getXAxisItems({
scale: scale as AxisScale<AxisDomain>,
count: tickCount,
maxCount: getMaxTickCount({width, axis: preparedXAxis}),
});
const step = getClosestPointsRange(preparedXAxis, ticks);
const labels = ticks.map((value: AxisDomain) => {
return formatAxisTickLabel({
axis: preparedXAxis,
value,
step,
});
});
const overlapping = hasOverlappingLabels({
width,
labels,
padding: preparedXAxis.labels.padding,
style: preparedXAxis.labels.style,
});

const labelsHeight = overlapping
? getLabelsMaxHeight({
labels,
style: preparedXAxis.labels.style,
transform: 'rotate(-45)',
})
: getHorisontalSvgTextHeight({text: 'Tmp', style: preparedXAxis.labels.style});
height += preparedXAxis.labels.margin + labelsHeight;
}

return height;
Expand All @@ -32,34 +80,40 @@ const getBottomOffset = (args: {
hasAxisRelatedSeries: boolean;
preparedLegend: PreparedLegend;
preparedXAxis: PreparedAxis;
preparedSeries: PreparedSeries[];
width: number;
}) => {
const {hasAxisRelatedSeries, preparedLegend, preparedXAxis} = args;
const {hasAxisRelatedSeries, preparedLegend, preparedXAxis, preparedSeries, width} = args;
let result = 0;

if (preparedLegend.enabled) {
result += preparedLegend.height + preparedLegend.margin;
}

if (hasAxisRelatedSeries) {
result += getHeightOccupiedByXAxis(preparedXAxis);
result += getHeightOccupiedByXAxis({preparedXAxis, preparedSeries, width});
}

return result;
};

export const useChartDimensions = (args: Args) => {
const {
hasAxisRelatedSeries,
margin,
width,
height,
preparedLegend,
preparedXAxis,
preparedYAxis,
} = args;
const bottomOffset = getBottomOffset({hasAxisRelatedSeries, preparedLegend, preparedXAxis});
const boundsWidth = getBoundsWidth({chartWidth: width, chartMargin: margin, preparedYAxis});
const boundsHeight = height - margin.top - margin.bottom - bottomOffset;
const {margin, width, height, preparedLegend, preparedXAxis, preparedYAxis, preparedSeries} =
args;

return React.useMemo(() => {
const hasAxisRelatedSeries = preparedSeries.some(isAxisRelatedSeries);
const boundsWidth = getBoundsWidth({chartWidth: width, chartMargin: margin, preparedYAxis});
const bottomOffset = getBottomOffset({
hasAxisRelatedSeries,
preparedLegend,
preparedXAxis,
preparedSeries,
width: boundsWidth,
});

const boundsHeight = height - margin.top - margin.bottom - bottomOffset;

return {boundsWidth, boundsHeight};
return {boundsWidth, boundsHeight};
}, [margin, width, height, preparedLegend, preparedXAxis, preparedYAxis, preparedSeries]);
};
2 changes: 1 addition & 1 deletion src/plugins/d3/renderer/hooks/useChartOptions/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const getMarginLeft = (args: {
if (hasAxisRelatedSeries) {
marginLeft +=
AXIS_LINE_WIDTH +
preparedY1Axis.labels.padding +
preparedY1Axis.labels.margin +
(preparedY1Axis.labels.maxWidth || 0) +
preparedY1Axis.title.height;
}
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/d3/renderer/hooks/useChartOptions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
} from '../../../../../types/widget-data';

type PreparedAxisLabels = Omit<ChartKitWidgetAxisLabels, 'enabled' | 'padding' | 'style'> &
Required<Pick<ChartKitWidgetAxisLabels, 'enabled' | 'padding'>> & {
Required<Pick<ChartKitWidgetAxisLabels, 'enabled' | 'padding' | 'margin'>> & {
style: BaseTextStyle;
maxWidth?: number;
};
Expand Down
Loading