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

fix(D3 plugin): use units for number label formatter #290

Merged
merged 3 commits into from
Sep 14, 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
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
4 changes: 3 additions & 1 deletion src/plugins/d3/renderer/components/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ export const Chart = (props: Props) => {
const {top, left, width, height, data} = props;
const svgRef = React.createRef<SVGSVGElement>();
const {chartHovered, handleMouseEnter, handleMouseLeave} = useChartEvents();
const {chart, title, tooltip, xAxis, yAxis} = useChartOptions(data);
const {chart, title, tooltip, xAxis, yAxis} = useChartOptions({
data,
});
const {legendItems, legendConfig, preparedSeries, preparedLegend, handleLegendItemClick} =
useSeries({
chartWidth: width,
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
8 changes: 6 additions & 2 deletions src/plugins/d3/renderer/hooks/useChartOptions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ import {getPreparedXAxis} from './x-axis';
import {getPreparedYAxis} from './y-axis';
import type {ChartOptions} from './types';

type Args = ChartKitWidgetData;
type Args = {
data: ChartKitWidgetData;
};

export const useChartOptions = (args: Args): ChartOptions => {
const {chart, series, title, tooltip, xAxis, yAxis} = args;
const {
data: {chart, series, title, tooltip, xAxis, yAxis},
} = args;
const options: ChartOptions = React.useMemo(() => {
const preparedTitle = getPreparedTitle({title});
const preparedTooltip = getPreparedTooltip({tooltip});
Expand Down
Loading