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): split charts with same X axis #486

Merged
merged 5 commits into from
Jun 11, 2024
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
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
Loading