Skip to content

Commit

Permalink
feat(D3 plugin): split charts with same X axis (#486)
Browse files Browse the repository at this point in the history
* feat(D3 plugin): split charts with same X axis

* fix types

* Add plot titles

* fix import

* fix typings and story
  • Loading branch information
kuzmadom authored Jun 11, 2024
1 parent 5a5f482 commit 3904443
Show file tree
Hide file tree
Showing 22 changed files with 463 additions and 64 deletions.
121 changes: 121 additions & 0 deletions src/plugins/d3/__stories__/line/Split.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import React from 'react';

import {action} from '@storybook/addon-actions';
import type {StoryObj} from '@storybook/react';

import {D3Plugin} from '../..';
import {ChartKit} from '../../../../components/ChartKit';
import {Loader} from '../../../../components/Loader/Loader';
import {settings} from '../../../../libs';
import type {ChartKitRef, ChartKitWidgetData, LineSeries, LineSeriesData} from '../../../../types';
import nintendoGames from '../../examples/nintendoGames';

function prepareData(): LineSeries[] {
const games = nintendoGames.filter((d) => {
return d.date && d.user_score;
});

const byGenre = (genre: string) => {
return games
.filter((d) => d.genres.includes(genre))
.map((d) => {
const releaseDate = new Date(d.date as number);
return {
x: releaseDate.getFullYear(),
y: d.user_score,
label: `${d.title} (${d.user_score})`,
custom: d,
};
}) as LineSeriesData[];
};

return [
{
name: 'Strategy',
type: 'line',
data: byGenre('Strategy'),
yAxis: 0,
},
{
name: 'Shooter',
type: 'line',
data: byGenre('Shooter'),
yAxis: 1,
},
{
name: 'Puzzle',
type: 'line',
data: byGenre('Puzzle'),
yAxis: 1,
},
];
}

const ChartStory = () => {
const [loading, setLoading] = React.useState(true);
const chartkitRef = React.useRef<ChartKitRef>();

React.useEffect(() => {
settings.set({plugins: [D3Plugin]});
setLoading(false);
}, []);

const widgetData: ChartKitWidgetData = {
title: {
text: 'Chart title',
},
series: {
data: prepareData(),
},
split: {
enable: true,
layout: 'vertical',
gap: '40px',
plots: [{title: {text: 'Strategy'}}, {title: {text: 'Shooter & Puzzle'}}],
},
yAxis: [
{
title: {text: '1'},
plotIndex: 0,
},
{
title: {text: '2'},
plotIndex: 1,
},
],
xAxis: {
type: 'linear',
labels: {
numberFormat: {
showRankDelimiter: false,
},
},
},
};

if (loading) {
return <Loader />;
}

return (
<div style={{height: '90vh', width: '100%'}}>
<ChartKit
ref={chartkitRef}
type="d3"
data={widgetData}
onRender={action('onRender')}
onLoad={action('onLoad')}
onChartLoad={action('onChartLoad')}
/>
</div>
);
};

export const Split: StoryObj<typeof ChartStory> = {
name: 'Split',
};

export default {
title: 'Plugins/D3/Line',
component: ChartStory,
};
22 changes: 17 additions & 5 deletions src/plugins/d3/renderer/components/AxisX.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {select} from 'd3';
import type {AxisDomain, AxisScale} from 'd3';

import {block} from '../../../../utils/cn';
import type {ChartScale, PreparedAxis} from '../hooks';
import type {ChartScale, PreparedAxis, PreparedSplit} from '../hooks';
import {
formatAxisTickLabel,
getClosestPointsRange,
Expand All @@ -22,6 +22,7 @@ type Props = {
width: number;
height: number;
scale: ChartScale;
split: PreparedSplit;
};

function getLabelFormatter({axis, scale}: {axis: PreparedAxis; scale: ChartScale}) {
Expand All @@ -41,18 +42,29 @@ function getLabelFormatter({axis, scale}: {axis: PreparedAxis; scale: ChartScale
};
}

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

React.useEffect(() => {
if (!ref.current) {
return;
}

let tickItems: [number, number][] = [];
if (axis.grid.enabled) {
tickItems = new Array(split.plots.length || 1).fill(null).map((_, index) => {
const top = split.plots[index]?.top || 0;
const height = split.plots[index]?.height || totalHeight;

return [-top, -(top + height)];
});
}

const xAxisGenerator = axisBottom({
scale: scale as AxisScale<AxisDomain>,
ticks: {
size: axis.grid.enabled ? height * -1 : 0,
items: tickItems,
labelFormat: getLabelFormatter({axis, scale}),
labelsPaddings: axis.labels.padding,
labelsMargin: axis.labels.margin,
Expand Down Expand Up @@ -89,7 +101,7 @@ export const AxisX = React.memo(function AxisX({axis, width, height, scale}: Pro
.text(axis.title.text)
.call(setEllipsisForOverflowText, width);
}
}, [axis, width, height, scale]);
}, [axis, width, totalHeight, scale, split]);

return <g ref={ref} />;
});
29 changes: 20 additions & 9 deletions src/plugins/d3/renderer/components/AxisY.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import {axisLeft, axisRight, line, select} from 'd3';
import type {Axis, AxisDomain, AxisScale, Selection} from 'd3';

import {block} from '../../../../utils/cn';
import type {ChartScale, PreparedAxis} from '../hooks';
import type {ChartScale, PreparedAxis, PreparedSplit} from '../hooks';
import {
calculateCos,
calculateSin,
formatAxisTickLabel,
getAxisHeight,
getClosestPointsRange,
getScaleTicks,
getTicksCount,
Expand All @@ -20,10 +21,11 @@ import {
const b = block('d3-axis');

type Props = {
axises: PreparedAxis[];
axes: PreparedAxis[];
scale: ChartScale[];
width: number;
height: number;
split: PreparedSplit;
};

function transformLabel(args: {node: Element; axis: PreparedAxis}) {
Expand Down Expand Up @@ -91,7 +93,9 @@ function getAxisGenerator(args: {
return axisGenerator;
}

export const AxisY = ({axises, width, height, scale}: Props) => {
export const AxisY = (props: Props) => {
const {axes, width, height: totalHeight, scale, split} = props;
const height = getAxisHeight({split, boundsHeight: totalHeight});
const ref = React.useRef<SVGGElement | null>(null);

React.useEffect(() => {
Expand All @@ -104,10 +108,17 @@ export const AxisY = ({axises, width, height, scale}: Props) => {

const axisSelection = svgElement
.selectAll('axis')
.data(axises)
.data(axes)
.join('g')
.attr('class', b())
.style('transform', (_d, index) => (index === 0 ? '' : `translate(${width}px, 0)`));
.style('transform', (d) => {
const top = split.plots[d.plotIndex]?.top || 0;
if (d.position === 'left') {
return `translate(0, ${top}px)`;
}

return `translate(${width}px, 0)`;
});

axisSelection.each((d, index, node) => {
const seriesScale = scale[index];
Expand All @@ -119,7 +130,7 @@ export const AxisY = ({axises, width, height, scale}: Props) => {
>;
const yAxisGenerator = getAxisGenerator({
axisGenerator:
index === 0
d.position === 'left'
? axisLeft(seriesScale as AxisScale<AxisDomain>)
: axisRight(seriesScale as AxisScale<AxisDomain>),
preparedAxis: d,
Expand Down Expand Up @@ -195,17 +206,17 @@ export const AxisY = ({axises, width, height, scale}: Props) => {
.attr('class', b('title'))
.attr('text-anchor', 'middle')
.attr('dy', (d) => -(d.title.margin + d.labels.margin + d.labels.width))
.attr('dx', (_d, index) => (index === 0 ? -height / 2 : height / 2))
.attr('dx', (d) => (d.position === 'left' ? -height / 2 : height / 2))
.attr('font-size', (d) => d.title.style.fontSize)
.attr('transform', (_d, index) => (index === 0 ? 'rotate(-90)' : 'rotate(90)'))
.attr('transform', (d) => (d.position === 'left' ? 'rotate(-90)' : 'rotate(90)'))
.text((d) => d.title.text)
.each((_d, index, node) => {
return setEllipsisForOverflowText(
select(node[index]) as Selection<SVGTextElement, unknown, null, unknown>,
height,
);
});
}, [axises, width, height, scale]);
}, [axes, width, height, scale, split]);

return <g ref={ref} className={b('container')} />;
};
24 changes: 20 additions & 4 deletions src/plugins/d3/renderer/components/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import {useAxisScales, useChartDimensions, useChartOptions, useSeries, useShapes
import {getYAxisWidth} from '../hooks/useChartDimensions/utils';
import {getPreparedXAxis} from '../hooks/useChartOptions/x-axis';
import {getPreparedYAxis} from '../hooks/useChartOptions/y-axis';
import {useSplit} from '../hooks/useSplit';
import {getClosestPoints} from '../utils/get-closest-data';

import {AxisX} from './AxisX';
import {AxisY} from './AxisY';
import {Legend} from './Legend';
import {PlotTitle} from './PlotTitle';
import {Title} from './Title';
import {Tooltip} from './Tooltip';

Expand All @@ -32,7 +34,7 @@ type Props = {

export const Chart = (props: Props) => {
const {width, height, data} = props;
const svgRef = React.useRef<SVGSVGElement>(null);
const svgRef = React.useRef<SVGSVGElement | null>(null);
const dispatcher = React.useMemo(() => {
return getD3Dispatcher();
}, []);
Expand All @@ -44,8 +46,12 @@ export const Chart = (props: Props) => {
[data, width],
);
const yAxis = React.useMemo(
() => getPreparedYAxis({series: data.series.data, yAxis: data.yAxis}),
[data, width],
() =>
getPreparedYAxis({
series: data.series.data,
yAxis: data.yAxis,
}),
[data],
);

const {
Expand All @@ -72,12 +78,14 @@ export const Chart = (props: Props) => {
preparedYAxis: yAxis,
preparedSeries: preparedSeries,
});
const preparedSplit = useSplit({split: data.split, boundsHeight, chartWidth: width});
const {xScale, yScale} = useAxisScales({
boundsWidth,
boundsHeight,
series: preparedSeries,
xAxis,
yAxis,
split: preparedSplit,
});
const {shapes, shapesData} = useShapes({
boundsWidth,
Expand All @@ -89,6 +97,7 @@ export const Chart = (props: Props) => {
xScale,
yAxis,
yScale,
split: preparedSplit,
});

const clickHandler = data.chart?.events?.click;
Expand Down Expand Up @@ -139,6 +148,11 @@ export const Chart = (props: Props) => {
onMouseLeave={handleMouseLeave}
>
{title && <Title {...title} chartWidth={width} />}
<g transform={`translate(0, ${boundsOffsetTop})`}>
{preparedSplit.plots.map((plot, index) => {
return <PlotTitle key={`plot-${index}`} title={plot.title} />;
})}
</g>
<g
width={boundsWidth}
height={boundsHeight}
Expand All @@ -147,17 +161,19 @@ export const Chart = (props: Props) => {
{xScale && yScale?.length && (
<React.Fragment>
<AxisY
axises={yAxis}
axes={yAxis}
width={boundsWidth}
height={boundsHeight}
scale={yScale}
split={preparedSplit}
/>
<g transform={`translate(0, ${boundsHeight})`}>
<AxisX
axis={xAxis}
width={boundsWidth}
height={boundsHeight}
scale={xScale}
split={preparedSplit}
/>
</g>
</React.Fragment>
Expand Down
36 changes: 36 additions & 0 deletions src/plugins/d3/renderer/components/PlotTitle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';

import {block} from '../../../../utils/cn';
import type {PreparedPlotTitle} from '../hooks/useSplit/types';

const b = block('d3-plot-title');

type Props = {
title?: PreparedPlotTitle;
};

export const PlotTitle = (props: Props) => {
const {title} = props;

if (!title) {
return null;
}

const {x, y, text, style, height} = title;

return (
<text
className={b()}
dx={x}
dy={y}
dominantBaseline="middle"
textAnchor="middle"
style={{
lineHeight: `${height}px`,
...style,
}}
>
<tspan>{text}</tspan>
</text>
);
};
Loading

0 comments on commit 3904443

Please sign in to comment.