Skip to content

Commit

Permalink
fix(D3 plugin): number label formatter
Browse files Browse the repository at this point in the history
  • Loading branch information
kuzmadom committed Sep 14, 2023
1 parent 0846585 commit 38b01d0
Show file tree
Hide file tree
Showing 9 changed files with 229 additions and 124 deletions.
30 changes: 27 additions & 3 deletions src/plugins/d3/__stories__/scatter/PerformanceIssue.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,38 @@ import {Meta, Story} from '@storybook/react';
import {Button} from '@gravity-ui/uikit';
import {settings} from '../../../../libs';
import {ChartKit} from '../../../../components/ChartKit';
import type {ChartKitRef} from '../../../../types';
import type {ChartKitRef, ChartKitWidgetData} from '../../../../types';
import {D3Plugin} from '../..';
import data from '../scatter-performance.json';
import {randomNormal} from 'd3';

const Template: Story = () => {
const [shown, setShown] = React.useState(false);
const chartkitRef = React.useRef<ChartKitRef>();

const widgetData: ChartKitWidgetData = React.useMemo(() => {
const categories = Array.from({length: 5000}).map((_, i) => String(i));
const randomFn = randomNormal(0, 10);

return {
xAxis: {
type: 'category',
categories: categories,
},
series: {
data: [
{
type: 'scatter',
name: 'Series 1',
data: categories.map((_, i) => ({
x: i,
y: randomFn(),
})),
},
],
},
};
}, []);

if (!shown) {
settings.set({plugins: [D3Plugin]});
return <Button onClick={() => setShown(true)}>Show chart</Button>;
Expand All @@ -19,7 +43,7 @@ const Template: Story = () => {
return (
<div style={{height: '300px', width: '100%'}}>
{/* @ts-ignore */}
<ChartKit ref={chartkitRef} type="d3" data={data} />
<ChartKit ref={chartkitRef} type="d3" data={widgetData} />
</div>
);
};
Expand Down
7 changes: 6 additions & 1 deletion 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, select} from 'd3';
import {axisBottom, ScaleLinear, select} from 'd3';
import type {AxisScale, AxisDomain} from 'd3';

import {block} from '../../../../utils/cn';
Expand Down Expand Up @@ -30,6 +30,10 @@ 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 tickStep =
axis.type === 'category'
? undefined
: (scale as ScaleLinear<number, number>).ticks()[0];
let xAxisGenerator = axisBottom(scale as AxisScale<AxisDomain>)
.tickSize(tickSize)
.tickPadding(axis.labels.padding)
Expand All @@ -43,6 +47,7 @@ export const AxisX = ({axis, width, height, scale, chartWidth}: Props) => {
value,
dateFormat: axis.labels['dateFormat'],
numberFormat: axis.labels['numberFormat'],
step: tickStep,
});
});

Expand Down
66 changes: 37 additions & 29 deletions src/plugins/d3/renderer/components/AxisY.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import React from 'react';
import {axisLeft, select} from 'd3';
import type {AxisScale, AxisDomain, Selection} from 'd3';
import {axisLeft, ScaleLinear, select} from 'd3';
import type {AxisScale, AxisDomain} from 'd3';

import {block} from '../../../../utils/cn';

import type {ChartScale, PreparedAxis} from '../hooks';
import {formatAxisTickLabel, parseTransformStyle} from '../utils';
import {
formatAxisTickLabel,
parseTransformStyle,
setEllipsisForOverflowText,
setEllipsisForOverflowTexts,
} from '../utils';

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

type Props = {
axises: PreparedAxis[];
Expand All @@ -17,27 +23,6 @@ type Props = {
scale: ChartScale;
};

// Note: this method do not prepared for rotated labels
const removeOverlappingYTicks = (axis: Selection<SVGGElement, unknown, null, undefined>) => {
const a = axis.selectAll('g.tick').nodes();

if (a.length <= 1) {
return;
}

for (let i = 0, x = 0; i < a.length; i++) {
const node = a[i] as Element;
const r = node.getBoundingClientRect();

if (r.bottom > x && i !== 0) {
node?.parentNode?.removeChild(node);
} else {
x = r.top - EMPTY_SPACE_BETWEEN_LABELS;
}
}
};

// FIXME: add overflow ellipsis for the labels that out of boundaries
export const AxisY = ({axises, width, height, scale}: Props) => {
const ref = React.useRef<SVGGElement>(null);

Expand All @@ -50,6 +35,10 @@ 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 tickStep =
axis.type === 'category'
? undefined
: (scale as ScaleLinear<number, number>).ticks()[0];
let yAxisGenerator = axisLeft(scale as AxisScale<AxisDomain>)
.tickSize(tickSize)
.tickPadding(axis.labels.padding)
Expand All @@ -63,6 +52,7 @@ export const AxisY = ({axises, width, height, scale}: Props) => {
value,
dateFormat: axis.labels['dateFormat'],
numberFormat: axis.labels['numberFormat'],
step: tickStep,
});
});

