From 39044433796e71bf02a348ae30f982afd2c537c9 Mon Sep 17 00:00:00 2001 From: Irina Kuzmina Date: Tue, 11 Jun 2024 17:13:24 +0300 Subject: [PATCH] feat(D3 plugin): split charts with same X axis (#486) * feat(D3 plugin): split charts with same X axis * fix types * Add plot titles * fix import * fix typings and story --- .../d3/__stories__/line/Split.stories.tsx | 121 ++++++++++++++++++ src/plugins/d3/renderer/components/AxisX.tsx | 22 +++- src/plugins/d3/renderer/components/AxisY.tsx | 29 +++-- src/plugins/d3/renderer/components/Chart.tsx | 24 +++- .../d3/renderer/components/PlotTitle.tsx | 36 ++++++ .../d3/renderer/components/styles.scss | 9 +- src/plugins/d3/renderer/hooks/index.ts | 1 + .../d3/renderer/hooks/useAxisScales/index.ts | 27 ++-- .../hooks/useChartDimensions/utils.ts | 16 ++- .../renderer/hooks/useChartOptions/types.ts | 1 + .../renderer/hooks/useChartOptions/x-axis.ts | 5 +- .../renderer/hooks/useChartOptions/y-axis.ts | 25 ++-- .../d3/renderer/hooks/useShapes/index.tsx | 4 + .../hooks/useShapes/line/prepare-data.ts | 18 ++- .../d3/renderer/hooks/useSplit/index.ts | 85 ++++++++++++ .../d3/renderer/hooks/useSplit/types.ts | 20 +++ .../renderer/utils/axis-generators/bottom.ts | 21 ++- src/plugins/d3/renderer/utils/axis.ts | 14 +- src/plugins/d3/renderer/validation/index.ts | 11 +- src/types/widget-data/axis.ts | 12 ++ src/types/widget-data/index.ts | 11 +- src/types/widget-data/split.ts | 15 +++ 22 files changed, 463 insertions(+), 64 deletions(-) create mode 100644 src/plugins/d3/__stories__/line/Split.stories.tsx create mode 100644 src/plugins/d3/renderer/components/PlotTitle.tsx create mode 100644 src/plugins/d3/renderer/hooks/useSplit/index.ts create mode 100644 src/plugins/d3/renderer/hooks/useSplit/types.ts create mode 100644 src/types/widget-data/split.ts diff --git a/src/plugins/d3/__stories__/line/Split.stories.tsx b/src/plugins/d3/__stories__/line/Split.stories.tsx new file mode 100644 index 00000000..013e75a1 --- /dev/null +++ b/src/plugins/d3/__stories__/line/Split.stories.tsx @@ -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(); + + 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 ; + } + + return ( +
+ +
+ ); +}; + +export const Split: StoryObj = { + name: 'Split', +}; + +export default { + title: 'Plugins/D3/Line', + component: ChartStory, +}; diff --git a/src/plugins/d3/renderer/components/AxisX.tsx b/src/plugins/d3/renderer/components/AxisX.tsx index db23af2b..8eb6e214 100644 --- a/src/plugins/d3/renderer/components/AxisX.tsx +++ b/src/plugins/d3/renderer/components/AxisX.tsx @@ -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, @@ -22,6 +22,7 @@ type Props = { width: number; height: number; scale: ChartScale; + split: PreparedSplit; }; function getLabelFormatter({axis, scale}: {axis: PreparedAxis; scale: ChartScale}) { @@ -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(null); +export const AxisX = React.memo(function AxisX(props: Props) { + const {axis, width, height: totalHeight, scale, split} = props; + const ref = React.useRef(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, ticks: { - size: axis.grid.enabled ? height * -1 : 0, + items: tickItems, labelFormat: getLabelFormatter({axis, scale}), labelsPaddings: axis.labels.padding, labelsMargin: axis.labels.margin, @@ -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 ; }); diff --git a/src/plugins/d3/renderer/components/AxisY.tsx b/src/plugins/d3/renderer/components/AxisY.tsx index f872d1ed..f511c44f 100644 --- a/src/plugins/d3/renderer/components/AxisY.tsx +++ b/src/plugins/d3/renderer/components/AxisY.tsx @@ -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, @@ -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}) { @@ -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(null); React.useEffect(() => { @@ -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]; @@ -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) : axisRight(seriesScale as AxisScale), preparedAxis: d, @@ -195,9 +206,9 @@ 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( @@ -205,7 +216,7 @@ export const AxisY = ({axises, width, height, scale}: Props) => { height, ); }); - }, [axises, width, height, scale]); + }, [axes, width, height, scale, split]); return ; }; diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index 19d83102..4b2b1e2c 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -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'; @@ -32,7 +34,7 @@ type Props = { export const Chart = (props: Props) => { const {width, height, data} = props; - const svgRef = React.useRef(null); + const svgRef = React.useRef(null); const dispatcher = React.useMemo(() => { return getD3Dispatcher(); }, []); @@ -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 { @@ -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, @@ -89,6 +97,7 @@ export const Chart = (props: Props) => { xScale, yAxis, yScale, + split: preparedSplit, }); const clickHandler = data.chart?.events?.click; @@ -139,6 +148,11 @@ export const Chart = (props: Props) => { onMouseLeave={handleMouseLeave} > {title && } + <g transform={`translate(0, ${boundsOffsetTop})`}> + {preparedSplit.plots.map((plot, index) => { + return <PlotTitle key={`plot-${index}`} title={plot.title} />; + })} + </g> <g width={boundsWidth} height={boundsHeight} @@ -147,10 +161,11 @@ 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 @@ -158,6 +173,7 @@ export const Chart = (props: Props) => { width={boundsWidth} height={boundsHeight} scale={xScale} + split={preparedSplit} /> </g> </React.Fragment> diff --git a/src/plugins/d3/renderer/components/PlotTitle.tsx b/src/plugins/d3/renderer/components/PlotTitle.tsx new file mode 100644 index 00000000..00f5a2ae --- /dev/null +++ b/src/plugins/d3/renderer/components/PlotTitle.tsx @@ -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> + ); +}; diff --git a/src/plugins/d3/renderer/components/styles.scss b/src/plugins/d3/renderer/components/styles.scss index 773a6733..cf014ac6 100644 --- a/src/plugins/d3/renderer/components/styles.scss +++ b/src/plugins/d3/renderer/components/styles.scss @@ -12,7 +12,8 @@ alignment-baseline: after-edge; } - & .tick line { + & .tick line, + & .tick path { stroke: var(--g-color-line-generic); } @@ -86,6 +87,12 @@ fill: var(--g-color-text-primary); } +.chartkit-d3-plot-title { + font-size: var(--g-text-subheader-3-font-size); + font-weight: var(--g-text-subheader-font-weight); + fill: var(--g-color-text-secondary); +} + .chartkit-d3-tooltip { &[class] { --g-popup-border-width: 0; diff --git a/src/plugins/d3/renderer/hooks/index.ts b/src/plugins/d3/renderer/hooks/index.ts index 3e7cb54c..6600b60c 100644 --- a/src/plugins/d3/renderer/hooks/index.ts +++ b/src/plugins/d3/renderer/hooks/index.ts @@ -7,3 +7,4 @@ export * from './useSeries/types'; export * from './useShapes'; export * from './useTooltip'; export * from './useTooltip/types'; +export * from './useSplit/types'; diff --git a/src/plugins/d3/renderer/hooks/useAxisScales/index.ts b/src/plugins/d3/renderer/hooks/useAxisScales/index.ts index 768e0408..3a42826d 100644 --- a/src/plugins/d3/renderer/hooks/useAxisScales/index.ts +++ b/src/plugins/d3/renderer/hooks/useAxisScales/index.ts @@ -8,6 +8,7 @@ import {ChartKitWidgetAxis, ChartKitWidgetSeries} from '../../../../../types'; import {DEFAULT_AXIS_TYPE} from '../../constants'; import { CHART_SERIES_WITH_VOLUME, + getAxisHeight, getDataCategoryValue, getDefaultMaxXAxisValue, getDomainDataXBySeries, @@ -18,7 +19,8 @@ import { } from '../../utils'; import type {AxisDirection} from '../../utils'; import type {PreparedAxis} from '../useChartOptions/types'; -import {PreparedSeries} from '../useSeries/types'; +import type {PreparedSeries} from '../useSeries/types'; +import type {PreparedSplit} from '../useSplit/types'; export type ChartScale = | ScaleLinear<number, number> @@ -31,6 +33,7 @@ type Args = { series: PreparedSeries[]; xAxis: PreparedAxis; yAxis: PreparedAxis[]; + split: PreparedSplit; }; type ReturnValue = { @@ -204,7 +207,7 @@ export function createXScale( } const createScales = (args: Args) => { - const {boundsWidth, boundsHeight, series, xAxis, yAxis} = args; + const {boundsWidth, boundsHeight, series, xAxis, yAxis, split} = args; let visibleSeries = getOnlyVisibleSeries(series); // Reassign to all series in case of all series unselected, // otherwise we will get an empty space without grid @@ -218,10 +221,11 @@ const createScales = (args: Args) => { return seriesAxisIndex === index; }); const visibleAxisSeries = getOnlyVisibleSeries(axisSeries); + const axisHeight = getAxisHeight({boundsHeight, split}); return createYScale( axis, visibleAxisSeries.length ? visibleAxisSeries : axisSeries, - boundsHeight, + axisHeight, ); }), }; @@ -231,18 +235,23 @@ const createScales = (args: Args) => { * Uses to create scales for axis related series */ export const useAxisScales = (args: Args): ReturnValue => { - const {boundsWidth, boundsHeight, series, xAxis, yAxis} = args; - const scales = React.useMemo(() => { + const {boundsWidth, boundsHeight, series, xAxis, yAxis, split} = args; + return React.useMemo(() => { let xScale: ChartScale | undefined; let yScale: ChartScale[] | undefined; const hasAxisRelatedSeries = series.some(isAxisRelatedSeries); if (hasAxisRelatedSeries) { - ({xScale, yScale} = createScales({boundsWidth, boundsHeight, series, xAxis, yAxis})); + ({xScale, yScale} = createScales({ + boundsWidth, + boundsHeight, + series, + xAxis, + yAxis, + split, + })); } return {xScale, yScale}; - }, [boundsWidth, boundsHeight, series, xAxis, yAxis]); - - return scales; + }, [boundsWidth, boundsHeight, series, xAxis, yAxis, split]); }; diff --git a/src/plugins/d3/renderer/hooks/useChartDimensions/utils.ts b/src/plugins/d3/renderer/hooks/useChartDimensions/utils.ts index 05bbe6c0..b1a5ff2d 100644 --- a/src/plugins/d3/renderer/hooks/useChartDimensions/utils.ts +++ b/src/plugins/d3/renderer/hooks/useChartDimensions/utils.ts @@ -29,6 +29,18 @@ export function getYAxisWidth(axis: PreparedAxis | undefined) { } export function getWidthOccupiedByYAxis(args: {preparedAxis: PreparedAxis[]}) { - const {preparedAxis = []} = args; - return preparedAxis.reduce((sum, axis) => sum + getYAxisWidth(axis), 0); + const {preparedAxis} = args; + let leftAxisWidth = 0; + let rightAxisWidth = 0; + + preparedAxis?.forEach((axis) => { + const axisWidth = getYAxisWidth(axis); + if (axis.position === 'right') { + rightAxisWidth = Math.max(rightAxisWidth, axisWidth); + } else { + leftAxisWidth = Math.max(leftAxisWidth, axisWidth); + } + }); + + return leftAxisWidth + rightAxisWidth; } diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts index 68065924..ea939afb 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts @@ -42,6 +42,7 @@ export type PreparedAxis = Omit<ChartKitWidgetAxis, 'type' | 'labels'> & { pixelInterval?: number; }; position: 'left' | 'right' | 'top' | 'bottom'; + plotIndex: number; }; export type PreparedTitle = ChartKitWidgetData['title'] & { diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts index 02b8ddaf..e521f799 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts @@ -1,7 +1,7 @@ import type {AxisDomain, AxisScale} from 'd3'; import get from 'lodash/get'; -import type {BaseTextStyle, ChartKitWidgetAxis, ChartKitWidgetSeries} from '../../../../../types'; +import type {BaseTextStyle, ChartKitWidgetSeries, ChartKitWidgetXAxis} from '../../../../../types'; import { DEFAULT_AXIS_LABEL_FONT_SIZE, axisLabelsDefaults, @@ -74,7 +74,7 @@ export const getPreparedXAxis = ({ series, width, }: { - xAxis?: ChartKitWidgetAxis; + xAxis?: ChartKitWidgetXAxis; series: ChartKitWidgetSeries[]; width: number; }): PreparedAxis => { @@ -121,6 +121,7 @@ export const getPreparedXAxis = ({ pixelInterval: get(xAxis, 'ticks.pixelInterval'), }, position: 'bottom', + plotIndex: 0, }; const {height, rotation} = getLabelSettings({ diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts index 46908420..663fa5e5 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts @@ -1,8 +1,7 @@ import type {AxisDomain, AxisScale} from 'd3'; import get from 'lodash/get'; -import type {BaseTextStyle, ChartKitWidgetData, ChartKitWidgetSeries} from '../../../../../types'; -import {ChartKitWidgetAxis} from '../../../../../types'; +import type {BaseTextStyle, ChartKitWidgetSeries, ChartKitWidgetYAxis} from '../../../../../types'; import { DEFAULT_AXIS_LABEL_FONT_SIZE, axisLabelsDefaults, @@ -50,7 +49,7 @@ const getAxisLabelMaxWidth = (args: {axis: PreparedAxis; series: ChartKitWidgetS }).maxWidth; }; -function getAxisMin(axis?: ChartKitWidgetAxis, series?: ChartKitWidgetSeries[]) { +function getAxisMin(axis?: ChartKitWidgetYAxis, series?: ChartKitWidgetSeries[]) { const min = axis?.min; if ( @@ -86,10 +85,19 @@ export const getPreparedYAxis = ({ yAxis, }: { series: ChartKitWidgetSeries[]; - yAxis: ChartKitWidgetData['yAxis']; + yAxis: ChartKitWidgetYAxis[] | undefined; }): PreparedAxis[] => { - return (yAxis || [{}]).map((axisItem, index) => { - const axisPosition = index === 0 ? 'left' : 'right'; + const axisByPlot: ChartKitWidgetYAxis[][] = []; + const axisItems = yAxis || [{} as ChartKitWidgetYAxis]; + return axisItems.map((axisItem) => { + const plotIndex = get(axisItem, 'plotIndex', 0); + const firstPlotAxis = !axisByPlot[plotIndex]; + if (firstPlotAxis) { + axisByPlot[plotIndex] = []; + } + axisByPlot[plotIndex].push(axisItem); + const defaultAxisPosition = firstPlotAxis ? 'left' : 'right'; + const labelsEnabled = get(axisItem, 'labels.enabled', true); const labelsStyle: BaseTextStyle = { @@ -133,12 +141,13 @@ export const getPreparedYAxis = ({ min: getAxisMin(axisItem, series), maxPadding: get(axisItem, 'maxPadding', 0.05), grid: { - enabled: get(axisItem, 'grid.enabled', index === 0), + enabled: get(axisItem, 'grid.enabled', firstPlotAxis), }, ticks: { pixelInterval: get(axisItem, 'ticks.pixelInterval'), }, - position: axisPosition, + position: get(axisItem, 'position', defaultAxisPosition), + plotIndex: get(axisItem, 'plotIndex', 0), }; if (labelsEnabled) { diff --git a/src/plugins/d3/renderer/hooks/useShapes/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/index.tsx index 80a5c71f..9ec4c72a 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -11,6 +11,7 @@ import type { PreparedScatterSeries, PreparedSeries, PreparedSeriesOptions, + PreparedSplit, PreparedTreemapSeries, PreparedWaterfallSeries, } from '../'; @@ -60,6 +61,7 @@ type Args = { yAxis: PreparedAxis[]; xScale?: ChartScale; yScale?: ChartScale[]; + split: PreparedSplit; }; export const useShapes = (args: Args) => { @@ -73,6 +75,7 @@ export const useShapes = (args: Args) => { xScale, yAxis, yScale, + split, } = args; const shapesComponents = React.useMemo(() => { @@ -157,6 +160,7 @@ export const useShapes = (args: Args) => { xScale, yAxis, yScale, + split, }); acc.push( <LineSeriesShapes diff --git a/src/plugins/d3/renderer/hooks/useShapes/line/prepare-data.ts b/src/plugins/d3/renderer/hooks/useShapes/line/prepare-data.ts index 2fb70f6f..0b187a10 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/line/prepare-data.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/line/prepare-data.ts @@ -1,11 +1,12 @@ import type {LabelData} from '../../../types'; import {getLabelsSize, getLeftPosition} from '../../../utils'; -import {ChartScale} from '../../useAxisScales'; -import {PreparedAxis} from '../../useChartOptions/types'; -import {PreparedLineSeries} from '../../useSeries/types'; +import type {ChartScale} from '../../useAxisScales'; +import type {PreparedAxis} from '../../useChartOptions/types'; +import type {PreparedLineSeries} from '../../useSeries/types'; +import type {PreparedSplit} from '../../useSplit/types'; import {getXValue, getYValue} from '../utils'; -import {MarkerData, PointData, PreparedLineData} from './types'; +import type {MarkerData, PointData, PreparedLineData} from './types'; function getLabelData(point: PointData, series: PreparedLineSeries, xMax: number) { const text = String(point.data.label || point.data.y); @@ -42,17 +43,20 @@ export const prepareLineData = (args: { xScale: ChartScale; yAxis: PreparedAxis[]; yScale: ChartScale[]; + split: PreparedSplit; }): PreparedLineData[] => { - const {series, xAxis, xScale, yScale} = args; - const yAxis = args.yAxis[0]; + const {series, xAxis, yAxis, xScale, yScale, split} = args; const [_xMin, xRangeMax] = xScale.range(); const xMax = xRangeMax / (1 - xAxis.maxPadding); return series.reduce<PreparedLineData[]>((acc, s) => { + const yAxisIndex = s.yAxis; + const seriesYAxis = yAxis[yAxisIndex]; + const yAxisTop = split.plots[seriesYAxis.plotIndex]?.top || 0; const seriesYScale = yScale[s.yAxis]; const points = s.data.map((d) => ({ x: getXValue({point: d, xAxis, xScale}), - y: getYValue({point: d, yAxis, yScale: seriesYScale}), + y: yAxisTop + getYValue({point: d, yAxis: seriesYAxis, yScale: seriesYScale}), active: true, data: d, series: s, diff --git a/src/plugins/d3/renderer/hooks/useSplit/index.ts b/src/plugins/d3/renderer/hooks/useSplit/index.ts new file mode 100644 index 00000000..3a0cc0f4 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useSplit/index.ts @@ -0,0 +1,85 @@ +import get from 'lodash/get'; + +import type {BaseTextStyle, ChartKitWidgetSplit, SplitPlotOptions} from '../../../../../types'; +import {calculateNumericProperty, getHorisontalSvgTextHeight} from '../../utils'; + +import type {PreparedPlot, PreparedPlotTitle, PreparedSplit} from './types'; + +type UseSplitArgs = { + split?: ChartKitWidgetSplit; + boundsHeight: number; + chartWidth: number; +}; + +const DEFAULT_TITLE_FONT_SIZE = '15px'; +const TITLE_TOP_BOTTOM_PADDING = 8; + +function preparePlotTitle(args: { + title: SplitPlotOptions['title']; + plotIndex: number; + plotHeight: number; + chartWidth: number; + gap: number; +}): PreparedPlotTitle { + const {title, plotIndex, plotHeight, chartWidth, gap} = args; + const titleText = title?.text || ''; + const titleStyle: BaseTextStyle = { + fontSize: get(title, 'style.fontSize', DEFAULT_TITLE_FONT_SIZE), + fontWeight: get(title, 'style.fontWeight'), + }; + const titleHeight = titleText + ? getHorisontalSvgTextHeight({text: titleText, style: titleStyle}) + + TITLE_TOP_BOTTOM_PADDING * 2 + : 0; + const top = plotIndex * (plotHeight + gap); + + return { + text: titleText, + x: chartWidth / 2, + y: top + titleHeight / 2, + style: titleStyle, + height: titleHeight, + }; +} + +export function getPlotHeight(args: { + split: ChartKitWidgetSplit | undefined; + boundsHeight: number; + gap: number; +}) { + const {split, boundsHeight, gap} = args; + const plots = split?.plots || []; + + if (plots.length > 1) { + return (boundsHeight - gap * (plots.length - 1)) / plots.length; + } + + return boundsHeight; +} + +export const useSplit = (args: UseSplitArgs): PreparedSplit => { + const {split, boundsHeight, chartWidth} = args; + const splitGap = calculateNumericProperty({value: split?.gap, base: boundsHeight}) ?? 0; + const plotHeight = getPlotHeight({split: split, boundsHeight, gap: splitGap}); + + const plots = split?.plots || []; + return { + plots: plots.map<PreparedPlot>((p, index) => { + const title = preparePlotTitle({ + title: p.title, + plotIndex: index, + gap: splitGap, + plotHeight, + chartWidth, + }); + const top = index * (plotHeight + splitGap); + + return { + top: top + title.height, + height: plotHeight - title.height, + title, + }; + }), + gap: splitGap, + }; +}; diff --git a/src/plugins/d3/renderer/hooks/useSplit/types.ts b/src/plugins/d3/renderer/hooks/useSplit/types.ts new file mode 100644 index 00000000..9ebf56a2 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useSplit/types.ts @@ -0,0 +1,20 @@ +import type {BaseTextStyle} from '../../../../../types'; + +export type PreparedSplit = { + plots: PreparedPlot[]; + gap: number; +}; + +export type PreparedPlot = { + title: PreparedPlotTitle; + top: number; + height: number; +}; + +export type PreparedPlotTitle = { + x: number; + y: number; + text: string; + style?: Partial<BaseTextStyle>; + height: number; +}; diff --git a/src/plugins/d3/renderer/utils/axis-generators/bottom.ts b/src/plugins/d3/renderer/utils/axis-generators/bottom.ts index 491dc1a6..920e9d73 100644 --- a/src/plugins/d3/renderer/utils/axis-generators/bottom.ts +++ b/src/plugins/d3/renderer/utils/axis-generators/bottom.ts @@ -1,5 +1,5 @@ import type {AxisDomain, AxisScale, Selection} from 'd3'; -import {select} from 'd3'; +import {path, select} from 'd3'; import {BaseTextStyle} from '../../../../../types'; import {getXAxisItems, getXAxisOffset, getXTickPosition} from '../axis'; @@ -17,7 +17,7 @@ type AxisBottomArgs = { labelsStyle?: BaseTextStyle; labelsMaxWidth?: number; labelsLineHeight: number; - size: number; + items: [number, number][]; rotation: number; }; domain: { @@ -55,7 +55,7 @@ export function axisBottom(args: AxisBottomArgs) { labelsMaxWidth = Infinity, labelsStyle, labelsLineHeight, - size: tickSize, + items: tickItems, count: ticksCount, maxTickCount, rotation, @@ -73,10 +73,11 @@ export function axisBottom(args: AxisBottomArgs) { return function (selection: Selection<SVGGElement, unknown, null, undefined>) { const x = selection.node()?.getBoundingClientRect()?.x || 0; const right = x + domainSize; + const top = -tickItems[0][0] || 0; - let transform = `translate(0, ${labelHeight + labelsMargin}px)`; + let transform = `translate(0, ${labelHeight + labelsMargin - top}px)`; if (rotation) { - const labelsOffsetTop = labelHeight * calculateCos(rotation) + labelsMargin; + const labelsOffsetTop = labelHeight * calculateCos(rotation) + labelsMargin - top; let labelsOffsetLeft = calculateSin(rotation) * labelHeight; if (Math.abs(rotation) % 360 === 90) { labelsOffsetLeft += ((rotation > 0 ? -1 : 1) * labelHeight) / 2; @@ -84,13 +85,19 @@ export function axisBottom(args: AxisBottomArgs) { transform = `translate(${-labelsOffsetLeft}px, ${labelsOffsetTop}px) rotate(${rotation}deg)`; } + const tickPath = path(); + tickItems.forEach(([start, end]) => { + tickPath.moveTo(0, start); + tickPath.lineTo(0, end); + }); + selection .selectAll('.tick') .data(values) .order() .join((el) => { const tick = el.append('g').attr('class', 'tick'); - tick.append('line').attr('stroke', 'currentColor').attr('y2', tickSize); + tick.append('path').attr('d', tickPath.toString()).attr('stroke', 'currentColor'); tick.append('text') .text(labelFormat) .attr('fill', 'currentColor') @@ -106,7 +113,7 @@ export function axisBottom(args: AxisBottomArgs) { return tick; }) .attr('transform', function (d) { - return `translate(${position(d as AxisDomain) + offset},0)`; + return `translate(${position(d as AxisDomain) + offset}, ${top})`; }); // Remove tick that has the same x coordinate like domain diff --git a/src/plugins/d3/renderer/utils/axis.ts b/src/plugins/d3/renderer/utils/axis.ts index 7e117716..fa372213 100644 --- a/src/plugins/d3/renderer/utils/axis.ts +++ b/src/plugins/d3/renderer/utils/axis.ts @@ -1,6 +1,6 @@ -import {AxisDomain, AxisScale, ScaleBand} from 'd3'; +import type {AxisDomain, AxisScale, ScaleBand} from 'd3'; -import {PreparedAxis} from '../hooks'; +import type {PreparedAxis, PreparedSplit} from '../hooks'; export function getTicksCount({axis, range}: {axis: PreparedAxis; range: number}) { let ticksCount: number | undefined; @@ -65,3 +65,13 @@ export function getMaxTickCount({axis, width}: {axis: PreparedAxis; width: numbe const minTickWidth = parseInt(axis.labels.style.fontSize) + axis.labels.padding; return Math.floor(width / minTickWidth); } + +export function getAxisHeight(args: {split: PreparedSplit; boundsHeight: number}) { + const {split, boundsHeight} = args; + + if (split.plots.length > 1) { + return split.plots[0].height; + } + + return boundsHeight; +} diff --git a/src/plugins/d3/renderer/validation/index.ts b/src/plugins/d3/renderer/validation/index.ts index 79e7a45d..009a00e8 100644 --- a/src/plugins/d3/renderer/validation/index.ts +++ b/src/plugins/d3/renderer/validation/index.ts @@ -8,9 +8,10 @@ import { AreaSeries, BarXSeries, BarYSeries, - ChartKitWidgetAxis, ChartKitWidgetData, ChartKitWidgetSeries, + ChartKitWidgetXAxis, + ChartKitWidgetYAxis, LineSeries, PieSeries, ScatterSeries, @@ -24,8 +25,8 @@ const AVAILABLE_SERIES_TYPES = Object.values(SeriesType); const validateXYSeries = (args: { series: XYSeries; - xAxis?: ChartKitWidgetAxis; - yAxis?: ChartKitWidgetAxis[]; + xAxis?: ChartKitWidgetXAxis; + yAxis?: ChartKitWidgetYAxis[]; }) => { const {series, xAxis, yAxis = []} = args; @@ -183,8 +184,8 @@ const validateTreemapSeries = ({series}: {series: TreemapSeries}) => { const validateSeries = (args: { series: ChartKitWidgetSeries; - xAxis?: ChartKitWidgetAxis; - yAxis?: ChartKitWidgetAxis[]; + xAxis?: ChartKitWidgetXAxis; + yAxis?: ChartKitWidgetYAxis[]; }) => { const {series, xAxis, yAxis} = args; diff --git a/src/types/widget-data/axis.ts b/src/types/widget-data/axis.ts index e8175e7e..78317dc5 100644 --- a/src/types/widget-data/axis.ts +++ b/src/types/widget-data/axis.ts @@ -69,3 +69,15 @@ export type ChartKitWidgetAxis = { * */ maxPadding?: number; }; + +export type ChartKitWidgetXAxis = ChartKitWidgetAxis; + +export type ChartKitWidgetYAxis = ChartKitWidgetAxis & { + /** Axis location. + * Possible values - 'left' and 'right'. + * */ + position?: 'left' | 'right'; + /** Property for splitting charts. Determines which area the axis is located in. + * */ + plotIndex?: number; +}; diff --git a/src/types/widget-data/index.ts b/src/types/widget-data/index.ts index 14875f22..fd65b7df 100644 --- a/src/types/widget-data/index.ts +++ b/src/types/widget-data/index.ts @@ -1,7 +1,8 @@ -import type {ChartKitWidgetAxis} from './axis'; +import type {ChartKitWidgetXAxis, ChartKitWidgetYAxis} from './axis'; import type {ChartKitWidgetChart} from './chart'; import type {ChartKitWidgetLegend} from './legend'; import type {ChartKitWidgetSeries, ChartKitWidgetSeriesOptions} from './series'; +import type {ChartKitWidgetSplit} from './split'; import type {ChartKitWidgetTitle} from './title'; import type {ChartKitWidgetTooltip} from './tooltip'; @@ -16,6 +17,7 @@ export * from './bar-y'; export * from './area'; export * from './line'; export * from './series'; +export * from './split'; export * from './title'; export * from './tooltip'; export * from './halo'; @@ -31,6 +33,9 @@ export type ChartKitWidgetData<T = any> = { }; title?: ChartKitWidgetTitle; tooltip?: ChartKitWidgetTooltip<T>; - xAxis?: ChartKitWidgetAxis; - yAxis?: ChartKitWidgetAxis[]; + xAxis?: ChartKitWidgetXAxis; + yAxis?: ChartKitWidgetYAxis[]; + /** Setting for displaying charts on different plots. + * It can be used to visualize related information on multiple charts. */ + split?: ChartKitWidgetSplit; }; diff --git a/src/types/widget-data/split.ts b/src/types/widget-data/split.ts new file mode 100644 index 00000000..2115c28e --- /dev/null +++ b/src/types/widget-data/split.ts @@ -0,0 +1,15 @@ +import type {BaseTextStyle} from './base'; + +export type SplitPlotOptions = { + title?: { + text: string; + style?: Partial<BaseTextStyle>; + }; +}; + +export type ChartKitWidgetSplit = { + enable: boolean; + layout?: 'vertical'; + gap?: string | number; + plots?: SplitPlotOptions[]; +};