From b4bca3359a217075a47398f5619a7f6f448d6a4d Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Wed, 13 Sep 2023 12:43:53 +0300 Subject: [PATCH 1/4] feat(D3 plugin): add paginated legend --- .../__stories__/scatter/BigLegend.stories.tsx | 87 ++++++ src/plugins/d3/renderer/components/Chart.tsx | 43 ++- src/plugins/d3/renderer/components/Legend.tsx | 273 +++++++++++------- .../d3/renderer/components/styles.scss | 30 ++ .../hooks/useChartDimensions/index.ts | 58 +++- .../hooks/useChartDimensions/utils.ts | 15 + .../renderer/hooks/useChartOptions/chart.ts | 43 +-- .../renderer/hooks/useChartOptions/index.ts | 8 +- .../renderer/hooks/useChartOptions/legend.ts | 21 -- .../renderer/hooks/useChartOptions/types.ts | 6 - .../d3/renderer/hooks/useSeries/constants.ts | 2 +- .../d3/renderer/hooks/useSeries/index.ts | 58 +++- .../hooks/useSeries/prepare-legend.ts | 128 ++++++++ .../renderer/hooks/useSeries/prepareSeries.ts | 2 +- .../d3/renderer/hooks/useSeries/types.ts | 27 ++ src/utils/common.ts | 2 +- src/utils/index.ts | 2 +- 17 files changed, 580 insertions(+), 225 deletions(-) create mode 100644 src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx create mode 100644 src/plugins/d3/renderer/hooks/useChartDimensions/utils.ts delete mode 100644 src/plugins/d3/renderer/hooks/useChartOptions/legend.ts create mode 100644 src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts diff --git a/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx b/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx new file mode 100644 index 00000000..f298e290 --- /dev/null +++ b/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import range from 'lodash/range'; +import random from 'lodash/random'; +import {Meta, Story} from '@storybook/react'; +import {boolean, number} from '@storybook/addon-knobs'; +import {Button} from '@gravity-ui/uikit'; +import {settings} from '../../../../libs'; +import {ChartKit} from '../../../../components/ChartKit'; +import {randomString} from '../../../../utils'; +import type {ChartKitRef} from '../../../../types'; +import type {ChartKitWidgetData, ScatterSeries} from '../../../../types/widget-data'; +import {D3Plugin} from '../..'; + +const TEMPLATE_STRING = '0123456789abcdefghijklmnopqrstuvwxyz'; + +const generateSeriesData = (seriesCount = 5): ScatterSeries[] => { + return range(0, seriesCount).map(() => { + return { + type: 'scatter', + data: [ + { + x: random(0, 1000), + y: random(0, 1000), + }, + ], + name: `${randomString(random(3, 15), TEMPLATE_STRING)}`, + }; + }); +}; + +const shapeData = (): ChartKitWidgetData => { + return { + chart: {margin: {bottom: 0}}, + legend: { + align: 'left', + }, + series: { + data: generateSeriesData(1000), + }, + xAxis: { + grid: { + enabled: boolean('xAxis.grid.enabled', true), + }, + labels: { + enabled: boolean('xAxis.labels.enabled', true), + }, + ticks: { + pixelInterval: number('xAxis.ticks.pixelInterval', 100), + }, + }, + yAxis: [ + { + grid: { + enabled: boolean('yAxis.grid.enabled', true), + }, + labels: { + enabled: boolean('yAxis.labels.enabled', true), + }, + }, + ], + }; +}; + +const Template: Story = () => { + const [shown, setShown] = React.useState(false); + const chartkitRef = React.useRef(); + const data = shapeData(); + + if (!shown) { + settings.set({plugins: [D3Plugin]}); + return ; + } + + return ( +
+ +
+ ); +}; + +export const BigLegend = Template.bind({}); + +const meta: Meta = { + title: 'Plugins/D3/Scatter', +}; + +export default meta; diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index bb6d50d9..30705d56 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -3,20 +3,21 @@ import React from 'react'; import type {ChartKitWidgetData} from '../../../../types'; import {block} from '../../../../utils/cn'; -import {AxisY} from './AxisY'; -import {AxisX} from './AxisX'; -import {Legend} from './Legend'; -import {Title} from './Title'; -import {Tooltip} from './Tooltip'; import { + useAxisScales, useChartDimensions, useChartEvents, useChartOptions, - useAxisScales, useSeries, useShapes, useTooltip, } from '../hooks'; +import {isAxisRelatedSeries} from '../utils'; +import {AxisY} from './AxisY'; +import {AxisX} from './AxisX'; +import {Legend} from './Legend'; +import {Title} from './Title'; +import {Tooltip} from './Tooltip'; import './styles.scss'; @@ -31,18 +32,29 @@ type Props = { }; export const Chart = (props: Props) => { - const {top, left, width, height, data} = props; // FIXME: add data validation + const {top, left, width, height, data} = props; const svgRef = React.createRef(); const {chartHovered, handleMouseEnter, handleMouseLeave} = useChartEvents(); - const {chart, legend, title, tooltip, xAxis, yAxis} = useChartOptions(data); + const {chart, title, tooltip, xAxis, yAxis} = useChartOptions(data); + const {legendItems, legendConfig, preparedSeries, preparedLegend, handleLegendItemClick} = + useSeries({ + chartWidth: width, + chartHeight: height, + chartMargin: chart.margin, + series: data.series, + legend: data.legend, + preparedYAxis: yAxis, + }); const {boundsWidth, boundsHeight} = useChartDimensions({ + hasAxisRelatedSeries: data.series.data.some(isAxisRelatedSeries), width, height, margin: chart.margin, - yAxis, + preparedLegend, + preparedXAxis: xAxis, + preparedYAxis: yAxis, }); - const {preparedSeries, handleLegendItemClick} = useSeries({series: data.series, legend}); const {xScale, yScale} = useAxisScales({ boundsWidth, boundsHeight, @@ -106,14 +118,13 @@ export const Chart = (props: Props) => { )} {shapes} - {legend.enabled && ( + {preparedLegend.enabled && ( )} diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index a946cbf1..4902cb2e 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -1,55 +1,33 @@ import React from 'react'; -import {select, sum} from 'd3'; -import get from 'lodash/get'; +import {select} from 'd3'; +import type {Selection} from 'd3'; import {block} from '../../../../utils/cn'; import type { OnLegendItemClick, PreparedLegend, - PreparedLegendSymbol, PreparedSeries, + LegendItem, + LegendConfig, } from '../hooks'; const b = block('d3-legend'); type Props = { - width: number; - height: number; - legend: PreparedLegend; - offsetWidth: number; - offsetHeight: number; + boundsWidth: number; chartSeries: PreparedSeries[]; + legend: PreparedLegend; + items: LegendItem[][]; + config: LegendConfig; onItemClick: OnLegendItemClick; }; -type LegendItem = { - color: string; - name: string; - visible?: boolean; - symbol: PreparedLegendSymbol; -}; - -const getLegendItems = (series: PreparedSeries[]) => { - return series.reduce((acc, s) => { - const legendEnabled = get(s, 'legend.enabled', true); - - if (legendEnabled) { - acc.push({ - ...s, - symbol: s.legend.symbol, - }); - } - - return acc; - }, []); -}; - -function getLegendPosition(args: { +const getLegendPosition = (args: { align: PreparedLegend['align']; contentWidth: number; width: number; offsetWidth: number; -}) { +}) => { const {align, offsetWidth, width, contentWidth} = args; const top = 0; @@ -62,98 +40,171 @@ function getLegendPosition(args: { } return {top, left: offsetWidth + width / 2 - contentWidth / 2}; -} +}; + +const appendPaginator = (args: { + container: Selection; + offset: number; + maxPage: number; + transform: string; + onArrowClick: (offset: number) => void; +}) => { + const {container, offset, transform, maxPage, onArrowClick} = args; + const paginationLine = container.append('g').attr('class', b('pagination')); + let computedWidth = 0; + paginationLine + .append('text') + .text('▲') + .attr('class', function () { + return b('pagination-arrow', {inactive: offset === 0}); + }) + .each(function () { + computedWidth += this.getComputedTextLength(); + }) + .on('click', function () { + if (offset - 1 >= 0) { + onArrowClick(offset - 1); + } + }); + paginationLine + .append('text') + .text(`${offset + 1}/${maxPage}`) + .attr('class', b('pagination-counter')) + .attr('x', computedWidth) + .each(function () { + computedWidth += this.getComputedTextLength(); + }); + paginationLine + .append('text') + .text('▼') + .attr('class', function () { + return b('pagination-arrow', {inactive: offset === maxPage - 1}); + }) + .attr('x', computedWidth) + .on('click', function () { + if (offset + 1 < maxPage) { + onArrowClick(offset + 1); + } + }); + paginationLine.attr('transform', transform); +}; export const Legend = (props: Props) => { - const {width, offsetWidth, height, offsetHeight, chartSeries, legend, onItemClick} = props; + const {boundsWidth, chartSeries, legend, items, config, onItemClick} = props; const ref = React.useRef(null); + const [paginationOffset, setPaginationOffset] = React.useState(0); + + React.useEffect(() => { + setPaginationOffset(0); + }, [boundsWidth]); React.useEffect(() => { if (!ref.current) { return; } - const legendItems = getLegendItems(chartSeries); - const textWidths: number[] = [0]; const svgElement = select(ref.current); svgElement.selectAll('*').remove(); - const legendItemTemplate = svgElement - .selectAll('legend-history') - .data(legendItems) - .enter() - .append('g') - .attr('class', b('item')) - .on('click', function (e, d) { - onItemClick({name: d.name, metaKey: e.metaKey}); - }); - svgElement - .selectAll('*') - .data(legendItems) - .append('text') - .text(function (d) { - return d.name; - }) - .each(function () { - textWidths.push(this.getComputedTextLength()); - }) - .remove(); - - legendItemTemplate - .append('rect') - .attr('x', function (legendItem, i) { - return ( - i * legendItem.symbol.width + - i * legend.itemDistance + - i * legendItem.symbol.padding + - textWidths.slice(0, i + 1).reduce((acc, tw) => acc + tw, 0) - ); - }) - .attr('y', (legendItem) => offsetHeight - legendItem.symbol.height / 2) - .attr('width', (legendItem) => { - return legendItem.symbol.width; - }) - .attr('height', (legendItem) => legendItem.symbol.height) - .attr('rx', (legendItem) => legendItem.symbol.radius) - .attr('class', b('item-shape')) - .style('fill', function (d) { - return d.color; + const limit = config.pagination?.limit; + const pageItems = + typeof limit === 'number' + ? 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') + .data(line) + .enter() + .append('g') + .attr('class', b('item')) + .on('click', function (e, d) { + onItemClick({name: d.name, metaKey: e.metaKey}); + }); + legendLine + .selectAll('*') + .data(line) + .append('text') + .text(function (d) { + return d.name; + }) + .each(function () { + textWidths.push(this.getComputedTextLength()); + }) + .remove(); + legendItemTemplate + .append('rect') + .attr('x', function (legendItem, i) { + return ( + i * legendItem.symbol.width + + i * legend.itemDistance + + i * legendItem.symbol.padding + + textWidths.slice(0, i).reduce((acc, tw) => acc + tw, 0) + ); + }) + .attr('y', (legendItem) => { + const lineOffset = legend.lineHeight * lineIndex; + return config.offset.top + lineOffset - legendItem.symbol.height / 2; + }) + .attr('width', (legendItem) => { + return legendItem.symbol.width; + }) + .attr('height', (legendItem) => legendItem.symbol.height) + .attr('rx', (legendItem) => legendItem.symbol.radius) + .attr('class', function (d) { + return b('item-shape', {unselected: !d.visible}); + }) + .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) + ); + }) + .attr('y', config.offset.top + legend.lineHeight * lineIndex) + .attr('class', function (d) { + const mods = {selected: d.visible, unselected: !d.visible}; + return b('item-text', mods); + }) + .text(function (d) { + return ('name' in d && d.name) as string; + }) + .style('alignment-baseline', 'middle'); + + const contentWidth = legendLine.node()?.getBoundingClientRect().width || 0; + const {left} = getLegendPosition({ + align: legend.align, + width: boundsWidth, + offsetWidth: config.offset.left, + contentWidth, }); - 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 + 1).reduce((acc, tw) => acc + tw, 0) - ); - }) - .attr('y', offsetHeight) - .attr('class', function (d) { - const mods = {selected: d.visible, unselected: !d.visible}; - return b('item-text', mods); - }) - .text(function (d) { - return ('name' in d && d.name) as string; - }) - .style('alignment-baseline', 'middle'); - - const contentWidth = - sum(textWidths) + - sum(legendItems, (item) => item.symbol.width + item.symbol.padding) + - legend.itemDistance * (legendItems.length - 1); - - const {left} = getLegendPosition({ - align: legend.align, - width, - offsetWidth, - contentWidth, + + legendLine.attr('transform', `translate(${[left, 0].join(',')})`); }); - svgElement.attr('transform', `translate(${[left, 0].join(',')})`); - }, [width, offsetWidth, height, offsetHeight, chartSeries, onItemClick, legend]); + if (config.pagination) { + const transform = `translate(${[ + config.offset.left, + config.offset.top + legend.lineHeight * config.pagination.limit, + ].join(',')})`; + appendPaginator({ + container: svgElement, + offset: paginationOffset, + maxPage: config.pagination.maxPage, + transform, + onArrowClick: setPaginationOffset, + }); + } + }, [boundsWidth, chartSeries, onItemClick, legend, items, config, paginationOffset]); - return ; + return ; }; diff --git a/src/plugins/d3/renderer/components/styles.scss b/src/plugins/d3/renderer/components/styles.scss index b25c5bc8..c3056be1 100644 --- a/src/plugins/d3/renderer/components/styles.scss +++ b/src/plugins/d3/renderer/components/styles.scss @@ -24,9 +24,14 @@ &__item-shape { fill: var(--g-color-base-misc-medium); + + &_unselected { + fill: var(--g-color-text-hint); + } } &__item-text { + font-size: 12px; fill: var(--g-color-text-secondary); &_unselected { @@ -37,6 +42,31 @@ fill: var(--g-color-text-complementary); } } + + &__pagination { + font-size: 12px; + fill: var(--g-color-text-primary); + user-select: none; + } + + &__pagination-counter, + &__pagination-arrow { + alignment-baseline: middle; + } + + &__pagination-arrow { + fill: var(--g-color-text-brand); + cursor: pointer; + + &_inactive { + fill: var(--g-color-base-generic-accent-disabled); + cursor: inherit; + } + + &:hover:not(#{&}_inactive) { + fill: var(--g-color-base-brand-hover); + } + } } .chartkit-d3-title { diff --git a/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts b/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts index a05e7332..5e8ea737 100644 --- a/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts +++ b/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts @@ -1,23 +1,61 @@ import type {ChartMargin} from '../../../../../types/widget-data'; -import type {PreparedAxis} from '../useChartOptions/types'; +import type {PreparedAxis, PreparedLegend} from '../../hooks'; +import {getHorisontalSvgTextHeight} from '../../utils'; +import {getBoundsWidth} from './utils'; + +export {getBoundsWidth} from './utils'; type Args = { + hasAxisRelatedSeries: boolean; width: number; height: number; margin: ChartMargin; - yAxis?: PreparedAxis[]; + preparedLegend: PreparedLegend; + preparedXAxis: PreparedAxis; + preparedYAxis: PreparedAxis[]; +}; + +const getHeightOccupiedByXAxis = (preparedXAxis: PreparedAxis) => { + let height = preparedXAxis.title.height; + + if (preparedXAxis.labels.enabled) { + height += + preparedXAxis.labels.padding + + getHorisontalSvgTextHeight({text: 'Tmp', style: preparedXAxis.labels.style}); + } + + return height; +}; + +const getBottomOffset = (args: { + hasAxisRelatedSeries: boolean; + preparedLegend: PreparedLegend; + preparedXAxis: PreparedAxis; +}) => { + const {hasAxisRelatedSeries, preparedLegend, preparedXAxis} = args; + let result = preparedLegend.height + preparedLegend.paddingTop; + + if (hasAxisRelatedSeries) { + result += getHeightOccupiedByXAxis(preparedXAxis); + } + + return result; }; export const useChartDimensions = (args: Args) => { - const {margin, width, height, yAxis} = args; - const yAxisTitleHeight = - yAxis?.reduce((acc, axis) => { - return acc + (axis.title.height || 0); - }, 0) || 0; - - const boundsWidth = width - margin.right - margin.left - yAxisTitleHeight; - const boundsHeight = height - margin.top - margin.bottom; + const { + hasAxisRelatedSeries, + margin, + width, + height, + preparedLegend, + preparedXAxis, + preparedYAxis, + } = args; + const bottomOffset = getBottomOffset({hasAxisRelatedSeries, preparedLegend, preparedXAxis}); + const boundsWidth = getBoundsWidth({chartWidth: width, chartMargin: margin, preparedYAxis}); + const boundsHeight = height - margin.top - margin.bottom - bottomOffset; return {boundsWidth, boundsHeight}; }; diff --git a/src/plugins/d3/renderer/hooks/useChartDimensions/utils.ts b/src/plugins/d3/renderer/hooks/useChartDimensions/utils.ts new file mode 100644 index 00000000..68bff917 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useChartDimensions/utils.ts @@ -0,0 +1,15 @@ +import type {PreparedAxis, PreparedChart} from '../../hooks'; + +export const getBoundsWidth = (args: { + chartWidth: number; + chartMargin: PreparedChart['margin']; + 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; +}; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts b/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts index d041c42a..c0050f9d 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts @@ -5,15 +5,14 @@ import get from 'lodash/get'; import type {ChartKitWidgetData, ChartKitWidgetSeries} from '../../../../../types/widget-data'; import { + isAxisRelatedSeries, formatAxisTickLabel, getDomainDataYBySeries, getHorisontalSvgTextHeight, - isAxisRelatedSeries, } from '../../utils'; +import type {PreparedAxis, PreparedChart, PreparedTitle} from './types'; -import type {PreparedAxis, PreparedChart, PreparedTitle, PreparedLegend} from './types'; - -const AXIS_WIDTH = 1; +const AXIS_LINE_WIDTH = 1; const getAxisLabelMaxWidth = (args: {axis: PreparedAxis; series: ChartKitWidgetSeries[]}) => { const {axis, series} = args; @@ -35,6 +34,8 @@ const getAxisLabelMaxWidth = (args: {axis: PreparedAxis; series: ChartKitWidgetS case 'linear': { const domain = getDomainDataYBySeries(series) as number[]; maxDomainValue = max(domain) as number; + // maxDomainValue (sometimes) is not the largest value in domain cause of using .nice() method + (maxDomainValue as number) *= 10; } } @@ -82,28 +83,6 @@ const getMarginTop = (args: { return marginTop; }; -const getMarginBottom = (args: { - chart: ChartKitWidgetData['chart']; - hasAxisRelatedSeries: boolean; - preparedLegend: PreparedLegend; - preparedXAxis: PreparedAxis; -}) => { - const {chart, hasAxisRelatedSeries, preparedLegend, preparedXAxis} = args; - let marginBottom = get(chart, 'margin.bottom', 0) + preparedLegend.height; - - if (hasAxisRelatedSeries) { - marginBottom += - preparedXAxis.title.height + - getHorisontalSvgTextHeight({text: 'Tmp', style: preparedXAxis.labels.style}); - - if (preparedXAxis.labels.enabled) { - marginBottom += preparedXAxis.labels.padding; - } - } - - return marginBottom; -}; - const getMarginLeft = (args: { chart: ChartKitWidgetData['chart']; hasAxisRelatedSeries: boolean; @@ -115,7 +94,7 @@ const getMarginLeft = (args: { if (hasAxisRelatedSeries) { marginLeft += - AXIS_WIDTH + + AXIS_LINE_WIDTH + preparedY1Axis.labels.padding + getAxisLabelMaxWidth({axis: preparedY1Axis, series: series.data}) + preparedY1Axis.title.height; @@ -133,20 +112,14 @@ const getMarginRight = (args: {chart: ChartKitWidgetData['chart']}) => { export const getPreparedChart = (args: { chart: ChartKitWidgetData['chart']; series: ChartKitWidgetData['series']; - preparedLegend: PreparedLegend; preparedXAxis: PreparedAxis; preparedY1Axis: PreparedAxis; preparedTitle?: PreparedTitle; }): PreparedChart => { - const {chart, series, preparedLegend, preparedXAxis, preparedY1Axis, preparedTitle} = args; + const {chart, series, preparedXAxis, preparedY1Axis, preparedTitle} = args; const hasAxisRelatedSeries = series.data.some(isAxisRelatedSeries); const marginTop = getMarginTop({chart, hasAxisRelatedSeries, preparedY1Axis, preparedTitle}); - const marginBottom = getMarginBottom({ - chart, - hasAxisRelatedSeries, - preparedLegend, - preparedXAxis, - }); + const marginBottom = get(chart, 'margin.bottom', 0); const marginLeft = getMarginLeft({chart, hasAxisRelatedSeries, series, preparedY1Axis}); const marginRight = getMarginRight({chart}); diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/index.ts b/src/plugins/d3/renderer/hooks/useChartOptions/index.ts index fa9efd96..0ed17960 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/index.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/index.ts @@ -3,7 +3,6 @@ import React from 'react'; import type {ChartKitWidgetData} from '../../../../../types/widget-data'; import {getPreparedChart} from './chart'; -import {getPreparedLegend} from './legend'; import {getPreparedTitle} from './title'; import {getPreparedTooltip} from './tooltip'; import {getPreparedXAxis} from './x-axis'; @@ -13,30 +12,27 @@ import type {ChartOptions} from './types'; type Args = ChartKitWidgetData; export const useChartOptions = (args: Args): ChartOptions => { - const {chart, series, legend, title, tooltip, xAxis, yAxis} = args; + const {chart, series, title, tooltip, xAxis, yAxis} = args; const options: ChartOptions = React.useMemo(() => { const preparedTitle = getPreparedTitle({title}); const preparedTooltip = getPreparedTooltip({tooltip}); - const preparedLegend = getPreparedLegend({legend, series}); const preparedYAxis = getPreparedYAxis({yAxis}); const preparedXAxis = getPreparedXAxis({xAxis}); const preparedChart = getPreparedChart({ chart, series, preparedTitle, - preparedLegend, preparedXAxis, preparedY1Axis: preparedYAxis[0], }); return { chart: preparedChart, - legend: preparedLegend, title: preparedTitle, tooltip: preparedTooltip, xAxis: preparedXAxis, yAxis: preparedYAxis, }; - }, [chart, legend, title, tooltip, series, xAxis, yAxis]); + }, [chart, title, tooltip, series, xAxis, yAxis]); return options; }; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/legend.ts b/src/plugins/d3/renderer/hooks/useChartOptions/legend.ts deleted file mode 100644 index 8d0b3b13..00000000 --- a/src/plugins/d3/renderer/hooks/useChartOptions/legend.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type {ChartKitWidgetData} from '../../../../../types/widget-data'; - -import type {PreparedLegend} from './types'; - -const LEGEND_LINE_HEIGHT = 15; - -export const getPreparedLegend = (args: { - legend: ChartKitWidgetData['legend']; - series: ChartKitWidgetData['series']; -}): PreparedLegend => { - const {legend, series} = args; - const enabled = typeof legend?.enabled === 'boolean' ? legend?.enabled : series.data.length > 1; - const height = enabled ? LEGEND_LINE_HEIGHT : 0; - - return { - align: legend?.align || 'center', - enabled, - itemDistance: legend?.itemDistance || 20, - height, - }; -}; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts index 0962c79f..8d36e1f9 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts @@ -4,7 +4,6 @@ import type { ChartKitWidgetAxis, ChartKitWidgetAxisType, ChartKitWidgetAxisLabels, - ChartKitWidgetLegend, ChartMargin, } from '../../../../../types/widget-data'; @@ -17,10 +16,6 @@ export type PreparedChart = { margin: ChartMargin; }; -export type PreparedLegend = Required & { - height: number; -}; - export type PreparedAxis = Omit & { type: ChartKitWidgetAxisType; labels: PreparedAxisLabels; @@ -49,7 +44,6 @@ export type PreparedTooltip = ChartKitWidgetData['tooltip'] & { export type ChartOptions = { chart: PreparedChart; - legend: PreparedLegend; tooltip: PreparedTooltip; xAxis: PreparedAxis; yAxis: PreparedAxis[]; diff --git a/src/plugins/d3/renderer/hooks/useSeries/constants.ts b/src/plugins/d3/renderer/hooks/useSeries/constants.ts index 318e0c9b..921da7b2 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/constants.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/constants.ts @@ -1 +1 @@ -export const DEFAULT_LEGEND_SYMBOL_SIZE = 10; +export const DEFAULT_LEGEND_SYMBOL_SIZE = 8; diff --git a/src/plugins/d3/renderer/hooks/useSeries/index.ts b/src/plugins/d3/renderer/hooks/useSeries/index.ts index 33149d4a..a6e2c4ce 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/index.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/index.ts @@ -5,35 +5,46 @@ import type {ChartKitWidgetData} from '../../../../../types/widget-data'; import {DEFAULT_PALETTE} from '../../constants'; import {getSeriesNames} from '../../utils'; -import {PreparedLegend} from '../useChartOptions/types'; +import type {PreparedAxis, PreparedChart} from '../useChartOptions/types'; import {getActiveLegendItems, getAllLegendItems} from './utils'; -import {PreparedSeries} from './types'; +import type {PreparedSeries, OnLegendItemClick} from './types'; +import {getPreparedLegend, getLegendComponents} from './prepare-legend'; import {prepareSeries} from './prepareSeries'; -export type OnLegendItemClick = (data: {name: string; metaKey: boolean}) => void; - type Args = { - legend: PreparedLegend; + chartWidth: number; + chartHeight: number; + chartMargin: PreparedChart['margin']; + legend: ChartKitWidgetData['legend']; series: ChartKitWidgetData['series']; + preparedYAxis: PreparedAxis[]; }; export const useSeries = (args: Args) => { const { - series: {data: series}, + chartWidth, + chartHeight, + chartMargin, legend, + preparedYAxis, + series: {data: series}, } = args; + const preparedLegend = React.useMemo( + () => getPreparedLegend({legend, series}), + [legend, series], + ); const preparedSeries = React.useMemo(() => { const seriesNames = getSeriesNames(series); const colorScale = scaleOrdinal(seriesNames, DEFAULT_PALETTE); - const groupedSeries = group(series, (item) => item.type); + return Array.from(groupedSeries).reduce( (acc, [seriesType, seriesList]) => { acc.push( ...prepareSeries({ type: seriesType, series: seriesList, - legend, + legend: preparedLegend, colorScale, }), ); @@ -41,11 +52,10 @@ export const useSeries = (args: Args) => { }, [], ); - }, [series, legend]); + }, [series, preparedLegend]); const [activeLegendItems, setActiveLegendItems] = React.useState( getActiveLegendItems(preparedSeries), ); - const chartSeries = React.useMemo(() => { return preparedSeries.map((singleSeries) => { if (singleSeries.legend.enabled) { @@ -58,11 +68,16 @@ export const useSeries = (args: Args) => { return singleSeries; }); }, [preparedSeries, activeLegendItems]); - - // FIXME: remove effect. It initiates extra rerender - React.useEffect(() => { - setActiveLegendItems(getActiveLegendItems(preparedSeries)); - }, [preparedSeries]); + const {legendConfig, legendItems} = React.useMemo(() => { + return getLegendComponents({ + chartHeight, + chartMargin, + chartWidth, + series: chartSeries, + preparedLegend, + preparedYAxis, + }); + }, [chartWidth, chartHeight, chartMargin, chartSeries, preparedLegend, preparedYAxis]); const handleLegendItemClick: OnLegendItemClick = React.useCallback( ({name, metaKey}) => { @@ -85,5 +100,16 @@ export const useSeries = (args: Args) => { [preparedSeries, activeLegendItems], ); - return {preparedSeries: chartSeries, handleLegendItemClick}; + // FIXME: remove effect. It initiates extra rerender + React.useEffect(() => { + setActiveLegendItems(getActiveLegendItems(preparedSeries)); + }, [preparedSeries]); + + return { + legendItems, + legendConfig, + preparedLegend, + preparedSeries: chartSeries, + handleLegendItemClick, + }; }; diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts new file mode 100644 index 00000000..9dc532bf --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts @@ -0,0 +1,128 @@ +import get from 'lodash/get'; +import {select} from 'd3'; + +import {block} from '../../../../../utils/cn'; +import type {ChartKitWidgetData} from '../../../../../types/widget-data'; + +import {getBoundsWidth} from '../useChartDimensions'; +import type {PreparedAxis, PreparedChart} from '../useChartOptions/types'; +import type {PreparedLegend, PreparedSeries, LegendConfig, LegendItem} from './types'; + +const b = block('d3-legend'); +const LEGEND_LINE_HEIGHT = 15; +const LEGEND_PADDING_TOP = 20; + +export const getPreparedLegend = (args: { + legend: ChartKitWidgetData['legend']; + series: ChartKitWidgetData['series']['data']; +}): PreparedLegend => { + const {legend, series} = args; + const enabled = typeof legend?.enabled === 'boolean' ? legend?.enabled : series.length > 1; + const height = enabled ? LEGEND_LINE_HEIGHT : 0; + + return { + align: legend?.align || 'center', + enabled, + itemDistance: legend?.itemDistance || 20, + height, + lineHeight: LEGEND_LINE_HEIGHT, + paddingTop: LEGEND_PADDING_TOP, + }; +}; + +const getFlattenLegendItems = (series: PreparedSeries[]) => { + return series.reduce((acc, s) => { + const legendEnabled = get(s, 'legend.enabled', true); + + if (legendEnabled) { + acc.push({ + ...s, + symbol: s.legend.symbol, + }); + } + + return acc; + }, []); +}; + +const getGroupedLegendItems = (args: { + boundsWidth: number; + items: LegendItem[]; + preparedLegend: PreparedLegend; +}) => { + const {boundsWidth, items, preparedLegend} = args; + const result: LegendItem[][] = [[]]; + let textWidthsInLine: number[] = [0]; + let lineIndex = 0; + + items.forEach((item) => { + select(document.body) + .append('text') + .text(item.name) + .attr('class', b('item-text')) + .each(function () { + const textWidth = this.getBoundingClientRect().width; + textWidthsInLine.push(textWidth); + const textsWidth = textWidthsInLine.reduce((acc, width) => acc + width, 0); + result[lineIndex].push(item); + const symbolsWidth = result[lineIndex].reduce((acc, {symbol}) => { + return acc + symbol.width + symbol.padding; + }, 0); + const distancesWidth = (result[lineIndex].length - 1) * preparedLegend.itemDistance; + const isOverfilled = boundsWidth < textsWidth + symbolsWidth + distancesWidth; + + if (isOverfilled) { + result[lineIndex].pop(); + lineIndex += 1; + textWidthsInLine = [textWidth]; + const nextLineIndex = lineIndex; + result[nextLineIndex] = []; + result[nextLineIndex].push(item); + } + }) + .remove(); + }); + + return result; +}; + +export const getLegendComponents = (args: { + chartWidth: number; + chartHeight: number; + chartMargin: PreparedChart['margin']; + series: PreparedSeries[]; + preparedLegend: PreparedLegend; + preparedYAxis: PreparedAxis[]; +}) => { + const {chartWidth, chartHeight, chartMargin, series, preparedLegend, preparedYAxis} = args; + const approximatelyBoundsWidth = getBoundsWidth({chartWidth, chartMargin, preparedYAxis}); + const approximatelyBoundsHeight = + (chartHeight - chartMargin.top - chartMargin.bottom - preparedLegend.paddingTop) / 2; + const flattenLegendItems = getFlattenLegendItems(series); + const items = getGroupedLegendItems({ + boundsWidth: approximatelyBoundsWidth, + items: flattenLegendItems, + preparedLegend, + }); + let legendHeight = preparedLegend.lineHeight * items.length; + let pagination: LegendConfig['pagination'] | undefined; + + if (approximatelyBoundsHeight < legendHeight) { + legendHeight = approximatelyBoundsHeight; + const limit = Math.floor(approximatelyBoundsHeight / preparedLegend.lineHeight) - 1; + const capacity = items.slice(0, limit).reduce((acc, line) => { + return acc + line.length; + }, 0); + const allItemsCount = items.reduce((acc, line) => { + return acc + line.length; + }, 0); + const maxPage = Math.ceil(allItemsCount / capacity); + pagination = {limit, maxPage}; + } + + preparedLegend.height = legendHeight; + const top = chartHeight - chartMargin.bottom - preparedLegend.height; + const offset: LegendConfig['offset'] = {left: chartMargin.left, top}; + + return {legendConfig: {offset, pagination}, legendItems: items}; +}; diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts index 3d7435ec..fd378427 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts @@ -6,10 +6,10 @@ import type { PieSeries, RectLegendSymbolOptions, } from '../../../../../types/widget-data'; -import type {PreparedLegend} from '../useChartOptions/types'; import cloneDeep from 'lodash/cloneDeep'; import type { PreparedBarXSeries, + PreparedLegend, PreparedLegendSymbol, PreparedPieSeries, PreparedSeries, diff --git a/src/plugins/d3/renderer/hooks/useSeries/types.ts b/src/plugins/d3/renderer/hooks/useSeries/types.ts index 91c71721..e9ee26e2 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/types.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/types.ts @@ -2,6 +2,7 @@ import { BarXSeries, BarXSeriesData, BaseTextStyle, + ChartKitWidgetLegend, PieSeries, PieSeriesData, RectLegendSymbolOptions, @@ -15,6 +16,32 @@ export type RectLegendSymbol = { export type PreparedLegendSymbol = RectLegendSymbol; +export type PreparedLegend = Required & { + height: number; + lineHeight: number; + paddingTop: number; +}; + +export type OnLegendItemClick = (data: {name: string; metaKey: boolean}) => void; + +export type LegendItem = { + color: string; + name: string; + symbol: PreparedLegendSymbol; + visible?: boolean; +}; + +export type LegendConfig = { + offset: { + left: number; + top: number; + }; + pagination?: { + limit: number; + maxPage: number; + }; +}; + type BasePreparedSeries = { color: string; name: string; diff --git a/src/utils/common.ts b/src/utils/common.ts index d7128d2c..62e45d84 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,4 +1,4 @@ -const randomString = (length: number, chars: string) => { +export const randomString = (length: number, chars: string) => { let result = ''; for (let i = length; i > 0; --i) { result += chars[Math.floor(Math.random() * chars.length)]; diff --git a/src/utils/index.ts b/src/utils/index.ts index cfe2feea..9dc051f6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,3 @@ -export {getRandomCKId} from './common'; +export {getRandomCKId, randomString} from './common'; export {typedMemo} from './react'; export {getChartPerformanceDuration, markChartPerformance} from './performance'; From 05a74e66ddbc892a87653d7d9d3b00e23e432ab4 Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Wed, 13 Sep 2023 12:57:33 +0300 Subject: [PATCH 2/4] fix: getPreparedChart fixes --- src/plugins/d3/renderer/hooks/useChartOptions/chart.ts | 3 +-- src/plugins/d3/renderer/hooks/useChartOptions/index.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts b/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts index c0050f9d..66cb2bb3 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts @@ -112,11 +112,10 @@ const getMarginRight = (args: {chart: ChartKitWidgetData['chart']}) => { export const getPreparedChart = (args: { chart: ChartKitWidgetData['chart']; series: ChartKitWidgetData['series']; - preparedXAxis: PreparedAxis; preparedY1Axis: PreparedAxis; preparedTitle?: PreparedTitle; }): PreparedChart => { - const {chart, series, preparedXAxis, preparedY1Axis, preparedTitle} = args; + const {chart, series, preparedY1Axis, preparedTitle} = args; const hasAxisRelatedSeries = series.data.some(isAxisRelatedSeries); const marginTop = getMarginTop({chart, hasAxisRelatedSeries, preparedY1Axis, preparedTitle}); const marginBottom = get(chart, 'margin.bottom', 0); diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/index.ts b/src/plugins/d3/renderer/hooks/useChartOptions/index.ts index 0ed17960..83e80e06 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/index.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/index.ts @@ -22,7 +22,6 @@ export const useChartOptions = (args: Args): ChartOptions => { chart, series, preparedTitle, - preparedXAxis, preparedY1Axis: preparedYAxis[0], }); return { From d7bdc4f3b9e386a45ca7f5ecc7af3d8cc345c8b4 Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Wed, 13 Sep 2023 21:19:21 +0300 Subject: [PATCH 3/4] fix: review fixes --- .../__stories__/scatter/BigLegend.stories.tsx | 1 - src/plugins/d3/renderer/components/Legend.tsx | 22 +++--- .../d3/renderer/components/styles.scss | 2 - .../d3/renderer/constants/defaults/index.ts | 1 + .../d3/renderer/constants/defaults/legend.ts | 13 ++++ .../{constants.ts => constants/index.ts} | 2 + .../hooks/useChartDimensions/index.ts | 6 +- .../renderer/hooks/useChartOptions/chart.ts | 66 ++-------------- .../renderer/hooks/useChartOptions/index.ts | 2 +- .../renderer/hooks/useChartOptions/types.ts | 1 + .../renderer/hooks/useChartOptions/y-axis.ts | 75 ++++++++++++++++++- .../hooks/useSeries/prepare-legend.ts | 67 +++++++++-------- .../d3/renderer/hooks/useSeries/types.ts | 2 +- src/types/widget-data/legend.ts | 32 ++++++-- 14 files changed, 171 insertions(+), 121 deletions(-) create mode 100644 src/plugins/d3/renderer/constants/defaults/index.ts create mode 100644 src/plugins/d3/renderer/constants/defaults/legend.ts rename src/plugins/d3/renderer/{constants.ts => constants/index.ts} (94%) diff --git a/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx b/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx index f298e290..149cd49a 100644 --- a/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx +++ b/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx @@ -30,7 +30,6 @@ const generateSeriesData = (seriesCount = 5): ScatterSeries[] => { const shapeData = (): ChartKitWidgetData => { return { - chart: {margin: {bottom: 0}}, legend: { align: 'left', }, diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index 4902cb2e..2f0e36ed 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -46,10 +46,11 @@ const appendPaginator = (args: { container: Selection; offset: number; maxPage: number; + legend: PreparedLegend; transform: string; onArrowClick: (offset: number) => void; }) => { - const {container, offset, transform, maxPage, onArrowClick} = args; + const {container, offset, maxPage, legend, transform, onArrowClick} = args; const paginationLine = container.append('g').attr('class', b('pagination')); let computedWidth = 0; paginationLine @@ -58,6 +59,7 @@ const appendPaginator = (args: { .attr('class', function () { return b('pagination-arrow', {inactive: offset === 0}); }) + .style('font-size', legend.itemStyle.fontSize) .each(function () { computedWidth += this.getComputedTextLength(); }) @@ -71,6 +73,7 @@ const appendPaginator = (args: { .text(`${offset + 1}/${maxPage}`) .attr('class', b('pagination-counter')) .attr('x', computedWidth) + .style('font-size', legend.itemStyle.fontSize) .each(function () { computedWidth += this.getComputedTextLength(); }); @@ -81,6 +84,7 @@ const appendPaginator = (args: { return b('pagination-arrow', {inactive: offset === maxPage - 1}); }) .attr('x', computedWidth) + .style('font-size', legend.itemStyle.fontSize) .on('click', function () { if (offset + 1 < maxPage) { onArrowClick(offset + 1); @@ -121,18 +125,10 @@ export const Legend = (props: Props) => { .attr('class', b('item')) .on('click', function (e, d) { onItemClick({name: d.name, metaKey: e.metaKey}); - }); - legendLine - .selectAll('*') - .data(line) - .append('text') - .text(function (d) { - return d.name; - }) - .each(function () { - textWidths.push(this.getComputedTextLength()); }) - .remove(); + .each(function (d) { + textWidths.push(d.textWidth); + }); legendItemTemplate .append('rect') .attr('x', function (legendItem, i) { @@ -178,6 +174,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'); const contentWidth = legendLine.node()?.getBoundingClientRect().width || 0; @@ -200,6 +197,7 @@ export const Legend = (props: Props) => { container: svgElement, offset: paginationOffset, maxPage: config.pagination.maxPage, + legend, transform, onArrowClick: setPaginationOffset, }); diff --git a/src/plugins/d3/renderer/components/styles.scss b/src/plugins/d3/renderer/components/styles.scss index c3056be1..4288f821 100644 --- a/src/plugins/d3/renderer/components/styles.scss +++ b/src/plugins/d3/renderer/components/styles.scss @@ -31,7 +31,6 @@ } &__item-text { - font-size: 12px; fill: var(--g-color-text-secondary); &_unselected { @@ -44,7 +43,6 @@ } &__pagination { - font-size: 12px; fill: var(--g-color-text-primary); user-select: none; } diff --git a/src/plugins/d3/renderer/constants/defaults/index.ts b/src/plugins/d3/renderer/constants/defaults/index.ts new file mode 100644 index 00000000..a0482efa --- /dev/null +++ b/src/plugins/d3/renderer/constants/defaults/index.ts @@ -0,0 +1 @@ +export * from './legend'; diff --git a/src/plugins/d3/renderer/constants/defaults/legend.ts b/src/plugins/d3/renderer/constants/defaults/legend.ts new file mode 100644 index 00000000..680b660e --- /dev/null +++ b/src/plugins/d3/renderer/constants/defaults/legend.ts @@ -0,0 +1,13 @@ +import type {ChartKitWidgetLegend} from '../../../../../types'; + +type LegendDefaults = Required> & + Pick; + +export const legendDefaults: LegendDefaults = { + align: 'center', + itemDistance: 20, + margin: 15, + itemStyle: { + fontSize: '12px', + }, +}; diff --git a/src/plugins/d3/renderer/constants.ts b/src/plugins/d3/renderer/constants/index.ts similarity index 94% rename from src/plugins/d3/renderer/constants.ts rename to src/plugins/d3/renderer/constants/index.ts index 72db1df9..f42e5202 100644 --- a/src/plugins/d3/renderer/constants.ts +++ b/src/plugins/d3/renderer/constants/index.ts @@ -1,3 +1,5 @@ +export * from './defaults'; + export const DEFAULT_PALETTE = [ '#4DA2F1', '#FF3D64', diff --git a/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts b/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts index 5e8ea737..86670215 100644 --- a/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts +++ b/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts @@ -34,7 +34,11 @@ const getBottomOffset = (args: { preparedXAxis: PreparedAxis; }) => { const {hasAxisRelatedSeries, preparedLegend, preparedXAxis} = args; - let result = preparedLegend.height + preparedLegend.paddingTop; + let result = 0; + + if (preparedLegend.enabled) { + result += preparedLegend.height + preparedLegend.margin; + } if (hasAxisRelatedSeries) { result += getHeightOccupiedByXAxis(preparedXAxis); diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts b/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts index 66cb2bb3..fd789c68 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts @@ -1,67 +1,12 @@ -import {select, max} from 'd3'; -import type {AxisDomain} from 'd3'; import get from 'lodash/get'; -import type {ChartKitWidgetData, ChartKitWidgetSeries} from '../../../../../types/widget-data'; +import type {ChartKitWidgetData} from '../../../../../types/widget-data'; -import { - isAxisRelatedSeries, - formatAxisTickLabel, - getDomainDataYBySeries, - getHorisontalSvgTextHeight, -} from '../../utils'; +import {isAxisRelatedSeries, getHorisontalSvgTextHeight} from '../../utils'; import type {PreparedAxis, PreparedChart, PreparedTitle} from './types'; const AXIS_LINE_WIDTH = 1; -const getAxisLabelMaxWidth = (args: {axis: PreparedAxis; series: ChartKitWidgetSeries[]}) => { - const {axis, series} = args; - let maxDomainValue: AxisDomain; - let width = 0; - - switch (axis.type) { - case 'category': { - const yCategories = get(axis, 'categories', [] as string[]); - maxDomainValue = [...yCategories].sort((c1, c2) => c2.length - c1.length)[0]; - break; - } - case 'datetime': { - const yTimestamps = get(axis, 'timestamps'); - const domain = yTimestamps || (getDomainDataYBySeries(series) as number[]); - maxDomainValue = max(domain) as number; - break; - } - case 'linear': { - const domain = getDomainDataYBySeries(series) as number[]; - maxDomainValue = max(domain) as number; - // maxDomainValue (sometimes) is not the largest value in domain cause of using .nice() method - (maxDomainValue as number) *= 10; - } - } - - let formattedValue = ''; - - if (axis.labels.enabled) { - formattedValue = formatAxisTickLabel({ - axisType: axis.type, - value: maxDomainValue, - dateFormat: axis.labels['dateFormat'], - numberFormat: axis.labels['numberFormat'], - }); - } - - select(document.body) - .append('text') - .style('font-size', axis.labels.style.fontSize) - .text(formattedValue) - .each(function () { - width = this.getBoundingClientRect().width; - }) - .remove(); - - return width; -}; - const getMarginTop = (args: { chart: ChartKitWidgetData['chart']; hasAxisRelatedSeries: boolean; @@ -86,17 +31,16 @@ const getMarginTop = (args: { const getMarginLeft = (args: { chart: ChartKitWidgetData['chart']; hasAxisRelatedSeries: boolean; - series: ChartKitWidgetData['series']; preparedY1Axis: PreparedAxis; }) => { - const {chart, hasAxisRelatedSeries, series, preparedY1Axis} = args; + const {chart, hasAxisRelatedSeries, preparedY1Axis} = args; let marginLeft = get(chart, 'margin.left', 0); if (hasAxisRelatedSeries) { marginLeft += AXIS_LINE_WIDTH + preparedY1Axis.labels.padding + - getAxisLabelMaxWidth({axis: preparedY1Axis, series: series.data}) + + (preparedY1Axis.labels.maxWidth || 0) + preparedY1Axis.title.height; } @@ -119,7 +63,7 @@ export const getPreparedChart = (args: { const hasAxisRelatedSeries = series.data.some(isAxisRelatedSeries); const marginTop = getMarginTop({chart, hasAxisRelatedSeries, preparedY1Axis, preparedTitle}); const marginBottom = get(chart, 'margin.bottom', 0); - const marginLeft = getMarginLeft({chart, hasAxisRelatedSeries, series, preparedY1Axis}); + const marginLeft = getMarginLeft({chart, hasAxisRelatedSeries, preparedY1Axis}); 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 83e80e06..1a5d3b7b 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/index.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/index.ts @@ -16,7 +16,7 @@ export const useChartOptions = (args: Args): ChartOptions => { const options: ChartOptions = React.useMemo(() => { const preparedTitle = getPreparedTitle({title}); const preparedTooltip = getPreparedTooltip({tooltip}); - const preparedYAxis = getPreparedYAxis({yAxis}); + const preparedYAxis = getPreparedYAxis({series: series.data, yAxis}); const preparedXAxis = getPreparedXAxis({xAxis}); const preparedChart = getPreparedChart({ chart, diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts index 8d36e1f9..1228e639 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts @@ -10,6 +10,7 @@ import type { type PreparedAxisLabels = Omit & Required> & { style: BaseTextStyle; + maxWidth?: number; }; export type PreparedChart = { diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts index 686599ab..ba311e73 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts @@ -1,16 +1,83 @@ +import {select, max} from 'd3'; +import type {AxisDomain} from 'd3'; import get from 'lodash/get'; -import type {BaseTextStyle, ChartKitWidgetData} from '../../../../../types/widget-data'; +import type { + BaseTextStyle, + ChartKitWidgetData, + ChartKitWidgetSeries, +} from '../../../../../types/widget-data'; import { DEFAULT_AXIS_LABEL_FONT_SIZE, DEFAULT_AXIS_LABEL_PADDING, DEFAULT_AXIS_TITLE_FONT_SIZE, } from '../../constants'; -import {getHorisontalSvgTextHeight} from '../../utils'; +import {getDomainDataYBySeries, getHorisontalSvgTextHeight, formatAxisTickLabel} from '../../utils'; import type {PreparedAxis} from './types'; -export const getPreparedYAxis = ({yAxis}: {yAxis: ChartKitWidgetData['yAxis']}): PreparedAxis[] => { +const getAxisLabelMaxWidth = (args: {axis: PreparedAxis; series: ChartKitWidgetSeries[]}) => { + const {axis, series} = args; + let maxDomainValue: AxisDomain; + let width = 0; + + switch (axis.type) { + case 'category': { + const yCategories = get(axis, 'categories', [] as string[]); + maxDomainValue = [...yCategories].sort((c1, c2) => c2.length - c1.length)[0]; + break; + } + case 'datetime': { + const yTimestamps = get(axis, 'timestamps'); + const domain = yTimestamps || (getDomainDataYBySeries(series) as number[]); + maxDomainValue = max(domain) as number; + break; + } + case 'linear': { + const domain = getDomainDataYBySeries(series) as number[]; + maxDomainValue = max(domain) as number; + } + } + + let formattedValue = ''; + + if (axis.labels.enabled) { + formattedValue = formatAxisTickLabel({ + axisType: axis.type, + value: maxDomainValue, + dateFormat: axis.labels['dateFormat'], + numberFormat: axis.labels['numberFormat'], + }); + } + + select(document.body) + .append('text') + .style('font-size', axis.labels.style.fontSize) + .text(formattedValue) + .each(function () { + width = this.getBoundingClientRect().width; + }) + .remove(); + + return width; +}; + +const applyLabelsMaxWidth = (args: { + series: ChartKitWidgetSeries[]; + preparedYAxis: PreparedAxis; +}) => { + const {series, preparedYAxis} = args; + const maxWidth = getAxisLabelMaxWidth({axis: preparedYAxis, series}); + preparedYAxis.labels.maxWidth = maxWidth; +}; + +export const getPreparedYAxis = ({ + series, + yAxis, +}: { + series: ChartKitWidgetSeries[]; + yAxis: ChartKitWidgetData['yAxis']; +}): PreparedAxis[] => { // FIXME: add support for n axises const yAxis1 = yAxis?.[0]; @@ -50,5 +117,7 @@ export const getPreparedYAxis = ({yAxis}: {yAxis: ChartKitWidgetData['yAxis']}): }, }; + 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 9dc532bf..8549fcd8 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts @@ -1,16 +1,17 @@ +import clone from 'lodash/clone'; import get from 'lodash/get'; +import merge from 'lodash/merge'; import {select} from 'd3'; -import {block} from '../../../../../utils/cn'; import type {ChartKitWidgetData} from '../../../../../types/widget-data'; +import {legendDefaults} from '../../constants'; +import {getHorisontalSvgTextHeight} from '../../utils'; import {getBoundsWidth} from '../useChartDimensions'; import type {PreparedAxis, PreparedChart} from '../useChartOptions/types'; import type {PreparedLegend, PreparedSeries, LegendConfig, LegendItem} from './types'; -const b = block('d3-legend'); -const LEGEND_LINE_HEIGHT = 15; -const LEGEND_PADDING_TOP = 20; +type LegendItemWithoutTextWidth = Omit; export const getPreparedLegend = (args: { legend: ChartKitWidgetData['legend']; @@ -18,20 +19,25 @@ export const getPreparedLegend = (args: { }): PreparedLegend => { const {legend, series} = args; const enabled = typeof legend?.enabled === 'boolean' ? legend?.enabled : series.length > 1; - const height = enabled ? LEGEND_LINE_HEIGHT : 0; + const defaultItemStyle = clone(legendDefaults.itemStyle); + const itemStyle = get(legend, 'itemStyle'); + const computedItemStyle = merge(defaultItemStyle, itemStyle); + const lineHeight = getHorisontalSvgTextHeight({text: 'Tmp', style: computedItemStyle}); + const height = enabled ? lineHeight : 0; return { - align: legend?.align || 'center', + align: get(legend, 'align', legendDefaults.align), enabled, - itemDistance: legend?.itemDistance || 20, height, - lineHeight: LEGEND_LINE_HEIGHT, - paddingTop: LEGEND_PADDING_TOP, + itemDistance: get(legend, 'itemDistance', legendDefaults.itemDistance), + itemStyle: computedItemStyle, + lineHeight, + margin: get(legend, 'margin', legendDefaults.margin), }; }; const getFlattenLegendItems = (series: PreparedSeries[]) => { - return series.reduce((acc, s) => { + return series.reduce((acc, s) => { const legendEnabled = get(s, 'legend.enabled', true); if (legendEnabled) { @@ -46,11 +52,11 @@ const getFlattenLegendItems = (series: PreparedSeries[]) => { }; const getGroupedLegendItems = (args: { - boundsWidth: number; - items: LegendItem[]; + maxLegendWidth: number; + items: LegendItemWithoutTextWidth[]; preparedLegend: PreparedLegend; }) => { - const {boundsWidth, items, preparedLegend} = args; + const {maxLegendWidth, items, preparedLegend} = args; const result: LegendItem[][] = [[]]; let textWidthsInLine: number[] = [0]; let lineIndex = 0; @@ -59,17 +65,19 @@ const getGroupedLegendItems = (args: { select(document.body) .append('text') .text(item.name) - .attr('class', b('item-text')) + .style('font-size', preparedLegend.itemStyle.fontSize) .each(function () { + const resultItem = clone(item) as LegendItem; const textWidth = this.getBoundingClientRect().width; + resultItem.textWidth = textWidth; textWidthsInLine.push(textWidth); const textsWidth = textWidthsInLine.reduce((acc, width) => acc + width, 0); - result[lineIndex].push(item); + result[lineIndex].push(resultItem); const symbolsWidth = result[lineIndex].reduce((acc, {symbol}) => { return acc + symbol.width + symbol.padding; }, 0); const distancesWidth = (result[lineIndex].length - 1) * preparedLegend.itemDistance; - const isOverfilled = boundsWidth < textsWidth + symbolsWidth + distancesWidth; + const isOverfilled = maxLegendWidth < textsWidth + symbolsWidth + distancesWidth; if (isOverfilled) { result[lineIndex].pop(); @@ -77,7 +85,7 @@ const getGroupedLegendItems = (args: { textWidthsInLine = [textWidth]; const nextLineIndex = lineIndex; result[nextLineIndex] = []; - result[nextLineIndex].push(item); + result[nextLineIndex].push(resultItem); } }) .remove(); @@ -95,33 +103,28 @@ export const getLegendComponents = (args: { preparedYAxis: PreparedAxis[]; }) => { const {chartWidth, chartHeight, chartMargin, series, preparedLegend, preparedYAxis} = args; - const approximatelyBoundsWidth = getBoundsWidth({chartWidth, chartMargin, preparedYAxis}); - const approximatelyBoundsHeight = - (chartHeight - chartMargin.top - chartMargin.bottom - preparedLegend.paddingTop) / 2; + const maxLegendWidth = getBoundsWidth({chartWidth, chartMargin, preparedYAxis}); + const maxLegendHeight = + (chartHeight - chartMargin.top - chartMargin.bottom - preparedLegend.margin) / 2; const flattenLegendItems = getFlattenLegendItems(series); const items = getGroupedLegendItems({ - boundsWidth: approximatelyBoundsWidth, + maxLegendWidth, items: flattenLegendItems, preparedLegend, }); let legendHeight = preparedLegend.lineHeight * items.length; let pagination: LegendConfig['pagination'] | undefined; - if (approximatelyBoundsHeight < legendHeight) { - legendHeight = approximatelyBoundsHeight; - const limit = Math.floor(approximatelyBoundsHeight / preparedLegend.lineHeight) - 1; - const capacity = items.slice(0, limit).reduce((acc, line) => { - return acc + line.length; - }, 0); - const allItemsCount = items.reduce((acc, line) => { - return acc + line.length; - }, 0); - const maxPage = Math.ceil(allItemsCount / capacity); + if (maxLegendHeight < legendHeight) { + const limit = Math.floor(maxLegendHeight / preparedLegend.lineHeight); + const maxPage = Math.ceil(items.length / limit); pagination = {limit, maxPage}; + legendHeight = maxLegendHeight; } preparedLegend.height = legendHeight; - const top = chartHeight - chartMargin.bottom - preparedLegend.height; + const top = + chartHeight - chartMargin.bottom - preparedLegend.height + preparedLegend.lineHeight / 2; const offset: LegendConfig['offset'] = {left: chartMargin.left, top}; return {legendConfig: {offset, pagination}, legendItems: items}; diff --git a/src/plugins/d3/renderer/hooks/useSeries/types.ts b/src/plugins/d3/renderer/hooks/useSeries/types.ts index e9ee26e2..066ced75 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/types.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/types.ts @@ -19,7 +19,6 @@ export type PreparedLegendSymbol = RectLegendSymbol; export type PreparedLegend = Required & { height: number; lineHeight: number; - paddingTop: number; }; export type OnLegendItemClick = (data: {name: string; metaKey: boolean}) => void; @@ -28,6 +27,7 @@ export type LegendItem = { color: string; name: string; symbol: PreparedLegendSymbol; + textWidth: number; visible?: boolean; }; diff --git a/src/types/widget-data/legend.ts b/src/types/widget-data/legend.ts index d3a9e241..c1c3f68a 100644 --- a/src/types/widget-data/legend.ts +++ b/src/types/widget-data/legend.ts @@ -1,21 +1,34 @@ +import type {BaseTextStyle} from './base'; + export type ChartKitWidgetLegend = { enabled?: boolean; - /** The horizontal alignment of the legend box within the chart area. + /** + * The horizontal alignment of the legend box within the chart area. * * @default center * */ align?: 'left' | 'center' | 'right'; - /** Defines the pixel distance between each legend item + /** + * Defines the pixel distance between each legend item * * @default 20 * */ itemDistance?: number; + /** CSS styles for each legend item */ + itemStyle?: BaseTextStyle; + /** + * The space between the legend and the axis labels or chart area. + * + * @default 15 + */ + margin?: number; }; export type BaseLegendSymbol = { - /** The pixel padding between the legend item symbol and the legend item text. + /** + * The pixel padding between the legend item symbol and the legend item text. * * @default 5 * */ @@ -23,19 +36,24 @@ export type BaseLegendSymbol = { }; export type RectLegendSymbolOptions = BaseLegendSymbol & { - /** The pixel width of the symbol for series types that use a rectangle in the legend + /** + * The pixel width of the symbol for series types that use a rectangle in the legend * * @default 10 * */ width?: number; - /** The pixel width of the symbol for series types that use a rectangle in the legend + /** + * The pixel width of the symbol for series types that use a rectangle in the legend * * @default 10 * */ height?: number; - /** The border radius of the symbol for series types that use a rectangle in the legend. - * Defaults to half the symbolHeight, effectively creating a circle. */ + /** + * The border radius of the symbol for series types that use a rectangle in the legend. + * + * Defaults to half the symbolHeight, effectively creating a circle. + */ radius?: number; }; From ce75ea2f7c145e86a0fe283365a10a1ccb605cfd Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Wed, 13 Sep 2023 21:39:15 +0300 Subject: [PATCH 4/4] fix: fix big legend story --- .../__stories__/scatter/BigLegend.stories.tsx | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx b/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx index 149cd49a..fb5256d7 100644 --- a/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx +++ b/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import range from 'lodash/range'; import random from 'lodash/random'; import {Meta, Story} from '@storybook/react'; -import {boolean, number} from '@storybook/addon-knobs'; +import {boolean, number, select} from '@storybook/addon-knobs'; import {Button} from '@gravity-ui/uikit'; import {settings} from '../../../../libs'; import {ChartKit} from '../../../../components/ChartKit'; @@ -31,29 +31,22 @@ const generateSeriesData = (seriesCount = 5): ScatterSeries[] => { const shapeData = (): ChartKitWidgetData => { return { legend: { - align: 'left', + align: select('Align', ['left', 'right', 'center'], 'left', 'legend'), + margin: number('Margin', 15, undefined, 'legend'), + itemDistance: number('Item distance', 20, undefined, 'legend'), }, series: { - data: generateSeriesData(1000), + data: generateSeriesData(number('Amount of series', 100, undefined, 'legend')), }, xAxis: { - grid: { - enabled: boolean('xAxis.grid.enabled', true), - }, labels: { - enabled: boolean('xAxis.labels.enabled', true), - }, - ticks: { - pixelInterval: number('xAxis.ticks.pixelInterval', 100), + enabled: boolean('Show labels', true, 'xAxis'), }, }, yAxis: [ { - grid: { - enabled: boolean('yAxis.grid.enabled', true), - }, labels: { - enabled: boolean('yAxis.labels.enabled', true), + enabled: boolean('Show labels', true, 'yAxis'), }, }, ],