Expand All @@ -78,10 +68,12 @@ export const AxisY = ({axises, width, height, scale}: Props) => {
.style('stroke', axis.lineColor || '');

if (axis.labels.enabled) {
svgElement
.selectAll('.tick text')
const tickTexts = svgElement
.selectAll<SVGTextElement, string>('.tick text')
.style('font-size', axis.labels.style.fontSize)
.style('transform', 'translateY(-1px)');

tickTexts.call(setEllipsisForOverflowTexts, MAX_WIDTH);
}

const transformStyle = svgElement.select('.tick').attr('transform');
Expand All @@ -92,6 +84,23 @@ export const AxisY = ({axises, width, height, scale}: Props) => {
svgElement.select('.tick line').style('stroke', 'none');
}

// 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 - EMPTY_SPACE_BETWEEN_LABELS;
return false;
})
.remove();

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

Expand All @@ -103,10 +112,9 @@ export const AxisY = ({axises, width, height, scale}: Props) => {
.attr('dx', -height / 2)
.attr('font-size', axis.title.style.fontSize)
.attr('transform', 'rotate(-90)')
.text(axis.title.text);
.text(axis.title.text)
.call(setEllipsisForOverflowText, height);
}

removeOverlappingYTicks(svgElement);
}, [axises, width, height, scale]);

return <g ref={ref} />;
Expand Down
110 changes: 55 additions & 55 deletions src/plugins/d3/renderer/hooks/useAxisScales/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {scaleBand, scaleLinear, scaleUtc, extent} from 'd3';
import type {ScaleBand, ScaleLinear, ScaleTime} from 'd3';
import get from 'lodash/get';

