diff --git a/src/plugins/d3/__stories__/BarX.stories.tsx b/src/plugins/d3/__stories__/bar-x/BarX.stories.tsx similarity index 86% rename from src/plugins/d3/__stories__/BarX.stories.tsx rename to src/plugins/d3/__stories__/bar-x/BarX.stories.tsx index dc28cfdc..91098eea 100644 --- a/src/plugins/d3/__stories__/BarX.stories.tsx +++ b/src/plugins/d3/__stories__/bar-x/BarX.stories.tsx @@ -2,15 +2,15 @@ import React from 'react'; import {StoryObj} from '@storybook/react'; import {withKnobs} from '@storybook/addon-knobs'; import {Button} from '@gravity-ui/uikit'; -import {settings} from '../../../libs'; -import {D3Plugin} from '..'; +import {settings} from '../../../../libs'; +import {D3Plugin} from '../..'; import { BasicBarXChart, BasicLinearBarXChart, BasicDateTimeBarXChart, -} from '../examples/bar-x/Basic'; -import {GroupedColumns} from '../examples/bar-x/GroupedColumns'; -import {StackedColumns} from '../examples/bar-x/StackedColumns'; +} from '../../examples/bar-x/Basic'; +import {GroupedColumns} from '../../examples/bar-x/GroupedColumns'; +import {StackedColumns} from '../../examples/bar-x/StackedColumns'; const ChartStory = ({Chart}: {Chart: React.FC}) => { const [shown, setShown] = React.useState(false); diff --git a/src/plugins/d3/__stories__/bar-x/Playground.stories.tsx b/src/plugins/d3/__stories__/bar-x/Playground.stories.tsx new file mode 100644 index 00000000..660e89f2 --- /dev/null +++ b/src/plugins/d3/__stories__/bar-x/Playground.stories.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import {StoryObj} from '@storybook/react'; +import {Button} from '@gravity-ui/uikit'; +import {settings} from '../../../../libs'; +import {D3Plugin} from '../..'; +import {ChartKitWidgetData} from '../../../../types'; +import {ChartKit} from '../../../../components/ChartKit'; +import {groups} from 'd3'; +import nintendoGames from '../../examples/nintendoGames'; + +function prepareData(): ChartKitWidgetData { + const gamesByPlatform = groups(nintendoGames, (item) => item['platform']); + const data = gamesByPlatform.map(([value, games]) => ({ + x: value, + y: games.length, + })); + + return { + series: { + data: [ + { + type: 'bar-x', + data, + name: 'Games released', + }, + ], + }, + xAxis: { + type: 'category', + categories: gamesByPlatform.map(([key]) => key), + title: { + text: 'Game Platforms', + }, + labels: { + enabled: true, + rotation: 30, + }, + }, + yAxis: [{title: {text: 'Number of games released'}}], + }; +} + +const ChartStory = ({data}: {data: ChartKitWidgetData}) => { + const [shown, setShown] = React.useState(false); + + if (!shown) { + settings.set({plugins: [D3Plugin]}); + return ; + } + + return ( +
+ +
+ ); +}; + +export const PlaygroundBarXChartStory: StoryObj = { + name: 'Playground', + args: { + data: prepareData(), + }, + argTypes: { + data: { + control: 'object', + }, + }, +}; + +export default { + title: 'Plugins/D3/Bar-X', + component: ChartStory, +}; diff --git a/src/plugins/d3/examples/bar-x/Basic.tsx b/src/plugins/d3/examples/bar-x/Basic.tsx index 789b0ae3..65c4d973 100644 --- a/src/plugins/d3/examples/bar-x/Basic.tsx +++ b/src/plugins/d3/examples/bar-x/Basic.tsx @@ -40,6 +40,7 @@ export const BasicBarXChart = () => { text: 'Game Platforms', }, }, + yAxis: [{title: {text: 'Number of games released'}}], }; return ; diff --git a/src/plugins/d3/renderer/components/AxisX.tsx b/src/plugins/d3/renderer/components/AxisX.tsx index 4c3f0e7c..c804e215 100644 --- a/src/plugins/d3/renderer/components/AxisX.tsx +++ b/src/plugins/d3/renderer/components/AxisX.tsx @@ -59,7 +59,7 @@ export const AxisX = React.memo(({axis, width, height, scale}: Props) => { labelsStyle: axis.labels.style, count: getTicksCount({axis, range: width}), maxTickCount: getMaxTickCount({axis, width}), - autoRotation: axis.labels.autoRotation, + rotation: axis.labels.rotation, }, domain: { size: width, @@ -70,22 +70,19 @@ export const AxisX = React.memo(({axis, width, height, scale}: Props) => { const svgElement = select(ref.current); svgElement.selectAll('*').remove(); - svgElement - .call(xAxisGenerator) - .attr('class', b()) - .style('font-size', axis.labels.style.fontSize); + svgElement.call(xAxisGenerator).attr('class', b()); // add an axis header if necessary if (axis.title.text) { - const textY = - axis.title.height + parseInt(axis.labels.style.fontSize) + axis.labels.padding; + const y = + axis.title.height + axis.title.margin + axis.labels.height + axis.labels.margin; svgElement .append('text') .attr('class', b('title')) .attr('text-anchor', 'middle') .attr('x', width / 2) - .attr('y', textY) + .attr('y', y) .attr('font-size', axis.title.style.fontSize) .text(axis.title.text) .call(setEllipsisForOverflowText, width); diff --git a/src/plugins/d3/renderer/components/AxisY.tsx b/src/plugins/d3/renderer/components/AxisY.tsx index e3345fd9..4899d133 100644 --- a/src/plugins/d3/renderer/components/AxisY.tsx +++ b/src/plugins/d3/renderer/components/AxisY.tsx @@ -100,7 +100,7 @@ export const AxisY = ({axises, width, height, scale}: Props) => { .remove(); if (axis.title.text) { - const textY = axis.title.height + axis.labels.margin; + const textY = axis.title.margin + axis.labels.margin + axis.labels.width; svgElement .append('text') diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index f438cb97..9020b810 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -17,6 +17,9 @@ import {AxisX} from './AxisX'; import {Legend} from './Legend'; import {Title} from './Title'; import {Tooltip, TooltipTriggerArea} from './Tooltip'; +import {getPreparedXAxis} from '../hooks/useChartOptions/x-axis'; +import {getWidthOccupiedByYAxis} from '../hooks/useChartDimensions/utils'; +import {getPreparedYAxis} from '../hooks/useChartOptions/y-axis'; import './styles.scss'; @@ -37,9 +40,19 @@ export const Chart = (props: Props) => { const dispatcher = React.useMemo(() => { return getD3Dispatcher(); }, []); - const {chart, title, tooltip, xAxis, yAxis} = useChartOptions({ + const {chart, title, tooltip} = useChartOptions({ data, }); + const xAxis = React.useMemo( + () => getPreparedXAxis({xAxis: data.xAxis, width, series: data.series.data}), + [data, width], + ); + + const yAxis = React.useMemo( + () => getPreparedYAxis({series: data.series.data, yAxis: data.yAxis}), + [data, width], + ); + const { legendItems, legendConfig, @@ -87,6 +100,9 @@ export const Chart = (props: Props) => { svgContainer: svgRef.current, }); + const boundsOffsetTop = chart.margin.top; + const boundsOffsetLeft = chart.margin.left + getWidthOccupiedByYAxis({preparedAxis: yAxis}); + return ( @@ -94,7 +110,7 @@ export const Chart = (props: Props) => { {xScale && yScale && ( diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index 2f0e36ed..60274ae5 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -53,6 +53,7 @@ const appendPaginator = (args: { const {container, offset, maxPage, legend, transform, onArrowClick} = args; const paginationLine = container.append('g').attr('class', b('pagination')); let computedWidth = 0; + paginationLine .append('text') .text('▲') @@ -115,7 +116,6 @@ export const Legend = (props: Props) => { ? items.slice(paginationOffset * limit, paginationOffset * limit + limit) : items; pageItems.forEach((line, lineIndex) => { - const textWidths: number[] = []; const legendLine = svgElement.append('g').attr('class', b('line')); const legendItemTemplate = legendLine .selectAll('legend-history') @@ -125,23 +125,27 @@ export const Legend = (props: Props) => { .attr('class', b('item')) .on('click', function (e, d) { onItemClick({name: d.name, metaKey: e.metaKey}); - }) - .each(function (d) { - textWidths.push(d.textWidth); }); - legendItemTemplate - .append('rect') - .attr('x', function (legendItem, i) { + + const getXPosition = (i: number) => { + return line.slice(0, i).reduce((acc, legendItem) => { return ( - i * legendItem.symbol.width + - i * legend.itemDistance + - i * legendItem.symbol.padding + - textWidths.slice(0, i).reduce((acc, tw) => acc + tw, 0) + acc + + legendItem.symbol.width + + legendItem.symbol.padding + + legendItem.textWidth + + legend.itemDistance ); + }, 0); + }; + + legendItemTemplate + .append('rect') + .attr('x', function (_d, i) { + return getXPosition(i); }) .attr('y', (legendItem) => { - const lineOffset = legend.lineHeight * lineIndex; - return config.offset.top + lineOffset - legendItem.symbol.height / 2; + return (legend.lineHeight - legendItem.symbol.height) / 2; }) .attr('width', (legendItem) => { return legendItem.symbol.width; @@ -154,19 +158,13 @@ export const Legend = (props: Props) => { .style('fill', function (d) { return d.visible ? d.color : ''; }); + legendItemTemplate .append('text') .attr('x', function (legendItem, i) { - return ( - i * legendItem.symbol.width + - i * legend.itemDistance + - i * legendItem.symbol.padding + - legendItem.symbol.width + - legendItem.symbol.padding + - textWidths.slice(0, i).reduce((acc, tw) => acc + tw, 0) - ); + return getXPosition(i) + legendItem.symbol.width + legendItem.symbol.padding; }) - .attr('y', config.offset.top + legend.lineHeight * lineIndex) + .attr('height', legend.lineHeight) .attr('class', function (d) { const mods = {selected: d.visible, unselected: !d.visible}; return b('item-text', mods); @@ -174,8 +172,7 @@ export const Legend = (props: Props) => { .text(function (d) { return ('name' in d && d.name) as string; }) - .style('font-size', legend.itemStyle.fontSize) - .style('alignment-baseline', 'middle'); + .style('font-size', legend.itemStyle.fontSize); const contentWidth = legendLine.node()?.getBoundingClientRect().width || 0; const {left} = getLegendPosition({ @@ -184,14 +181,17 @@ export const Legend = (props: Props) => { offsetWidth: config.offset.left, contentWidth, }); + const top = config.offset.top + legend.lineHeight * lineIndex; - legendLine.attr('transform', `translate(${[left, 0].join(',')})`); + legendLine.attr('transform', `translate(${[left, top].join(',')})`); }); if (config.pagination) { const transform = `translate(${[ config.offset.left, - config.offset.top + legend.lineHeight * config.pagination.limit, + config.offset.top + + legend.lineHeight * config.pagination.limit + + legend.lineHeight / 2, ].join(',')})`; appendPaginator({ container: svgElement, diff --git a/src/plugins/d3/renderer/components/styles.scss b/src/plugins/d3/renderer/components/styles.scss index 4288f821..dd37ae6a 100644 --- a/src/plugins/d3/renderer/components/styles.scss +++ b/src/plugins/d3/renderer/components/styles.scss @@ -12,6 +12,7 @@ } &__title { + alignment-baseline: after-edge; fill: var(--g-color-text-secondary); } } @@ -32,6 +33,7 @@ &__item-text { fill: var(--g-color-text-secondary); + alignment-baseline: before-edge; &_unselected { fill: var(--g-color-text-hint); diff --git a/src/plugins/d3/renderer/constants/defaults/axis.ts b/src/plugins/d3/renderer/constants/defaults/axis.ts index 11fe72a0..96e4fcd6 100644 --- a/src/plugins/d3/renderer/constants/defaults/axis.ts +++ b/src/plugins/d3/renderer/constants/defaults/axis.ts @@ -3,3 +3,17 @@ export const axisLabelsDefaults = { padding: 10, fontSize: 11, }; + +const axisTitleDefaults = { + fontSize: '14px', +}; + +export const xAxisTitleDefaults = { + ...axisTitleDefaults, + margin: 4, +}; + +export const yAxisTitleDefaults = { + ...axisTitleDefaults, + margin: 8, +}; diff --git a/src/plugins/d3/renderer/constants/index.ts b/src/plugins/d3/renderer/constants/index.ts index 945a2ce3..1c995d20 100644 --- a/src/plugins/d3/renderer/constants/index.ts +++ b/src/plugins/d3/renderer/constants/index.ts @@ -24,4 +24,3 @@ export const DEFAULT_PALETTE = [ ]; export const DEFAULT_AXIS_LABEL_FONT_SIZE = '11px'; -export const DEFAULT_AXIS_TITLE_FONT_SIZE = '14px'; diff --git a/src/plugins/d3/renderer/hooks/useAxisScales/index.ts b/src/plugins/d3/renderer/hooks/useAxisScales/index.ts index 727861db..fdc34b99 100644 --- a/src/plugins/d3/renderer/hooks/useAxisScales/index.ts +++ b/src/plugins/d3/renderer/hooks/useAxisScales/index.ts @@ -3,7 +3,7 @@ import {scaleBand, scaleLinear, scaleUtc, extent} from 'd3'; import type {ScaleBand, ScaleLinear, ScaleTime} from 'd3'; import get from 'lodash/get'; -import type {ChartOptions, PreparedAxis} from '../useChartOptions/types'; +import type {PreparedAxis} from '../useChartOptions/types'; import { getOnlyVisibleSeries, getDataCategoryValue, @@ -14,6 +14,7 @@ import { } from '../../utils'; import type {AxisDirection} from '../../utils'; import {PreparedSeries} from '../useSeries/types'; +import {ChartKitWidgetAxis, ChartKitWidgetSeries} from '../../../../../types'; export type ChartScale = | ScaleLinear @@ -24,8 +25,8 @@ type Args = { boundsWidth: number; boundsHeight: number; series: PreparedSeries[]; - xAxis: ChartOptions['xAxis']; - yAxis: ChartOptions['yAxis']; + xAxis: PreparedAxis; + yAxis: PreparedAxis[]; }; type ReturnValue = { @@ -40,7 +41,7 @@ const isNumericalArrayData = (data: unknown[]): data is number[] => { const filterCategoriesByVisibleSeries = (args: { axisDirection: AxisDirection; categories: string[]; - series: PreparedSeries[]; + series: (PreparedSeries | ChartKitWidgetSeries)[]; }) => { const {axisDirection, categories, series} = args; @@ -109,13 +110,18 @@ export function createYScale(axis: PreparedAxis, series: PreparedSeries[], bound throw new Error('Failed to create yScale'); } -export function createXScale(axis: PreparedAxis, series: PreparedSeries[], boundsWidth: number) { +export function createXScale( + axis: PreparedAxis | ChartKitWidgetAxis, + series: (PreparedSeries | ChartKitWidgetSeries)[], + boundsWidth: number, +) { const xMin = get(axis, 'min'); const xType = get(axis, 'type', 'linear'); const xCategories = get(axis, 'categories'); const xTimestamps = get(axis, 'timestamps'); + const maxPadding = get(axis, 'maxPadding', 0); - const xAxisMinPadding = boundsWidth * axis.maxPadding; + const xAxisMinPadding = boundsWidth * maxPadding; const xRange = [0, boundsWidth - xAxisMinPadding]; switch (xType) { diff --git a/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts b/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts index e04001ca..fcbb003a 100644 --- a/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts +++ b/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts @@ -1,20 +1,8 @@ -import {AxisDomain, AxisScale} from 'd3'; import React from 'react'; import type {ChartMargin} from '../../../../../types'; import type {PreparedAxis, PreparedLegend, PreparedSeries} from '../../hooks'; -import {createXScale} from '../../hooks'; -import { - formatAxisTickLabel, - getClosestPointsRange, - getHorisontalSvgTextHeight, - getLabelsMaxHeight, - getMaxTickCount, - getTicksCount, - getXAxisItems, - hasOverlappingLabels, - isAxisRelatedSeries, -} from '../../utils'; +import {isAxisRelatedSeries} from '../../utils'; import {getBoundsWidth} from './utils'; export {getBoundsWidth} from './utils'; @@ -29,61 +17,12 @@ type Args = { preparedSeries: PreparedSeries[]; }; -const getHeightOccupiedByXAxis = ({ - preparedXAxis, - preparedSeries, - width, -}: { - preparedXAxis: PreparedAxis; - preparedSeries: PreparedSeries[]; - width: number; -}) => { - let height = preparedXAxis.title.height; - - if (preparedXAxis.labels.enabled) { - const scale = createXScale(preparedXAxis, preparedSeries, width); - const tickCount = getTicksCount({axis: preparedXAxis, range: width}); - const ticks = getXAxisItems({ - scale: scale as AxisScale, - count: tickCount, - maxCount: getMaxTickCount({width, axis: preparedXAxis}), - }); - const step = getClosestPointsRange(preparedXAxis, ticks); - const labels = ticks.map((value: AxisDomain) => { - return formatAxisTickLabel({ - axis: preparedXAxis, - value, - step, - }); - }); - const overlapping = hasOverlappingLabels({ - width, - labels, - padding: preparedXAxis.labels.padding, - style: preparedXAxis.labels.style, - }); - - const labelsHeight = overlapping - ? getLabelsMaxHeight({ - labels, - style: preparedXAxis.labels.style, - transform: 'rotate(-45)', - }) - : getHorisontalSvgTextHeight({text: 'Tmp', style: preparedXAxis.labels.style}); - height += preparedXAxis.labels.margin + labelsHeight; - } - - return height; -}; - const getBottomOffset = (args: { hasAxisRelatedSeries: boolean; preparedLegend: PreparedLegend; preparedXAxis: PreparedAxis; - preparedSeries: PreparedSeries[]; - width: number; }) => { - const {hasAxisRelatedSeries, preparedLegend, preparedXAxis, preparedSeries, width} = args; + const {hasAxisRelatedSeries, preparedLegend, preparedXAxis} = args; let result = 0; if (preparedLegend.enabled) { @@ -91,7 +30,13 @@ const getBottomOffset = (args: { } if (hasAxisRelatedSeries) { - result += getHeightOccupiedByXAxis({preparedXAxis, preparedSeries, width}); + if (preparedXAxis.title.text) { + result += preparedXAxis.title.height + preparedXAxis.title.margin; + } + + if (preparedXAxis.labels.enabled) { + result += preparedXAxis.labels.margin + preparedXAxis.labels.height; + } } return result; @@ -108,8 +53,6 @@ export const useChartDimensions = (args: Args) => { hasAxisRelatedSeries, preparedLegend, preparedXAxis, - preparedSeries, - width: boundsWidth, }); const boundsHeight = height - margin.top - margin.bottom - bottomOffset; diff --git a/src/plugins/d3/renderer/hooks/useChartDimensions/utils.ts b/src/plugins/d3/renderer/hooks/useChartDimensions/utils.ts index 68bff917..48211654 100644 --- a/src/plugins/d3/renderer/hooks/useChartDimensions/utils.ts +++ b/src/plugins/d3/renderer/hooks/useChartDimensions/utils.ts @@ -6,10 +6,28 @@ export const getBoundsWidth = (args: { preparedYAxis: PreparedAxis[]; }) => { const {chartWidth, chartMargin, preparedYAxis} = args; - const yAxisTitleHeight = - preparedYAxis.reduce((acc, axis) => { - return acc + (axis.title.height || 0); - }, 0) || 0; - return chartWidth - chartMargin.right - chartMargin.left - yAxisTitleHeight; + return ( + chartWidth - + chartMargin.right - + chartMargin.left - + getWidthOccupiedByYAxis({preparedAxis: preparedYAxis}) + ); }; + +export function getWidthOccupiedByYAxis(args: {preparedAxis: PreparedAxis[]}) { + const {preparedAxis} = args; + let result = 0; + + preparedAxis.forEach((axis) => { + if (axis.title.text) { + result += axis.title.height + axis.title.margin; + } + + if (axis.labels.enabled) { + result += axis.labels.margin + axis.labels.width; + } + }); + + return result; +} diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts b/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts index 9ad778d8..8b71051f 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts @@ -1,26 +1,16 @@ import get from 'lodash/get'; -import type {ChartKitWidgetData} from '../../../../../types/widget-data'; +import type {ChartKitWidgetData} from '../../../../../types'; -import {isAxisRelatedSeries, getHorisontalSvgTextHeight} from '../../utils'; -import type {PreparedAxis, PreparedChart, PreparedTitle} from './types'; - -const AXIS_LINE_WIDTH = 1; +import type {PreparedChart, PreparedTitle} from './types'; const getMarginTop = (args: { chart: ChartKitWidgetData['chart']; - hasAxisRelatedSeries: boolean; - preparedY1Axis: PreparedAxis; preparedTitle?: PreparedTitle; }) => { - const {chart, hasAxisRelatedSeries, preparedY1Axis, preparedTitle} = args; + const {chart, preparedTitle} = args; let marginTop = get(chart, 'margin.top', 0); - if (hasAxisRelatedSeries) { - marginTop += - getHorisontalSvgTextHeight({text: 'Tmp', style: preparedY1Axis.labels.style}) / 2; - } - if (preparedTitle?.height) { marginTop += preparedTitle.height; } @@ -28,25 +18,6 @@ const getMarginTop = (args: { return marginTop; }; -const getMarginLeft = (args: { - chart: ChartKitWidgetData['chart']; - hasAxisRelatedSeries: boolean; - preparedY1Axis: PreparedAxis; -}) => { - const {chart, hasAxisRelatedSeries, preparedY1Axis} = args; - let marginLeft = get(chart, 'margin.left', 0); - - if (hasAxisRelatedSeries) { - marginLeft += - AXIS_LINE_WIDTH + - preparedY1Axis.labels.margin + - (preparedY1Axis.labels.maxWidth || 0) + - preparedY1Axis.title.height; - } - - return marginLeft; -}; - const getMarginRight = (args: {chart: ChartKitWidgetData['chart']}) => { const {chart} = args; @@ -55,15 +26,12 @@ const getMarginRight = (args: {chart: ChartKitWidgetData['chart']}) => { export const getPreparedChart = (args: { chart: ChartKitWidgetData['chart']; - series: ChartKitWidgetData['series']; - preparedY1Axis: PreparedAxis; preparedTitle?: PreparedTitle; }): PreparedChart => { - const {chart, series, preparedY1Axis, preparedTitle} = args; - const hasAxisRelatedSeries = series.data.some(isAxisRelatedSeries); - const marginTop = getMarginTop({chart, hasAxisRelatedSeries, preparedY1Axis, preparedTitle}); + const {chart, preparedTitle} = args; + const marginTop = getMarginTop({chart, preparedTitle}); const marginBottom = get(chart, 'margin.bottom', 0); - const marginLeft = getMarginLeft({chart, hasAxisRelatedSeries, preparedY1Axis}); + const marginLeft = get(chart, 'margin.left', 0); const marginRight = getMarginRight({chart}); return { diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/index.ts b/src/plugins/d3/renderer/hooks/useChartOptions/index.ts index 3c0c098d..e4105b37 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/index.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/index.ts @@ -1,12 +1,10 @@ import React from 'react'; -import type {ChartKitWidgetData} from '../../../../../types/widget-data'; +import type {ChartKitWidgetData} from '../../../../../types'; import {getPreparedChart} from './chart'; import {getPreparedTitle} from './title'; import {getPreparedTooltip} from './tooltip'; -import {getPreparedXAxis} from './x-axis'; -import {getPreparedYAxis} from './y-axis'; import type {ChartOptions} from './types'; type Args = { @@ -15,27 +13,22 @@ type Args = { export const useChartOptions = (args: Args): ChartOptions => { const { - data: {chart, series, title, tooltip, xAxis, yAxis}, + data: {chart, title, tooltip}, } = args; const options: ChartOptions = React.useMemo(() => { const preparedTitle = getPreparedTitle({title}); const preparedTooltip = getPreparedTooltip({tooltip}); - const preparedYAxis = getPreparedYAxis({series: series.data, yAxis}); - const preparedXAxis = getPreparedXAxis({xAxis}); + const preparedChart = getPreparedChart({ chart, - series, preparedTitle, - preparedY1Axis: preparedYAxis[0], }); return { chart: preparedChart, title: preparedTitle, tooltip: preparedTooltip, - xAxis: preparedXAxis, - yAxis: preparedYAxis, }; - }, [chart, title, tooltip, series, xAxis, yAxis]); + }, [chart, title, tooltip]); return options; }; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts index 911c4c06..dd32ebbf 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts @@ -5,12 +5,17 @@ import type { ChartKitWidgetAxisType, ChartKitWidgetAxisLabels, ChartMargin, -} from '../../../../../types/widget-data'; +} from '../../../../../types'; -type PreparedAxisLabels = Omit & - Required> & { +type PreparedAxisLabels = Omit< + ChartKitWidgetAxisLabels, + 'enabled' | 'padding' | 'style' | 'autoRotation' +> & + Required> & { style: BaseTextStyle; - maxWidth?: number; + rotation: number; + height: number; + width: number; }; export type PreparedChart = { @@ -23,6 +28,7 @@ export type PreparedAxis = Omit & { title: { height: number; text: string; + margin: number; style: BaseTextStyle; }; min?: number; @@ -46,7 +52,5 @@ export type PreparedTooltip = ChartKitWidgetData['tooltip'] & { export type ChartOptions = { chart: PreparedChart; tooltip: PreparedTooltip; - xAxis: PreparedAxis; - yAxis: PreparedAxis[]; title?: PreparedTitle; }; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts index 179d1bb3..e3c6f6a0 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/x-axis.ts @@ -1,20 +1,86 @@ import get from 'lodash/get'; - -import type {ChartKitWidgetData} from '../../../../../types/widget-data'; - +import type {AxisDomain, AxisScale} from 'd3'; +import type {BaseTextStyle, ChartKitWidgetSeries, ChartKitWidgetAxis} from '../../../../../types'; import { axisLabelsDefaults, DEFAULT_AXIS_LABEL_FONT_SIZE, - DEFAULT_AXIS_TITLE_FONT_SIZE, + xAxisTitleDefaults, } from '../../constants'; import type {PreparedAxis} from './types'; -import {BaseTextStyle} from '../../../../../types/widget-data'; -import {getHorisontalSvgTextHeight} from '../../utils'; +import { + formatAxisTickLabel, + getClosestPointsRange, + getHorisontalSvgTextHeight, + getLabelsMaxHeight, + getMaxTickCount, + getTicksCount, + getXAxisItems, + hasOverlappingLabels, +} from '../../utils'; +import {createXScale} from '../useAxisScales'; + +function getLabelSettings({ + axis, + series, + width, + autoRotation = true, +}: { + axis: PreparedAxis; + series: ChartKitWidgetSeries[]; + width: number; + autoRotation?: boolean; +}) { + const scale = createXScale(axis, series, width); + const tickCount = getTicksCount({axis, range: width}); + const ticks = getXAxisItems({ + scale: scale as AxisScale, + count: tickCount, + maxCount: getMaxTickCount({width, axis}), + }); + const step = getClosestPointsRange(axis, ticks); + const labels = ticks.map((value: AxisDomain) => { + return formatAxisTickLabel({ + axis, + value, + step, + }); + }); + const overlapping = hasOverlappingLabels({ + width, + labels, + padding: axis.labels.padding, + style: axis.labels.style, + }); + + const defaultRotation = overlapping && autoRotation ? -45 : 0; + const rotation = axis.labels.rotation || defaultRotation; -export const getPreparedXAxis = ({xAxis}: {xAxis: ChartKitWidgetData['xAxis']}): PreparedAxis => { + const labelsHeight = rotation + ? getLabelsMaxHeight({ + labels, + style: { + 'font-size': axis.labels.style.fontSize, + 'font-weight': axis.labels.style.fontWeight || 'normal', + }, + rotation, + }) + : getHorisontalSvgTextHeight({text: 'Tmp', style: axis.labels.style}); + + return {height: labelsHeight, rotation}; +} + +export const getPreparedXAxis = ({ + xAxis, + series, + width, +}: { + xAxis?: ChartKitWidgetAxis; + series: ChartKitWidgetSeries[]; + width: number; +}): PreparedAxis => { const titleText = get(xAxis, 'title.text', ''); const titleStyle: BaseTextStyle = { - fontSize: get(xAxis, 'title.style.fontSize', DEFAULT_AXIS_TITLE_FONT_SIZE), + fontSize: get(xAxis, 'title.style.fontSize', xAxisTitleDefaults.fontSize), }; const preparedXAxis: PreparedAxis = { @@ -25,8 +91,10 @@ export const getPreparedXAxis = ({xAxis}: {xAxis: ChartKitWidgetData['xAxis']}): padding: get(xAxis, 'labels.padding', axisLabelsDefaults.padding), dateFormat: get(xAxis, 'labels.dateFormat'), numberFormat: get(xAxis, 'labels.numberFormat'), - autoRotation: get(xAxis, 'labels.autoRotation', true), + rotation: get(xAxis, 'labels.rotation', 0), style: {fontSize: get(xAxis, 'labels.style.fontSize', DEFAULT_AXIS_LABEL_FONT_SIZE)}, + width: 0, + height: 0, }, lineColor: get(xAxis, 'lineColor'), categories: get(xAxis, 'categories'), @@ -34,6 +102,7 @@ export const getPreparedXAxis = ({xAxis}: {xAxis: ChartKitWidgetData['xAxis']}): title: { text: titleText, style: titleStyle, + margin: get(xAxis, 'title.margin', xAxisTitleDefaults.margin), height: titleText ? getHorisontalSvgTextHeight({text: titleText, style: titleStyle}) : 0, @@ -48,5 +117,15 @@ export const getPreparedXAxis = ({xAxis}: {xAxis: ChartKitWidgetData['xAxis']}): }, }; + const {height, rotation} = getLabelSettings({ + axis: preparedXAxis, + series, + width, + autoRotation: xAxis?.labels?.autoRotation, + }); + + preparedXAxis.labels.height = height; + preparedXAxis.labels.rotation = rotation; + return preparedXAxis; }; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts index 4a0fe149..911ba73d 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts @@ -10,7 +10,7 @@ import type { import { axisLabelsDefaults, DEFAULT_AXIS_LABEL_FONT_SIZE, - DEFAULT_AXIS_TITLE_FONT_SIZE, + yAxisTitleDefaults, } from '../../constants'; import { getHorisontalSvgTextHeight, @@ -46,7 +46,10 @@ const getAxisLabelMaxWidth = (args: {axis: PreparedAxis; series: ChartKitWidgetS return getLabelsMaxWidth({ labels, - style: axis.labels.style, + style: { + 'font-size': axis.labels.style.fontSize, + 'font-weight': axis.labels.style.fontWeight || '', + }, }); }; @@ -56,7 +59,7 @@ const applyLabelsMaxWidth = (args: { }) => { const {series, preparedYAxis} = args; - preparedYAxis.labels.maxWidth = getAxisLabelMaxWidth({axis: preparedYAxis, series}); + preparedYAxis.labels.width = getAxisLabelMaxWidth({axis: preparedYAxis, series}); }; export const getPreparedYAxis = ({ @@ -68,30 +71,34 @@ export const getPreparedYAxis = ({ }): PreparedAxis[] => { // FIXME: add support for n axises const yAxis1 = yAxis?.[0]; + const labelsEnabled = get(yAxis1, 'labels.enabled', true); const y1LabelsStyle: BaseTextStyle = { fontSize: get(yAxis1, 'labels.style.fontSize', DEFAULT_AXIS_LABEL_FONT_SIZE), }; const y1TitleText = get(yAxis1, 'title.text', ''); const y1TitleStyle: BaseTextStyle = { - fontSize: get(yAxis1, 'title.style.fontSize', DEFAULT_AXIS_TITLE_FONT_SIZE), + fontSize: get(yAxis1, 'title.style.fontSize', yAxisTitleDefaults.fontSize), }; const preparedY1Axis: PreparedAxis = { type: get(yAxis1, 'type', 'linear'), labels: { - enabled: get(yAxis1, 'labels.enabled', true), - margin: get(yAxis1, 'labels.margin', axisLabelsDefaults.margin), - padding: get(yAxis1, 'labels.padding', axisLabelsDefaults.padding), - autoRotation: get(yAxis1, 'labels.autoRotation', false), + enabled: labelsEnabled, + margin: labelsEnabled ? get(yAxis1, 'labels.margin', axisLabelsDefaults.margin) : 0, + padding: labelsEnabled ? get(yAxis1, 'labels.padding', axisLabelsDefaults.padding) : 0, dateFormat: get(yAxis1, 'labels.dateFormat'), numberFormat: get(yAxis1, 'labels.numberFormat'), style: y1LabelsStyle, + rotation: 0, + width: 0, + height: 0, }, lineColor: get(yAxis1, 'lineColor'), categories: get(yAxis1, 'categories'), timestamps: get(yAxis1, 'timestamps'), title: { text: y1TitleText, + margin: get(yAxis1, 'title.margin', yAxisTitleDefaults.margin), style: y1TitleStyle, height: y1TitleText ? getHorisontalSvgTextHeight({text: y1TitleText, style: y1TitleStyle}) @@ -107,7 +114,9 @@ export const getPreparedYAxis = ({ }, }; - applyLabelsMaxWidth({series, preparedYAxis: preparedY1Axis}); + if (labelsEnabled) { + applyLabelsMaxWidth({series, preparedYAxis: preparedY1Axis}); + } return [preparedY1Axis]; }; diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts index 8549fcd8..1776e436 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts @@ -10,6 +10,7 @@ import {getHorisontalSvgTextHeight} from '../../utils'; import {getBoundsWidth} from '../useChartDimensions'; import type {PreparedAxis, PreparedChart} from '../useChartOptions/types'; import type {PreparedLegend, PreparedSeries, LegendConfig, LegendItem} from './types'; +import {getWidthOccupiedByYAxis} from '../useChartDimensions/utils'; type LegendItemWithoutTextWidth = Omit; @@ -116,16 +117,19 @@ export const getLegendComponents = (args: { let pagination: LegendConfig['pagination'] | undefined; if (maxLegendHeight < legendHeight) { - const limit = Math.floor(maxLegendHeight / preparedLegend.lineHeight); + // extra line for paginator + const limit = Math.floor(maxLegendHeight / preparedLegend.lineHeight) - 1; const maxPage = Math.ceil(items.length / limit); pagination = {limit, maxPage}; legendHeight = maxLegendHeight; } preparedLegend.height = legendHeight; - const top = - chartHeight - chartMargin.bottom - preparedLegend.height + preparedLegend.lineHeight / 2; - const offset: LegendConfig['offset'] = {left: chartMargin.left, top}; + const top = chartHeight - chartMargin.bottom - preparedLegend.height; + const offset: LegendConfig['offset'] = { + left: chartMargin.left + getWidthOccupiedByYAxis({preparedAxis: preparedYAxis}), + top, + }; return {legendConfig: {offset, pagination}, legendItems: items}; }; diff --git a/src/plugins/d3/renderer/hooks/useShapes/bar-x/prepare-data.ts b/src/plugins/d3/renderer/hooks/useShapes/bar-x/prepare-data.ts index ad789cb5..32d62eab 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/bar-x/prepare-data.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/bar-x/prepare-data.ts @@ -6,7 +6,7 @@ import type {BarXSeriesData, TooltipDataChunkBarX} from '../../../../../../types import {getDataCategoryValue} from '../../../utils'; import type {ChartScale} from '../../useAxisScales'; -import type {ChartOptions} from '../../useChartOptions/types'; +import type {PreparedAxis} from '../../useChartOptions/types'; import type {PreparedBarXSeries, PreparedSeriesOptions} from '../../useSeries/types'; const MIN_RECT_WIDTH = 1; @@ -24,9 +24,9 @@ export type PreparedBarXData = Omit & { export const prepareBarXData = (args: { series: PreparedBarXSeries[]; seriesOptions: PreparedSeriesOptions; - xAxis: ChartOptions['xAxis']; + xAxis: PreparedAxis; xScale: ChartScale; - yAxis: ChartOptions['yAxis']; + yAxis: PreparedAxis[]; yScale: ChartScale; }): PreparedBarXData[] => { const {series, seriesOptions, xAxis, xScale, yScale} = args; diff --git a/src/plugins/d3/renderer/hooks/useShapes/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/index.tsx index 704a2016..04492a2d 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {Dispatch, group} from 'd3'; import {getOnlyVisibleSeries} from '../../utils'; -import type {ChartOptions} from '../useChartOptions/types'; +import type {PreparedAxis} from '../useChartOptions/types'; import type {ChartScale} from '../useAxisScales'; import type { PreparedBarXSeries, @@ -31,8 +31,8 @@ type Args = { dispatcher: Dispatch; series: PreparedSeries[]; seriesOptions: PreparedSeriesOptions; - xAxis: ChartOptions['xAxis']; - yAxis: ChartOptions['yAxis']; + xAxis: PreparedAxis; + yAxis: PreparedAxis[]; svgContainer: SVGSVGElement | null; xScale?: ChartScale; yScale?: ChartScale; diff --git a/src/plugins/d3/renderer/utils/axis-generators/bottom.ts b/src/plugins/d3/renderer/utils/axis-generators/bottom.ts index 7ddc8ea1..63ec83ed 100644 --- a/src/plugins/d3/renderer/utils/axis-generators/bottom.ts +++ b/src/plugins/d3/renderer/utils/axis-generators/bottom.ts @@ -2,7 +2,7 @@ import type {AxisDomain, AxisScale, Selection} from 'd3'; import {select} from 'd3'; import {BaseTextStyle} from '../../../../../types'; import {getXAxisItems, getXAxisOffset, getXTickPosition} from '../axis'; -import {hasOverlappingLabels, setEllipsisForOverflowText} from '../text'; +import {getLabelsMaxHeight, setEllipsisForOverflowText} from '../text'; type AxisBottomArgs = { scale: AxisScale; @@ -14,7 +14,7 @@ type AxisBottomArgs = { labelsMargin?: number; labelsStyle?: BaseTextStyle; size: number; - autoRotation?: boolean; + rotation: number; }; domain: { size: number; @@ -41,6 +41,16 @@ function addDomain( .attr('d', `M0,0V0H${size}`); } +function calculateCos(deg: number, precision = 2) { + const factor = Math.pow(10, precision); + return Math.floor(Math.cos((Math.PI / 180) * deg) * factor) / factor; +} + +function calculateSin(deg: number, precision = 2) { + const factor = Math.pow(10, precision); + return Math.floor(Math.sin((Math.PI / 180) * deg) * factor) / factor; +} + export function axisBottom(args: AxisBottomArgs) { const { scale, @@ -52,19 +62,29 @@ export function axisBottom(args: AxisBottomArgs) { size: tickSize, count: ticksCount, maxTickCount, - autoRotation = true, + rotation, }, domain: {size: domainSize, color: domainColor}, } = args; const offset = getXAxisOffset(); - const spacing = Math.max(tickSize, 0) + labelsMargin; const position = getXTickPosition({scale, offset}); const values = getXAxisItems({scale, count: ticksCount, maxCount: maxTickCount}); + const labelHeight = getLabelsMaxHeight({ + labels: values, + style: {'font-size': labelsStyle?.fontSize || ''}, + }); return function (selection: Selection) { const x = selection.node()?.getBoundingClientRect()?.x || 0; const right = x + domainSize; + let transform = `translate(0, ${labelHeight + labelsMargin}px)`; + if (rotation) { + const labelsOffsetTop = labelHeight * calculateCos(rotation) + labelsMargin; + const labelsOffsetLeft = calculateSin(rotation) * labelHeight; + transform = `translate(${-labelsOffsetLeft}px, ${labelsOffsetTop}px) rotate(${rotation}deg)`; + } + selection .selectAll('.tick') .data(values) @@ -73,10 +93,16 @@ export function axisBottom(args: AxisBottomArgs) { const tick = el.append('g').attr('class', 'tick'); tick.append('line').attr('stroke', 'currentColor').attr('y2', tickSize); tick.append('text') + .text(labelFormat) .attr('fill', 'currentColor') - .attr('y', spacing) - .attr('dy', '0.71em') - .text(labelFormat); + .attr('text-anchor', () => { + if (rotation) { + return rotation > 0 ? 'start' : 'end'; + } + return 'middle'; + }) + .style('transform', transform) + .style('alignment-baseline', 'after-edge'); return tick; }) @@ -93,28 +119,10 @@ export function axisBottom(args: AxisBottomArgs) { .select('line') .remove(); - const labels = selection.selectAll('.tick text'); - const labelNodes = labels.nodes() as SVGTextElement[]; - - const overlapping = hasOverlappingLabels({ - width: domainSize, - labels: values.map(labelFormat), - padding: labelsPaddings, - style: labelsStyle, - }); - - const rotationAngle = overlapping && autoRotation ? '-45' : undefined; - - if (rotationAngle) { - const labelHeight = labelNodes[0]?.getBoundingClientRect()?.height; - const labelOffset = (labelHeight / 2 + labelsMargin) / 2; - labels - .attr('text-anchor', 'end') - .attr( - 'transform', - `rotate(${rotationAngle}) translate(-${labelOffset}, -${labelOffset})`, - ); - } else { + const labels = selection.selectAll('.tick text'); + + // FIXME: handle rotated overlapping labels (with a smarter approach) + if (!rotation) { // remove overlapping labels let elementX = 0; selection @@ -160,7 +168,6 @@ export function axisBottom(args: AxisBottomArgs) { selection .call(addDomain, {size: domainSize, color: domainColor}) - .attr('text-anchor', 'middle') .style('font-size', labelsStyle?.fontSize || ''); }; } diff --git a/src/plugins/d3/renderer/utils/text.ts b/src/plugins/d3/renderer/utils/text.ts index 5b1bf1d4..b1b356cf 100644 --- a/src/plugins/d3/renderer/utils/text.ts +++ b/src/plugins/d3/renderer/utils/text.ts @@ -8,7 +8,7 @@ export function setEllipsisForOverflowText( ) { let text = selection.text(); selection.text(null).append('title').text(text); - const tSpan = selection.append('tspan').text(text); + const tSpan = selection.append('tspan').text(text).style('alignment-baseline', 'inherit'); let textLength = tSpan.node()?.getComputedTextLength() || 0; while (textLength > maxWidth && text.length > 1) { @@ -58,19 +58,24 @@ function renderLabels( selection: Selection, { labels, - style, - transform, + style = {}, + attrs = {}, }: { labels: string[]; - style?: BaseTextStyle; - transform?: string; + style?: Record; + attrs?: Record; }, ) { - const text = selection - .append('g') - .append('text') - .attr('transform', transform || '') - .style('font-size', style?.fontSize || ''); + const text = selection.append('g').append('text'); + + Object.entries(style).forEach(([name, value]) => { + text.style(name, value); + }); + + Object.entries(attrs).forEach(([name, value]) => { + text.attr(name, value); + }); + text.selectAll('tspan') .data(labels) .enter() @@ -88,11 +93,12 @@ export function getLabelsMaxWidth({ transform, }: { labels: string[]; - style?: BaseTextStyle; + style?: Record; transform?: string; }) { const svg = select(document.body).append('svg'); - svg.call(renderLabels, {labels, style, transform}); + const attrs: Record = transform ? {transform: transform} : {}; + svg.call(renderLabels, {labels, style, attrs}); const maxWidth = (svg.select('g').node() as Element)?.getBoundingClientRect()?.width || 0; svg.remove(); @@ -103,14 +109,19 @@ export function getLabelsMaxWidth({ export function getLabelsMaxHeight({ labels, style, - transform, + rotation, }: { labels: string[]; - style?: BaseTextStyle; - transform?: string; + style?: Record; + rotation?: number; }) { const svg = select(document.body).append('svg'); - svg.call(renderLabels, {labels, style, transform}); + const textSelection = renderLabels(svg, {labels, style}); + if (rotation) { + textSelection + .attr('text-anchor', rotation > 0 ? 'start' : 'end') + .style('transform', `rotate(${rotation}deg)`); + } const maxHeight = (svg.select('g').node() as Element)?.getBoundingClientRect()?.height || 0; svg.remove(); diff --git a/src/types/widget-data/axis.ts b/src/types/widget-data/axis.ts index f7af55f6..deb02a1b 100644 --- a/src/types/widget-data/axis.ts +++ b/src/types/widget-data/axis.ts @@ -6,27 +6,28 @@ export type ChartKitWidgetAxisType = 'category' | 'datetime' | 'linear'; export type ChartKitWidgetAxisLabels = { /** Enable or disable the axis labels. */ enabled?: boolean; - /** The label's pixel distance from the perimeter of the plot area. * * @default: 10 */ margin?: number; - /** The pixel padding for axis labels, to ensure white space between them. * * @defaults: 5 * */ padding?: number; - dateFormat?: string; numberFormat?: FormatNumberOptions; style?: Partial; - /** For horizontal axes, enable label rotation to prevent overlapping labels. * If there is enough space, labels are not rotated. * As the chart gets narrower, it will start rotating the labels -45 degrees. */ autoRotation?: boolean; + /** Rotation of the labels in degrees. + * + * @default: 0 + */ + rotation?: number; }; export type ChartKitWidgetAxis = { @@ -39,6 +40,11 @@ export type ChartKitWidgetAxis = { lineColor?: string; title?: { text?: string; + /** The pixel distance between the axis labels or line and the title. + * + * Defaults to 4 for horizontal axes, 8 for vertical. + * */ + margin?: number; }; /** The minimum value of the axis. If undefined the min value is automatically calculate. */ min?: number;