From 0ef86e80897d28a43192c9ee283b7a3ec77241bb Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Thu, 6 Jun 2024 13:02:44 +0300 Subject: [PATCH 1/5] feat(D3 plugin): split charts with same X axis --- .../d3/__stories__/line/Split.stories.tsx | 112 ++++++++++++++++++ src/plugins/d3/renderer/components/AxisX.tsx | 21 +++- src/plugins/d3/renderer/components/AxisY.tsx | 28 +++-- src/plugins/d3/renderer/components/Chart.tsx | 14 ++- .../d3/renderer/components/styles.scss | 3 +- .../d3/renderer/hooks/useAxisScales/index.ts | 26 ++-- .../renderer/hooks/useChartOptions/types.ts | 1 + .../renderer/hooks/useChartOptions/y-axis.ts | 18 ++- .../d3/renderer/hooks/useShapes/index.tsx | 5 + .../hooks/useShapes/line/prepare-data.ts | 20 +++- .../renderer/utils/axis-generators/bottom.ts | 16 ++- src/plugins/d3/renderer/utils/axis.ts | 15 +++ src/types/widget-data/axis.ts | 2 + src/types/widget-data/index.ts | 3 + src/types/widget-data/split.ts | 10 ++ 15 files changed, 259 insertions(+), 35 deletions(-) create mode 100644 src/plugins/d3/__stories__/line/Split.stories.tsx 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..605c582f --- /dev/null +++ b/src/plugins/d3/__stories__/line/Split.stories.tsx @@ -0,0 +1,112 @@ +import React from 'react'; + +import {action} from '@storybook/addon-actions'; +import {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) => { + return { + x: d.date, + 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 = { + series: { + data: prepareData(), + }, + split: { + enable: true, + layout: 'vertical', + gap: '40px', + plots: [{title: {text: 'First'}}, {title: {text: 'Second'}}], + }, + yAxis: [ + { + title: {text: '1'}, + plotIndex: 0, + }, + { + title: {text: '2'}, + plotIndex: 1, + }, + ], + xAxis: { + type: 'datetime', + }, + }; + + 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..a297c3eb 100644 --- a/src/plugins/d3/renderer/components/AxisX.tsx +++ b/src/plugins/d3/renderer/components/AxisX.tsx @@ -3,10 +3,13 @@ import React from 'react'; import {select} from 'd3'; import type {AxisDomain, AxisScale} from 'd3'; +import type {ChartKitWidgetSplit} from '../../../../types'; import {block} from '../../../../utils/cn'; import type {ChartScale, PreparedAxis} from '../hooks'; import { + calculateNumericProperty, formatAxisTickLabel, + getAxisHeight, getClosestPointsRange, getMaxTickCount, getScaleTicks, @@ -22,6 +25,7 @@ type Props = { width: number; height: number; scale: ChartScale; + split?: ChartKitWidgetSplit; }; function getLabelFormatter({axis, scale}: {axis: PreparedAxis; scale: ChartScale}) { @@ -41,18 +45,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); + const plotGap = calculateNumericProperty({value: split?.gap, base: totalHeight}) ?? 0; + const height = getAxisHeight({split, boundsHeight: totalHeight}); 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 = index * (height + plotGap); + 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, diff --git a/src/plugins/d3/renderer/components/AxisY.tsx b/src/plugins/d3/renderer/components/AxisY.tsx index f872d1ed..4090d292 100644 --- a/src/plugins/d3/renderer/components/AxisY.tsx +++ b/src/plugins/d3/renderer/components/AxisY.tsx @@ -3,12 +3,15 @@ import React from 'react'; import {axisLeft, axisRight, line, select} from 'd3'; import type {Axis, AxisDomain, AxisScale, Selection} from 'd3'; +import type {ChartKitWidgetSplit} from '../../../../types'; import {block} from '../../../../utils/cn'; import type {ChartScale, PreparedAxis} from '../hooks'; import { calculateCos, + calculateNumericProperty, calculateSin, formatAxisTickLabel, + getAxisHeight, getClosestPointsRange, getScaleTicks, getTicksCount, @@ -24,6 +27,7 @@ type Props = { scale: ChartScale[]; width: number; height: number; + split?: ChartKitWidgetSplit; }; function transformLabel(args: {node: Element; axis: PreparedAxis}) { @@ -91,7 +95,10 @@ 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 splitGap = calculateNumericProperty({value: split?.gap, base: totalHeight}) ?? 0; + const height = getAxisHeight({split, boundsHeight: totalHeight}); const ref = React.useRef(null); React.useEffect(() => { @@ -104,10 +111,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 = d.plotIndex * (height + splitGap); + 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 +133,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 +209,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 +219,7 @@ export const AxisY = ({axises, width, height, scale}: Props) => { height, ); }); - }, [axises, width, height, scale]); + }, [axes, width, height, scale, splitGap]); return ; }; diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index 19d83102..9e8dc036 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -44,8 +44,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 { @@ -78,6 +82,7 @@ export const Chart = (props: Props) => { series: preparedSeries, xAxis, yAxis, + split: data.split, }); const {shapes, shapesData} = useShapes({ boundsWidth, @@ -89,6 +94,7 @@ export const Chart = (props: Props) => { xScale, yAxis, yScale, + split: data.split, }); const clickHandler = data.chart?.events?.click; @@ -147,10 +153,11 @@ export const Chart = (props: Props) => { {xScale && yScale?.length && ( { width={boundsWidth} height={boundsHeight} scale={xScale} + split={data.split} /> diff --git a/src/plugins/d3/renderer/components/styles.scss b/src/plugins/d3/renderer/components/styles.scss index 773a6733..195509e8 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); } diff --git a/src/plugins/d3/renderer/hooks/useAxisScales/index.ts b/src/plugins/d3/renderer/hooks/useAxisScales/index.ts index 768e0408..e3a13b0c 100644 --- a/src/plugins/d3/renderer/hooks/useAxisScales/index.ts +++ b/src/plugins/d3/renderer/hooks/useAxisScales/index.ts @@ -4,10 +4,11 @@ import {extent, scaleBand, scaleLinear, scaleUtc} from 'd3'; import type {ScaleBand, ScaleLinear, ScaleTime} from 'd3'; import get from 'lodash/get'; -import {ChartKitWidgetAxis, ChartKitWidgetSeries} from '../../../../../types'; +import {ChartKitWidgetAxis, ChartKitWidgetData, ChartKitWidgetSeries} from '../../../../../types'; import {DEFAULT_AXIS_TYPE} from '../../constants'; import { CHART_SERIES_WITH_VOLUME, + getAxisHeight, getDataCategoryValue, getDefaultMaxXAxisValue, getDomainDataXBySeries, @@ -31,6 +32,7 @@ type Args = { series: PreparedSeries[]; xAxis: PreparedAxis; yAxis: PreparedAxis[]; + split?: ChartKitWidgetData['split']; }; type ReturnValue = { @@ -204,7 +206,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 +220,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 +234,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/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 & { pixelInterval?: number; }; position: 'left' | 'right' | 'top' | 'bottom'; + plotIndex: number; }; export type PreparedTitle = ChartKitWidgetData['title'] & { diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts index 46908420..12a4ed5c 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts @@ -88,8 +88,17 @@ export const getPreparedYAxis = ({ series: ChartKitWidgetSeries[]; yAxis: ChartKitWidgetData['yAxis']; }): PreparedAxis[] => { - return (yAxis || [{}]).map((axisItem, index) => { - const axisPosition = index === 0 ? 'left' : 'right'; + const axisByPlot = {}; + const axisItems = yAxis || [{}]; + 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 +142,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..fd7810a6 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -14,6 +14,7 @@ import type { PreparedTreemapSeries, PreparedWaterfallSeries, } from '../'; +import {ChartKitWidgetData} from '../../../../../types'; import {getOnlyVisibleSeries} from '../../utils'; import type {ChartScale} from '../useAxisScales'; import type {PreparedAxis} from '../useChartOptions/types'; @@ -60,6 +61,7 @@ type Args = { yAxis: PreparedAxis[]; xScale?: ChartScale; yScale?: ChartScale[]; + split?: ChartKitWidgetData['split']; }; 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,8 @@ export const useShapes = (args: Args) => { xScale, yAxis, yScale, + split, + boundsHeight, }); acc.push( { - const {series, xAxis, xScale, yScale} = args; - const yAxis = args.yAxis[0]; + const {series, xAxis, yAxis, xScale, yScale, split, boundsHeight} = args; + const splitGap = calculateNumericProperty({value: split?.gap, base: boundsHeight}) ?? 0; const [_xMin, xRangeMax] = xScale.range(); const xMax = xRangeMax / (1 - xAxis.maxPadding); return series.reduce((acc, s) => { + const yAxisIndex = s.yAxis; + const seriesYAxis = yAxis[yAxisIndex]; + const yAxisHeight = getAxisHeight({split, boundsHeight}); + const yAxisTop = seriesYAxis.plotIndex * (yAxisHeight + splitGap); 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/utils/axis-generators/bottom.ts b/src/plugins/d3/renderer/utils/axis-generators/bottom.ts index 491dc1a6..3dc2c807 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,8 @@ type AxisBottomArgs = { labelsStyle?: BaseTextStyle; labelsMaxWidth?: number; labelsLineHeight: number; - size: number; + // size: number; + items: [number, number][]; rotation: number; }; domain: { @@ -55,7 +56,8 @@ export function axisBottom(args: AxisBottomArgs) { labelsMaxWidth = Infinity, labelsStyle, labelsLineHeight, - size: tickSize, + // size: tickSize, + items: tickItems, count: ticksCount, maxTickCount, rotation, @@ -84,13 +86,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') diff --git a/src/plugins/d3/renderer/utils/axis.ts b/src/plugins/d3/renderer/utils/axis.ts index 7e117716..97ffc378 100644 --- a/src/plugins/d3/renderer/utils/axis.ts +++ b/src/plugins/d3/renderer/utils/axis.ts @@ -1,7 +1,10 @@ import {AxisDomain, AxisScale, ScaleBand} from 'd3'; +import {ChartKitWidgetData} from '../../../../types'; import {PreparedAxis} from '../hooks'; +import {calculateNumericProperty} from './math'; + export function getTicksCount({axis, range}: {axis: PreparedAxis; range: number}) { let ticksCount: number | undefined; @@ -65,3 +68,15 @@ 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: ChartKitWidgetData['split']; boundsHeight: number}) { + const {split, boundsHeight} = args; + const plots = split?.plots || []; + + if (plots.length > 1) { + const splitGap = calculateNumericProperty({value: split?.gap, base: boundsHeight}) ?? 0; + return (boundsHeight - splitGap * (plots.length - 1)) / plots.length; + } + + return boundsHeight; +} diff --git a/src/types/widget-data/axis.ts b/src/types/widget-data/axis.ts index e8175e7e..131f21c8 100644 --- a/src/types/widget-data/axis.ts +++ b/src/types/widget-data/axis.ts @@ -68,4 +68,6 @@ export type ChartKitWidgetAxis = { * Defaults to 0.05 for Y axis and to 0.01 for X axis. * */ maxPadding?: number; + position?: 'left' | 'right' | 'top' | 'bottom'; + plotIndex?: number; }; diff --git a/src/types/widget-data/index.ts b/src/types/widget-data/index.ts index 14875f22..13176235 100644 --- a/src/types/widget-data/index.ts +++ b/src/types/widget-data/index.ts @@ -2,6 +2,7 @@ import type {ChartKitWidgetAxis} 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'; @@ -33,4 +35,5 @@ export type ChartKitWidgetData = { tooltip?: ChartKitWidgetTooltip; xAxis?: ChartKitWidgetAxis; yAxis?: ChartKitWidgetAxis[]; + split?: ChartKitWidgetSplit; }; diff --git a/src/types/widget-data/split.ts b/src/types/widget-data/split.ts new file mode 100644 index 00000000..84d5114d --- /dev/null +++ b/src/types/widget-data/split.ts @@ -0,0 +1,10 @@ +type PlotOptions = { + title?: {text: string}; +}; + +export type ChartKitWidgetSplit = { + enable: boolean; + layout?: 'vertical'; + gap?: string | number; + plots?: PlotOptions[]; +}; From 05dcc912cdc0e7721269a499ffba814ba3431c72 Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Thu, 6 Jun 2024 13:22:37 +0300 Subject: [PATCH 2/5] fix types --- src/plugins/d3/renderer/components/AxisY.tsx | 2 +- src/plugins/d3/renderer/components/Chart.tsx | 2 +- src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts | 1 + src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts | 8 ++++---- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/plugins/d3/renderer/components/AxisY.tsx b/src/plugins/d3/renderer/components/AxisY.tsx index 4090d292..9a6b9830 100644 --- a/src/plugins/d3/renderer/components/AxisY.tsx +++ b/src/plugins/d3/renderer/components/AxisY.tsx @@ -23,7 +23,7 @@ import { const b = block('d3-axis'); type Props = { - axises: PreparedAxis[]; + axes: PreparedAxis[]; scale: ChartScale[]; width: number; height: number; diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index 9e8dc036..a3caf421 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -32,7 +32,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(); }, []); diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts index 02b8ddaf..fd7422c0 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts @@ -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 12a4ed5c..de89efff 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts @@ -1,7 +1,7 @@ import type {AxisDomain, AxisScale} from 'd3'; import get from 'lodash/get'; -import type {BaseTextStyle, ChartKitWidgetData, ChartKitWidgetSeries} from '../../../../../types'; +import type {BaseTextStyle, ChartKitWidgetSeries} from '../../../../../types'; import {ChartKitWidgetAxis} from '../../../../../types'; import { DEFAULT_AXIS_LABEL_FONT_SIZE, @@ -86,10 +86,10 @@ export const getPreparedYAxis = ({ yAxis, }: { series: ChartKitWidgetSeries[]; - yAxis: ChartKitWidgetData['yAxis']; + yAxis: ChartKitWidgetAxis[] | undefined; }): PreparedAxis[] => { - const axisByPlot = {}; - const axisItems = yAxis || [{}]; + const axisByPlot: ChartKitWidgetAxis[][] = []; + const axisItems = yAxis || [{} as ChartKitWidgetAxis]; return axisItems.map((axisItem) => { const plotIndex = get(axisItem, 'plotIndex', 0); const firstPlotAxis = !axisByPlot[plotIndex]; From c7c217072634234dca9658df46d3954ed9c0172e Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Thu, 6 Jun 2024 18:15:50 +0300 Subject: [PATCH 3/5] Add plot titles --- .../d3/__stories__/line/Split.stories.tsx | 5 +- src/plugins/d3/renderer/components/AxisX.tsx | 17 ++-- src/plugins/d3/renderer/components/AxisY.tsx | 11 +-- src/plugins/d3/renderer/components/Chart.tsx | 16 +++- .../d3/renderer/components/PlotTitle.tsx | 36 ++++++++ .../d3/renderer/components/styles.scss | 6 ++ src/plugins/d3/renderer/hooks/index.ts | 1 + .../d3/renderer/hooks/useAxisScales/index.ts | 5 +- .../hooks/useChartDimensions/utils.ts | 16 +++- .../d3/renderer/hooks/useShapes/index.tsx | 5 +- .../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 | 9 +- src/plugins/d3/renderer/utils/axis.ts | 13 +-- src/types/widget-data/split.ts | 9 +- 16 files changed, 214 insertions(+), 58 deletions(-) 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 diff --git a/src/plugins/d3/__stories__/line/Split.stories.tsx b/src/plugins/d3/__stories__/line/Split.stories.tsx index 605c582f..0d01654e 100644 --- a/src/plugins/d3/__stories__/line/Split.stories.tsx +++ b/src/plugins/d3/__stories__/line/Split.stories.tsx @@ -60,6 +60,9 @@ const ChartStory = () => { }, []); const widgetData: ChartKitWidgetData = { + title: { + text: 'Chart title', + }, series: { data: prepareData(), }, @@ -67,7 +70,7 @@ const ChartStory = () => { enable: true, layout: 'vertical', gap: '40px', - plots: [{title: {text: 'First'}}, {title: {text: 'Second'}}], + plots: [{title: {text: 'Strategy'}}, {title: {text: 'Shooter & Puzzle'}}], }, yAxis: [ { diff --git a/src/plugins/d3/renderer/components/AxisX.tsx b/src/plugins/d3/renderer/components/AxisX.tsx index a297c3eb..8eb6e214 100644 --- a/src/plugins/d3/renderer/components/AxisX.tsx +++ b/src/plugins/d3/renderer/components/AxisX.tsx @@ -3,13 +3,10 @@ import React from 'react'; import {select} from 'd3'; import type {AxisDomain, AxisScale} from 'd3'; -import type {ChartKitWidgetSplit} from '../../../../types'; import {block} from '../../../../utils/cn'; -import type {ChartScale, PreparedAxis} from '../hooks'; +import type {ChartScale, PreparedAxis, PreparedSplit} from '../hooks'; import { - calculateNumericProperty, formatAxisTickLabel, - getAxisHeight, getClosestPointsRange, getMaxTickCount, getScaleTicks, @@ -25,7 +22,7 @@ type Props = { width: number; height: number; scale: ChartScale; - split?: ChartKitWidgetSplit; + split: PreparedSplit; }; function getLabelFormatter({axis, scale}: {axis: PreparedAxis; scale: ChartScale}) { @@ -48,8 +45,6 @@ function getLabelFormatter({axis, scale}: {axis: PreparedAxis; scale: ChartScale export const AxisX = React.memo(function AxisX(props: Props) { const {axis, width, height: totalHeight, scale, split} = props; const ref = React.useRef(null); - const plotGap = calculateNumericProperty({value: split?.gap, base: totalHeight}) ?? 0; - const height = getAxisHeight({split, boundsHeight: totalHeight}); React.useEffect(() => { if (!ref.current) { @@ -58,8 +53,10 @@ export const AxisX = React.memo(function AxisX(props: Props) { let tickItems: [number, number][] = []; if (axis.grid.enabled) { - tickItems = new Array(split?.plots?.length || 1).fill(null).map((_, index) => { - const top = index * (height + plotGap); + 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)]; }); } @@ -104,7 +101,7 @@ export const AxisX = React.memo(function AxisX(props: Props) { .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 9a6b9830..f511c44f 100644 --- a/src/plugins/d3/renderer/components/AxisY.tsx +++ b/src/plugins/d3/renderer/components/AxisY.tsx @@ -3,12 +3,10 @@ import React from 'react'; import {axisLeft, axisRight, line, select} from 'd3'; import type {Axis, AxisDomain, AxisScale, Selection} from 'd3'; -import type {ChartKitWidgetSplit} from '../../../../types'; import {block} from '../../../../utils/cn'; -import type {ChartScale, PreparedAxis} from '../hooks'; +import type {ChartScale, PreparedAxis, PreparedSplit} from '../hooks'; import { calculateCos, - calculateNumericProperty, calculateSin, formatAxisTickLabel, getAxisHeight, @@ -27,7 +25,7 @@ type Props = { scale: ChartScale[]; width: number; height: number; - split?: ChartKitWidgetSplit; + split: PreparedSplit; }; function transformLabel(args: {node: Element; axis: PreparedAxis}) { @@ -97,7 +95,6 @@ function getAxisGenerator(args: { export const AxisY = (props: Props) => { const {axes, width, height: totalHeight, scale, split} = props; - const splitGap = calculateNumericProperty({value: split?.gap, base: totalHeight}) ?? 0; const height = getAxisHeight({split, boundsHeight: totalHeight}); const ref = React.useRef(null); @@ -115,7 +112,7 @@ export const AxisY = (props: Props) => { .join('g') .attr('class', b()) .style('transform', (d) => { - const top = d.plotIndex * (height + splitGap); + const top = split.plots[d.plotIndex]?.top || 0; if (d.position === 'left') { return `translate(0, ${top}px)`; } @@ -219,7 +216,7 @@ export const AxisY = (props: Props) => { height, ); }); - }, [axes, width, height, scale, splitGap]); + }, [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 a3caf421..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'; @@ -76,13 +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: data.split, + split: preparedSplit, }); const {shapes, shapesData} = useShapes({ boundsWidth, @@ -94,7 +97,7 @@ export const Chart = (props: Props) => { xScale, yAxis, yScale, - split: data.split, + split: preparedSplit, }); const clickHandler = data.chart?.events?.click; @@ -145,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} @@ -157,7 +165,7 @@ export const Chart = (props: Props) => { width={boundsWidth} height={boundsHeight} scale={yScale} - split={data.split} + split={preparedSplit} /> <g transform={`translate(0, ${boundsHeight})`}> <AxisX @@ -165,7 +173,7 @@ export const Chart = (props: Props) => { width={boundsWidth} height={boundsHeight} scale={xScale} - split={data.split} + 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 195509e8..cf014ac6 100644 --- a/src/plugins/d3/renderer/components/styles.scss +++ b/src/plugins/d3/renderer/components/styles.scss @@ -87,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 e3a13b0c..0519e194 100644 --- a/src/plugins/d3/renderer/hooks/useAxisScales/index.ts +++ b/src/plugins/d3/renderer/hooks/useAxisScales/index.ts @@ -4,7 +4,7 @@ import {extent, scaleBand, scaleLinear, scaleUtc} from 'd3'; import type {ScaleBand, ScaleLinear, ScaleTime} from 'd3'; import get from 'lodash/get'; -import {ChartKitWidgetAxis, ChartKitWidgetData, ChartKitWidgetSeries} from '../../../../../types'; +import {ChartKitWidgetAxis, ChartKitWidgetSeries} from '../../../../../types'; import {DEFAULT_AXIS_TYPE} from '../../constants'; import { CHART_SERIES_WITH_VOLUME, @@ -20,6 +20,7 @@ import { import type {AxisDirection} from '../../utils'; import type {PreparedAxis} from '../useChartOptions/types'; import {PreparedSeries} from '../useSeries/types'; +import {PreparedSplit} from '../useSplit/types'; export type ChartScale = | ScaleLinear<number, number> @@ -32,7 +33,7 @@ type Args = { series: PreparedSeries[]; xAxis: PreparedAxis; yAxis: PreparedAxis[]; - split?: ChartKitWidgetData['split']; + split: PreparedSplit; }; type ReturnValue = { 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/useShapes/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/index.tsx index fd7810a6..9ec4c72a 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -11,10 +11,10 @@ import type { PreparedScatterSeries, PreparedSeries, PreparedSeriesOptions, + PreparedSplit, PreparedTreemapSeries, PreparedWaterfallSeries, } from '../'; -import {ChartKitWidgetData} from '../../../../../types'; import {getOnlyVisibleSeries} from '../../utils'; import type {ChartScale} from '../useAxisScales'; import type {PreparedAxis} from '../useChartOptions/types'; @@ -61,7 +61,7 @@ type Args = { yAxis: PreparedAxis[]; xScale?: ChartScale; yScale?: ChartScale[]; - split?: ChartKitWidgetData['split']; + split: PreparedSplit; }; export const useShapes = (args: Args) => { @@ -161,7 +161,6 @@ export const useShapes = (args: Args) => { yAxis, yScale, split, - boundsHeight, }); 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 1eda9fdf..8250e4f7 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/line/prepare-data.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/line/prepare-data.ts @@ -1,14 +1,9 @@ -import {ChartKitWidgetData} from '../../../../../../types'; import type {LabelData} from '../../../types'; -import { - calculateNumericProperty, - getAxisHeight, - getLabelsSize, - getLeftPosition, -} from '../../../utils'; +import {getLabelsSize, getLeftPosition} from '../../../utils'; import {ChartScale} from '../../useAxisScales'; import {PreparedAxis} from '../../useChartOptions/types'; import {PreparedLineSeries} from '../../useSeries/types'; +import {PreparedSplit} from '../../useSplit/types'; import {getXValue, getYValue} from '../utils'; import {MarkerData, PointData, PreparedLineData} from './types'; @@ -48,19 +43,16 @@ export const prepareLineData = (args: { xScale: ChartScale; yAxis: PreparedAxis[]; yScale: ChartScale[]; - split?: ChartKitWidgetData['split']; - boundsHeight: number; + split: PreparedSplit; }): PreparedLineData[] => { - const {series, xAxis, yAxis, xScale, yScale, split, boundsHeight} = args; - const splitGap = calculateNumericProperty({value: split?.gap, base: boundsHeight}) ?? 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 yAxisHeight = getAxisHeight({split, boundsHeight}); - const yAxisTop = seriesYAxis.plotIndex * (yAxisHeight + splitGap); + 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}), 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..aac3d98c --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useSplit/index.ts @@ -0,0 +1,85 @@ +import get from 'lodash/get'; + +import {BaseTextStyle, ChartKitWidgetSplit, PlotOptions} from '../../../../../types'; +import {calculateNumericProperty, getHorisontalSvgTextHeight} from '../../utils'; + +import type {PreparedPlotTitle, PreparedSplit} from './types'; +import {PreparedPlot} from './types'; + +type UseSplitArgs = { + split?: ChartKitWidgetSplit; + boundsHeight: number; + chartWidth: number; +}; + +const DEFAULT_TITLE_FONT_SIZE = '15px'; +const TITLE_PADDINGS = 8 * 2; + +function preparePlotTitle(args: { + title: PlotOptions['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_PADDINGS + : 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 3dc2c807..920e9d73 100644 --- a/src/plugins/d3/renderer/utils/axis-generators/bottom.ts +++ b/src/plugins/d3/renderer/utils/axis-generators/bottom.ts @@ -17,7 +17,6 @@ type AxisBottomArgs = { labelsStyle?: BaseTextStyle; labelsMaxWidth?: number; labelsLineHeight: number; - // size: number; items: [number, number][]; rotation: number; }; @@ -56,7 +55,6 @@ export function axisBottom(args: AxisBottomArgs) { labelsMaxWidth = Infinity, labelsStyle, labelsLineHeight, - // size: tickSize, items: tickItems, count: ticksCount, maxTickCount, @@ -75,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; @@ -114,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 97ffc378..959e6701 100644 --- a/src/plugins/d3/renderer/utils/axis.ts +++ b/src/plugins/d3/renderer/utils/axis.ts @@ -1,9 +1,6 @@ import {AxisDomain, AxisScale, ScaleBand} from 'd3'; -import {ChartKitWidgetData} from '../../../../types'; -import {PreparedAxis} from '../hooks'; - -import {calculateNumericProperty} from './math'; +import {PreparedAxis, PreparedSplit} from '../hooks'; export function getTicksCount({axis, range}: {axis: PreparedAxis; range: number}) { let ticksCount: number | undefined; @@ -69,13 +66,11 @@ export function getMaxTickCount({axis, width}: {axis: PreparedAxis; width: numbe return Math.floor(width / minTickWidth); } -export function getAxisHeight(args: {split: ChartKitWidgetData['split']; boundsHeight: number}) { +export function getAxisHeight(args: {split: PreparedSplit; boundsHeight: number}) { const {split, boundsHeight} = args; - const plots = split?.plots || []; - if (plots.length > 1) { - const splitGap = calculateNumericProperty({value: split?.gap, base: boundsHeight}) ?? 0; - return (boundsHeight - splitGap * (plots.length - 1)) / plots.length; + if (split.plots.length > 1) { + return split.plots[0].height; } return boundsHeight; diff --git a/src/types/widget-data/split.ts b/src/types/widget-data/split.ts index 84d5114d..57fcedef 100644 --- a/src/types/widget-data/split.ts +++ b/src/types/widget-data/split.ts @@ -1,5 +1,10 @@ -type PlotOptions = { - title?: {text: string}; +import type {BaseTextStyle} from './base'; + +export type PlotOptions = { + title?: { + text: string; + style?: Partial<BaseTextStyle>; + }; }; export type ChartKitWidgetSplit = { From 5bca1b0f6c4c16d5117f7d5a165c221b36545c9b Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" <kuzmadom@yandex-team.ru> Date: Thu, 6 Jun 2024 18:55:29 +0300 Subject: [PATCH 4/5] fix import --- src/plugins/d3/__stories__/line/Split.stories.tsx | 2 +- src/plugins/d3/renderer/hooks/useAxisScales/index.ts | 4 ++-- .../d3/renderer/hooks/useShapes/line/prepare-data.ts | 10 +++++----- src/plugins/d3/renderer/hooks/useSplit/index.ts | 5 ++--- src/plugins/d3/renderer/utils/axis.ts | 4 ++-- src/types/widget-data/axis.ts | 7 ++++++- 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/plugins/d3/__stories__/line/Split.stories.tsx b/src/plugins/d3/__stories__/line/Split.stories.tsx index 0d01654e..5485c7cf 100644 --- a/src/plugins/d3/__stories__/line/Split.stories.tsx +++ b/src/plugins/d3/__stories__/line/Split.stories.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {action} from '@storybook/addon-actions'; -import {StoryObj} from '@storybook/react'; +import type {StoryObj} from '@storybook/react'; import {D3Plugin} from '../..'; import {ChartKit} from '../../../../components/ChartKit'; diff --git a/src/plugins/d3/renderer/hooks/useAxisScales/index.ts b/src/plugins/d3/renderer/hooks/useAxisScales/index.ts index 0519e194..3a42826d 100644 --- a/src/plugins/d3/renderer/hooks/useAxisScales/index.ts +++ b/src/plugins/d3/renderer/hooks/useAxisScales/index.ts @@ -19,8 +19,8 @@ import { } from '../../utils'; import type {AxisDirection} from '../../utils'; import type {PreparedAxis} from '../useChartOptions/types'; -import {PreparedSeries} from '../useSeries/types'; -import {PreparedSplit} from '../useSplit/types'; +import type {PreparedSeries} from '../useSeries/types'; +import type {PreparedSplit} from '../useSplit/types'; export type ChartScale = | ScaleLinear<number, number> 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 8250e4f7..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,12 +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 {PreparedSplit} from '../../useSplit/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); diff --git a/src/plugins/d3/renderer/hooks/useSplit/index.ts b/src/plugins/d3/renderer/hooks/useSplit/index.ts index aac3d98c..850e93d4 100644 --- a/src/plugins/d3/renderer/hooks/useSplit/index.ts +++ b/src/plugins/d3/renderer/hooks/useSplit/index.ts @@ -1,10 +1,9 @@ import get from 'lodash/get'; -import {BaseTextStyle, ChartKitWidgetSplit, PlotOptions} from '../../../../../types'; +import type {BaseTextStyle, ChartKitWidgetSplit, PlotOptions} from '../../../../../types'; import {calculateNumericProperty, getHorisontalSvgTextHeight} from '../../utils'; -import type {PreparedPlotTitle, PreparedSplit} from './types'; -import {PreparedPlot} from './types'; +import type {PreparedPlot, PreparedPlotTitle, PreparedSplit} from './types'; type UseSplitArgs = { split?: ChartKitWidgetSplit; diff --git a/src/plugins/d3/renderer/utils/axis.ts b/src/plugins/d3/renderer/utils/axis.ts index 959e6701..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, PreparedSplit} from '../hooks'; +import type {PreparedAxis, PreparedSplit} from '../hooks'; export function getTicksCount({axis, range}: {axis: PreparedAxis; range: number}) { let ticksCount: number | undefined; diff --git a/src/types/widget-data/axis.ts b/src/types/widget-data/axis.ts index 131f21c8..0391f753 100644 --- a/src/types/widget-data/axis.ts +++ b/src/types/widget-data/axis.ts @@ -68,6 +68,11 @@ export type ChartKitWidgetAxis = { * Defaults to 0.05 for Y axis and to 0.01 for X axis. * */ maxPadding?: number; - position?: 'left' | 'right' | 'top' | 'bottom'; + /** Axis location. + * Possible values for the X axis - 'bottom', for Y axis - 'left' and 'right'. + * */ + position?: 'left' | 'right' | 'bottom'; + /** Property for splitting charts. Determines which area the axis is located in. + * */ plotIndex?: number; }; From 21672d3be77818be8a2d9b0ec9b805f62f73188e Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" <kuzmadom@yandex-team.ru> Date: Tue, 11 Jun 2024 16:45:00 +0300 Subject: [PATCH 5/5] fix typings and story --- src/plugins/d3/__stories__/line/Split.stories.tsx | 10 ++++++++-- .../d3/renderer/hooks/useChartOptions/x-axis.ts | 4 ++-- .../d3/renderer/hooks/useChartOptions/y-axis.ts | 11 +++++------ src/plugins/d3/renderer/hooks/useSplit/index.ts | 9 +++++---- src/plugins/d3/renderer/validation/index.ts | 11 ++++++----- src/types/widget-data/axis.ts | 9 +++++++-- src/types/widget-data/index.ts | 8 +++++--- src/types/widget-data/split.ts | 4 ++-- 8 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/plugins/d3/__stories__/line/Split.stories.tsx b/src/plugins/d3/__stories__/line/Split.stories.tsx index 5485c7cf..013e75a1 100644 --- a/src/plugins/d3/__stories__/line/Split.stories.tsx +++ b/src/plugins/d3/__stories__/line/Split.stories.tsx @@ -19,8 +19,9 @@ function prepareData(): LineSeries[] { return games .filter((d) => d.genres.includes(genre)) .map((d) => { + const releaseDate = new Date(d.date as number); return { - x: d.date, + x: releaseDate.getFullYear(), y: d.user_score, label: `${d.title} (${d.user_score})`, custom: d, @@ -83,7 +84,12 @@ const ChartStory = () => { }, ], xAxis: { - type: 'datetime', + type: 'linear', + labels: { + numberFormat: { + showRankDelimiter: false, + }, + }, }, }; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts index fd7422c0..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 => { diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts index de89efff..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, 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,10 @@ export const getPreparedYAxis = ({ yAxis, }: { series: ChartKitWidgetSeries[]; - yAxis: ChartKitWidgetAxis[] | undefined; + yAxis: ChartKitWidgetYAxis[] | undefined; }): PreparedAxis[] => { - const axisByPlot: ChartKitWidgetAxis[][] = []; - const axisItems = yAxis || [{} as ChartKitWidgetAxis]; + const axisByPlot: ChartKitWidgetYAxis[][] = []; + const axisItems = yAxis || [{} as ChartKitWidgetYAxis]; return axisItems.map((axisItem) => { const plotIndex = get(axisItem, 'plotIndex', 0); const firstPlotAxis = !axisByPlot[plotIndex]; diff --git a/src/plugins/d3/renderer/hooks/useSplit/index.ts b/src/plugins/d3/renderer/hooks/useSplit/index.ts index 850e93d4..3a0cc0f4 100644 --- a/src/plugins/d3/renderer/hooks/useSplit/index.ts +++ b/src/plugins/d3/renderer/hooks/useSplit/index.ts @@ -1,6 +1,6 @@ import get from 'lodash/get'; -import type {BaseTextStyle, ChartKitWidgetSplit, PlotOptions} from '../../../../../types'; +import type {BaseTextStyle, ChartKitWidgetSplit, SplitPlotOptions} from '../../../../../types'; import {calculateNumericProperty, getHorisontalSvgTextHeight} from '../../utils'; import type {PreparedPlot, PreparedPlotTitle, PreparedSplit} from './types'; @@ -12,10 +12,10 @@ type UseSplitArgs = { }; const DEFAULT_TITLE_FONT_SIZE = '15px'; -const TITLE_PADDINGS = 8 * 2; +const TITLE_TOP_BOTTOM_PADDING = 8; function preparePlotTitle(args: { - title: PlotOptions['title']; + title: SplitPlotOptions['title']; plotIndex: number; plotHeight: number; chartWidth: number; @@ -28,7 +28,8 @@ function preparePlotTitle(args: { fontWeight: get(title, 'style.fontWeight'), }; const titleHeight = titleText - ? getHorisontalSvgTextHeight({text: titleText, style: titleStyle}) + TITLE_PADDINGS + ? getHorisontalSvgTextHeight({text: titleText, style: titleStyle}) + + TITLE_TOP_BOTTOM_PADDING * 2 : 0; const top = plotIndex * (plotHeight + gap); 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 0391f753..78317dc5 100644 --- a/src/types/widget-data/axis.ts +++ b/src/types/widget-data/axis.ts @@ -68,10 +68,15 @@ export type ChartKitWidgetAxis = { * Defaults to 0.05 for Y axis and to 0.01 for X axis. * */ maxPadding?: number; +}; + +export type ChartKitWidgetXAxis = ChartKitWidgetAxis; + +export type ChartKitWidgetYAxis = ChartKitWidgetAxis & { /** Axis location. - * Possible values for the X axis - 'bottom', for Y axis - 'left' and 'right'. + * Possible values - 'left' and 'right'. * */ - position?: 'left' | 'right' | 'bottom'; + 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 13176235..fd65b7df 100644 --- a/src/types/widget-data/index.ts +++ b/src/types/widget-data/index.ts @@ -1,4 +1,4 @@ -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'; @@ -33,7 +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 index 57fcedef..2115c28e 100644 --- a/src/types/widget-data/split.ts +++ b/src/types/widget-data/split.ts @@ -1,6 +1,6 @@ import type {BaseTextStyle} from './base'; -export type PlotOptions = { +export type SplitPlotOptions = { title?: { text: string; style?: Partial<BaseTextStyle>; @@ -11,5 +11,5 @@ export type ChartKitWidgetSplit = { enable: boolean; layout?: 'vertical'; gap?: string | number; - plots?: PlotOptions[]; + plots?: SplitPlotOptions[]; };