import type {ChartOptions} from '../useChartOptions/types';
import type {ChartOptions, PreparedAxis} from '../useChartOptions/types';
import {
getOnlyVisibleSeries,
getDataCategoryValue,
Expand Down Expand Up @@ -56,22 +56,70 @@ const filterCategoriesByVisibleSeries = (args: {
return categories.filter((c) => visibleCategories.has(c));
};

export function createYScale(axis: PreparedAxis, series: PreparedSeries[], boundsHeight: number) {
const yType = get(axis, 'type', 'linear');
const yMin = get(axis, 'min');
const yCategories = get(axis, 'categories');
const yTimestamps = get(axis, 'timestamps');

switch (yType) {
case 'linear': {
const domain = getDomainDataYBySeries(series);
const range = [boundsHeight, boundsHeight * axis.maxPadding];

if (isNumericalArrayData(domain)) {
const [domainYMin, yMax] = extent(domain) as [number, number];
const yMinValue = typeof yMin === 'number' ? yMin : domainYMin;
return scaleLinear().domain([yMinValue, yMax]).range(range).nice();
}

break;
}
case 'category': {
if (yCategories) {
const filteredCategories = filterCategoriesByVisibleSeries({
axisDirection: 'y',
categories: yCategories,
series: series,
});
return scaleBand().domain(filteredCategories).range([boundsHeight, 0]);
}

break;
}
case 'datetime': {
const range = [boundsHeight, boundsHeight * axis.maxPadding];

if (yTimestamps) {
const [yMin, yMax] = extent(yTimestamps) as [number, number];
return scaleUtc().domain([yMin, yMax]).range(range).nice();
} else {
const domain = getDomainDataYBySeries(series);

if (isNumericalArrayData(domain)) {
const [yMin, yMax] = extent(domain) as [number, number];
return scaleUtc().domain([yMin, yMax]).range(range).nice();
}
}

break;
}
}

throw new Error('Failed to create yScale');
}

const createScales = (args: Args) => {
const {boundsWidth, boundsHeight, series, xAxis, yAxis} = args;
const xMin = get(xAxis, 'min');
const xType = get(xAxis, 'type', 'linear');
const xCategories = get(xAxis, 'categories');
const xTimestamps = get(xAxis, 'timestamps');
const yType = get(yAxis[0], 'type', 'linear');
const yMin = get(yAxis[0], 'min');
const yCategories = get(yAxis[0], 'categories');
const yTimestamps = get(xAxis, 'timestamps');
let visibleSeries = getOnlyVisibleSeries(series);
// Reassign to all series in case of all series unselected,
// otherwise we will get an empty space without grid
visibleSeries = visibleSeries.length === 0 ? series : visibleSeries;
let xScale: ChartScale | undefined;
let yScale: ChartScale | undefined;

const xAxisMinPadding = boundsWidth * xAxis.maxPadding;
const xRange = [0, boundsWidth - xAxisMinPadding];
Expand Down Expand Up @@ -125,55 +173,7 @@ const createScales = (args: Args) => {
throw new Error('Failed to create xScale');
}

switch (yType) {
case 'linear': {
const domain = getDomainDataYBySeries(visibleSeries);
const range = [boundsHeight, boundsHeight * yAxis[0].maxPadding];

if (isNumericalArrayData(domain)) {
const [domainYMin, yMax] = extent(domain) as [number, number];
const yMinValue = typeof yMin === 'number' ? yMin : domainYMin;
yScale = scaleLinear().domain([yMinValue, yMax]).range(range).nice();
}

break;
}
case 'category': {
if (yCategories) {
const filteredCategories = filterCategoriesByVisibleSeries({
axisDirection: 'y',
categories: yCategories,
series: visibleSeries,
});
yScale = scaleBand().domain(filteredCategories).range([boundsHeight, 0]);
}

break;
}
case 'datetime': {
const range = [boundsHeight, boundsHeight * yAxis[0].maxPadding];

if (yTimestamps) {
const [yMin, yMax] = extent(yTimestamps) as [number, number];
yScale = scaleUtc().domain([yMin, yMax]).range(range).nice();
} else {
const domain = getDomainDataYBySeries(visibleSeries);

if (isNumericalArrayData(domain)) {
const [yMin, yMax] = extent(domain) as [number, number];
yScale = scaleUtc().domain([yMin, yMax]).range(range).nice();
}
}

break;
}
}

if (!yScale) {
throw new Error('Failed to create yScale');
}

return {xScale, yScale};
return {xScale, yScale: createYScale(yAxis[0], visibleSeries, boundsHeight)};
};

/**
Expand Down
17 changes: 5 additions & 12 deletions src/plugins/d3/renderer/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import type {
BarXSeries,
} from '../../../../types/widget-data';
import {formatNumber} from '../../../shared';
import type {FormatNumberOptions} from '../../../shared';
import {DEFAULT_AXIS_LABEL_FONT_SIZE} from '../constants';
import {getNumberUnitRate} from '../../../shared/format-number/format-number';

export * from './math';
export * from './text';
Expand Down Expand Up @@ -135,22 +135,14 @@ export const parseTransformStyle = (style: string | null): {x?: number; y?: numb
return {x, y};
};

const defaultFormatNumberOptions: FormatNumberOptions = {
precision: 0,
};

export const formatAxisTickLabel = (args: {
axisType: ChartKitWidgetAxisType;
value: AxisDomain;
dateFormat?: ChartKitWidgetAxisLabels['dateFormat'];
numberFormat?: ChartKitWidgetAxisLabels['numberFormat'];
step?: number;
}) => {
const {
axisType,
value,
dateFormat = 'DD.MM.YY',
numberFormat = defaultFormatNumberOptions,
} = args;
const {axisType, value, dateFormat = 'DD.MM.YY', numberFormat = {}, step} = args;

switch (axisType) {
case 'category': {
Expand All @@ -161,7 +153,8 @@ export const formatAxisTickLabel = (args: {
}
case 'linear':
default: {
return formatNumber(value as number | string, numberFormat);
const unitRate = step ? getNumberUnitRate(step) : undefined;
return formatNumber(value as number | string, {unitRate, ...numberFormat});
}
}
};
Expand Down
Loading

0 comments on commit 38b01d0

Please sign in to comment.