From 95e1e0109c21306812f105fa10b91af838bb6c1d Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Tue, 22 Aug 2023 20:22:42 +0300 Subject: [PATCH 1/5] feat(D3 plugin): add legend options --- src/plugins/d3/renderer/components/Chart.tsx | 7 ++- src/plugins/d3/renderer/components/Legend.tsx | 57 +++++++++++++++---- .../hooks/useChartDimensions/index.ts | 11 ++-- .../renderer/hooks/useChartOptions/legend.ts | 19 ++++++- .../renderer/hooks/useChartOptions/types.ts | 5 +- src/types/widget-data/legend.ts | 36 ++++++++++++ 6 files changed, 111 insertions(+), 24 deletions(-) diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index 64211b0c..237b479c 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -37,7 +37,7 @@ export const Chart = ({width, height, data}: Props) => { const hasAxisRelatedSeries = series.data.some(isAxisRelatedSeries); const {chartHovered, handleMouseEnter, handleMouseLeave} = useChartEvents(); const {chart, legend, title, tooltip, xAxis, yAxis} = useChartOptions(data); - const {boundsWidth, boundsHeight, legendHeight} = useChartDimensions({ + const {boundsWidth, boundsHeight} = useChartDimensions({ width, height, margin: chart.margin, @@ -112,8 +112,9 @@ export const Chart = ({width, height, data}: Props) => { diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index 4f616722..1cd1e6fb 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {select} from 'd3'; import {block} from '../../../../utils/cn'; -import type {ChartSeries, OnLegendItemClick} from '../hooks'; +import type {ChartSeries, OnLegendItemClick, PreparedLegend} from '../hooks'; const b = block('d3-legend'); @@ -11,23 +11,54 @@ type Props = { height: number; offsetWidth: number; offsetHeight: number; + legend: PreparedLegend; chartSeries: ChartSeries[]; onItemClick: OnLegendItemClick; }; +function getSeriesLegendWidth(legend: PreparedLegend, series: ChartSeries) { + let width = 0; + + select(document.body) + .append('text') + .text(series.name) + .each(function () { + width = this.getBoundingClientRect().width; + }) + .remove(); + + return legend.symbol.width + legend.symbol.padding + width; +} + export const Legend = (props: Props) => { - const {width, offsetWidth, height, offsetHeight, chartSeries, onItemClick} = props; + const {width, height, offsetWidth, offsetHeight, chartSeries, onItemClick, legend} = props; + + const left = React.useMemo(() => { + if (legend.align === 'left') { + return offsetWidth; + } + + const contentWidth = + chartSeries.reduce((acc, s) => acc + getSeriesLegendWidth(legend, s), 0) + + (chartSeries.length - 1) * legend.itemDistance; + + if (legend.align === 'right') { + return offsetWidth + width - contentWidth; + } + + return offsetWidth + width / 2 - contentWidth / 2; + }, [chartSeries, legend, offsetWidth, width]); return ( { if (!node) { return; } - const size = 10; const textWidths: number[] = [0]; const svgElement = select(node); svgElement.selectAll('*').remove(); @@ -56,14 +87,16 @@ export const Legend = (props: Props) => { .append('rect') .attr('x', function (_d, i) { return ( - offsetWidth + - i * size + + i * legend.symbol.width + + i * legend.itemDistance + + i * legend.symbol.padding + textWidths.slice(0, i + 1).reduce((acc, tw) => acc + tw, 0) ); }) - .attr('y', offsetHeight - size / 2) - .attr('width', size) - .attr('height', size) + .attr('y', offsetHeight - legend.symbol.height / 2) + .attr('width', legend.symbol.width) + .attr('height', legend.symbol.height) + .attr('rx', legend.symbol.radius) .style('fill', function (d) { return d.color; }); @@ -71,9 +104,11 @@ export const Legend = (props: Props) => { .append('text') .attr('x', function (_d, i) { return ( - offsetWidth + - i * size + - size + + i * legend.symbol.width + + i * legend.itemDistance + + i * legend.symbol.padding + + legend.symbol.width + + legend.symbol.padding + textWidths.slice(0, i + 1).reduce((acc, tw) => acc + tw, 0) ); }) diff --git a/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts b/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts index af9613c0..4cc5fb75 100644 --- a/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts +++ b/src/plugins/d3/renderer/hooks/useChartDimensions/index.ts @@ -1,14 +1,12 @@ import type {ChartMargin} from '../../../../../types/widget-data'; -import type {ChartOptions, PreparedAxis, PreparedTitle} from '../useChartOptions/types'; - -const LEGEND_LINE_HEIGHT = 15; +import type {PreparedAxis, PreparedLegend, PreparedTitle} from '../useChartOptions/types'; type Args = { width: number; height: number; margin: ChartMargin; - legend: ChartOptions['legend']; + legend: PreparedLegend; title?: PreparedTitle; xAxis?: PreparedAxis; yAxis?: PreparedAxis[]; @@ -16,7 +14,6 @@ type Args = { export const useChartDimensions = (args: Args) => { const {margin, legend, title, width, height, xAxis, yAxis} = args; - const legendHeight = legend.enabled ? LEGEND_LINE_HEIGHT : 0; const titleHeight = title?.height || 0; const xAxisTitleHeight = xAxis?.title.height || 0; const yAxisTitleHeight = @@ -26,7 +23,7 @@ export const useChartDimensions = (args: Args) => { const boundsWidth = width - margin.right - margin.left - yAxisTitleHeight; const boundsHeight = - height - margin.top - margin.bottom - legendHeight - titleHeight - xAxisTitleHeight; + height - margin.top - margin.bottom - legend.height - titleHeight - xAxisTitleHeight; - return {boundsWidth, boundsHeight, legendHeight}; + return {boundsWidth, boundsHeight, legendHeight: legend.height}; }; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/legend.ts b/src/plugins/d3/renderer/hooks/useChartOptions/legend.ts index a1f9ee33..b4e21e16 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/legend.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/legend.ts @@ -2,14 +2,29 @@ import type {ChartKitWidgetData} from '../../../../../types/widget-data'; import type {PreparedLegend} from './types'; +const LEGEND_LINE_HEIGHT = 15; +const LEGEND_SYMBOL_SIZE = 10; + export const getPreparedLegend = (args: { legend: ChartKitWidgetData['legend']; series: ChartKitWidgetData['series']; }): PreparedLegend => { const {legend, series} = args; - const enabled = legend?.enabled; + const enabled = typeof legend?.enabled === 'boolean' ? legend?.enabled : series.data.length > 1; + const height = enabled ? LEGEND_LINE_HEIGHT : 0; + const symbolHeight = legend?.symbol?.height || LEGEND_SYMBOL_SIZE; return { - enabled: typeof enabled === 'boolean' ? enabled : series.data.length > 1, + align: legend?.align || 'center', + enabled, + itemDistance: legend?.itemDistance || 20, + symbol: { + width: legend?.symbol?.width || LEGEND_SYMBOL_SIZE, + height: symbolHeight, + radius: legend?.symbol?.width || symbolHeight / 2, + padding: legend?.symbol?.padding || 5, + }, + + height, }; }; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts index bcf561ef..f57816d8 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts @@ -17,7 +17,10 @@ export type PreparedChart = { margin: ChartMargin; }; -export type PreparedLegend = Required; +export type PreparedLegend = Required & { + symbol: Required; + height: number; +}; export type PreparedAxis = Omit & { type: ChartKitWidgetAxisType; diff --git a/src/types/widget-data/legend.ts b/src/types/widget-data/legend.ts index 206d6684..3c469b88 100644 --- a/src/types/widget-data/legend.ts +++ b/src/types/widget-data/legend.ts @@ -1,3 +1,39 @@ export type ChartKitWidgetLegend = { enabled?: boolean; + + /** 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 + * + * @default 20 + * */ + itemDistance?: number; + + symbol?: { + /** 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 + * + * @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. */ + radius?: number; + + /** The pixel padding between the legend item symbol and the legend item text. + * + * @default 5 + * */ + padding?: number; + }; }; From 5873ddb7301eeebbe9a1222df32857feda28859d Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Thu, 24 Aug 2023 16:08:04 +0300 Subject: [PATCH 2/5] fix legend --- src/plugins/d3/renderer/components/Legend.tsx | 64 +++++++++++++++---- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index cdaa36cb..64140902 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import {select} from 'd3'; +import {select, sum} from 'd3'; import get from 'lodash/get'; import {block} from '../../../../utils/cn'; -import type {ChartSeries, OnLegendItemClick} from '../hooks'; +import type {ChartSeries, OnLegendItemClick, PreparedLegend} from '../hooks'; import {isAxisRelatedSeries} from '../utils'; const b = block('d3-legend'); @@ -11,6 +11,7 @@ const b = block('d3-legend'); type Props = { width: number; height: number; + legend: PreparedLegend; offsetWidth: number; offsetHeight: number; chartSeries: ChartSeries[]; @@ -34,8 +35,28 @@ const getLegendItems = (series: ChartSeries[]) => { }, []); }; +function getLegendPosition(args: { + align: PreparedLegend['align']; + contentWidth: number; + width: number; + offsetWidth: number; +}) { + const {align, offsetWidth, width, contentWidth} = args; + const top = 0; + + if (align === 'left') { + return {top, left: offsetWidth}; + } + + if (align === 'right') { + return {top, left: offsetWidth + width - contentWidth}; + } + + return {top, left: offsetWidth + width / 2 - contentWidth / 2}; +} + export const Legend = (props: Props) => { - const {width, offsetWidth, height, offsetHeight, chartSeries, onItemClick} = props; + const {width, offsetWidth, height, offsetHeight, chartSeries, legend, onItemClick} = props; const ref = React.useRef(null); React.useEffect(() => { @@ -44,7 +65,6 @@ export const Legend = (props: Props) => { } const legendItems = getLegendItems(chartSeries); - const size = 10; const textWidths: number[] = [0]; const svgElement = select(ref.current); svgElement.selectAll('*').remove(); @@ -73,14 +93,16 @@ export const Legend = (props: Props) => { .append('rect') .attr('x', function (_d, i) { return ( - offsetWidth + - i * size + + i * legend.symbol.width + + i * legend.itemDistance + + i * legend.symbol.padding + textWidths.slice(0, i + 1).reduce((acc, tw) => acc + tw, 0) ); }) - .attr('y', offsetHeight - size / 2) - .attr('width', size) - .attr('height', size) + .attr('y', offsetHeight - legend.symbol.height / 2) + .attr('width', legend.symbol.width) + .attr('height', legend.symbol.height) + .attr('rx', legend.symbol.radius) .attr('class', b('item-shape')) .style('fill', function (d) { return d.color; @@ -89,9 +111,11 @@ export const Legend = (props: Props) => { .append('text') .attr('x', function (_d, i) { return ( - offsetWidth + - i * size + - size + + i * legend.symbol.width + + i * legend.itemDistance + + i * legend.symbol.padding + + legend.symbol.width + + legend.symbol.padding + textWidths.slice(0, i + 1).reduce((acc, tw) => acc + tw, 0) ); }) @@ -104,7 +128,21 @@ export const Legend = (props: Props) => { return ('name' in d && d.name) as string; }) .style('alignment-baseline', 'middle'); - }, [width, offsetWidth, height, offsetHeight, chartSeries, onItemClick]); + + const contentWidth = + sum(textWidths) + + (legend.symbol.width + legend.symbol.padding) * legendItems.length + + legend.itemDistance * (legendItems.length - 1); + + const {left} = getLegendPosition({ + align: legend.align, + width, + offsetWidth, + contentWidth, + }); + + svgElement.attr('transform', `translate(${[left, 0].join(',')})`); + }, [width, offsetWidth, height, offsetHeight, chartSeries, onItemClick, legend]); return ; }; From 889d86f951935022a1eb22d318091f78d9d08705 Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Thu, 24 Aug 2023 18:39:42 +0300 Subject: [PATCH 3/5] fix legend symbol --- src/plugins/d3/renderer/components/Chart.tsx | 9 ++-- src/plugins/d3/renderer/components/Legend.tsx | 52 +++++++++++------- .../renderer/hooks/useChartOptions/index.ts | 3 ++ .../renderer/hooks/useChartOptions/legend.ts | 9 ---- .../renderer/hooks/useChartOptions/series.ts | 48 +++++++++++++++++ .../renderer/hooks/useChartOptions/types.ts | 10 +++- .../d3/renderer/hooks/useSeries/index.ts | 3 +- src/types/widget-data/bar-x.ts | 6 +++ src/types/widget-data/base.ts | 4 -- src/types/widget-data/legend.ts | 54 +++++++++++-------- src/types/widget-data/pie.ts | 6 +++ src/types/widget-data/scatter.ts | 6 +++ 12 files changed, 149 insertions(+), 61 deletions(-) create mode 100644 src/plugins/d3/renderer/hooks/useChartOptions/series.ts diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index a5aac826..7cb085dd 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -35,11 +35,9 @@ type Props = { export const Chart = (props: Props) => { const {top, left, width, height, data} = props; // FIXME: add data validation - const {series} = data; const svgRef = React.createRef(); - const hasAxisRelatedSeries = series.data.some(isAxisRelatedSeries); const {chartHovered, handleMouseEnter, handleMouseLeave} = useChartEvents(); - const {chart, legend, title, tooltip, xAxis, yAxis} = useChartOptions(data); + const {chart, legend, title, tooltip, xAxis, yAxis, series} = useChartOptions(data); const {boundsWidth, boundsHeight} = useChartDimensions({ width, height, @@ -49,8 +47,8 @@ export const Chart = (props: Props) => { xAxis, yAxis, }); - const {activeLegendItems, handleLegendItemClick} = useLegend({series: series.data}); - const {chartSeries} = useSeries({activeLegendItems, series: series.data}); + const {activeLegendItems, handleLegendItemClick} = useLegend({series: series}); + const {chartSeries} = useSeries({activeLegendItems, series: series}); const {xScale, yScale} = useAxisScales({ boundsWidth, boundsHeight, @@ -75,6 +73,7 @@ export const Chart = (props: Props) => { onSeriesMouseMove: handleSeriesMouseMove, onSeriesMouseLeave: handleSeriesMouseLeave, }); + const hasAxisRelatedSeries = series.some(isAxisRelatedSeries); return ( diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index 64140902..4dc82469 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -5,6 +5,7 @@ import get from 'lodash/get'; import {block} from '../../../../utils/cn'; import type {ChartSeries, OnLegendItemClick, PreparedLegend} from '../hooks'; import {isAxisRelatedSeries} from '../utils'; +import {LegendSymbol} from '../../../../types/widget-data'; const b = block('d3-legend'); @@ -18,17 +19,30 @@ type Props = { onItemClick: OnLegendItemClick; }; -type LegendItem = {color: string; name: string; visible?: boolean}; +type LegendItem = { + color: string; + name: string; + visible?: boolean; + legend: {enabled: boolean; symbol: LegendSymbol}; +}; const getLegendItems = (series: ChartSeries[]) => { return series.reduce((acc, s) => { const isAxisRelated = isAxisRelatedSeries(s); const legendEnabled = get(s, 'legend.enabled', true); - if (isAxisRelated) { - acc.push(s); - } else if (!isAxisRelated && legendEnabled) { - acc.push(...(s.data as LegendItem[])); + if (legendEnabled) { + if (isAxisRelated) { + acc.push(s as LegendItem); + } else { + const legendItems = s.data.map((item) => { + return { + ...item, + legend: s.legend, + } as LegendItem; + }); + acc.push(...legendItems); + } } return acc; @@ -91,31 +105,33 @@ export const Legend = (props: Props) => { legendItemTemplate .append('rect') - .attr('x', function (_d, i) { + .attr('x', function (legendItem, i) { return ( - i * legend.symbol.width + + i * legendItem.legend.symbol.width + i * legend.itemDistance + - i * legend.symbol.padding + + i * legendItem.legend.symbol.padding + textWidths.slice(0, i + 1).reduce((acc, tw) => acc + tw, 0) ); }) - .attr('y', offsetHeight - legend.symbol.height / 2) - .attr('width', legend.symbol.width) - .attr('height', legend.symbol.height) - .attr('rx', legend.symbol.radius) + .attr('y', (legendItem) => offsetHeight - legendItem.legend.symbol.height / 2) + .attr('width', (legendItem) => { + return legendItem.legend.symbol.width; + }) + .attr('height', (legendItem) => legendItem.legend.symbol.height) + .attr('rx', (legendItem) => legendItem.legend.symbol.radius) .attr('class', b('item-shape')) .style('fill', function (d) { return d.color; }); legendItemTemplate .append('text') - .attr('x', function (_d, i) { + .attr('x', function (legendItem, i) { return ( - i * legend.symbol.width + + i * legendItem.legend.symbol.width + i * legend.itemDistance + - i * legend.symbol.padding + - legend.symbol.width + - legend.symbol.padding + + i * legendItem.legend.symbol.padding + + legendItem.legend.symbol.width + + legendItem.legend.symbol.padding + textWidths.slice(0, i + 1).reduce((acc, tw) => acc + tw, 0) ); }) @@ -131,7 +147,7 @@ export const Legend = (props: Props) => { const contentWidth = sum(textWidths) + - (legend.symbol.width + legend.symbol.padding) * legendItems.length + + sum(legendItems, (item) => item.legend.symbol.width + item.legend.symbol.padding) + legend.itemDistance * (legendItems.length - 1); const {left} = getLegendPosition({ diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/index.ts b/src/plugins/d3/renderer/hooks/useChartOptions/index.ts index 85bd5932..41025190 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/index.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/index.ts @@ -9,6 +9,7 @@ import {getPreparedTooltip} from './tooltip'; import {getPreparedXAxis} from './x-axis'; import {getPreparedYAxis} from './y-axis'; import type {ChartOptions} from './types'; +import {getPreparedSeries} from './series'; type Args = ChartKitWidgetData; @@ -18,6 +19,7 @@ export const useChartOptions = (args: Args): ChartOptions => { const preparedTitle = getPreparedTitle({title}); const preparedTooltip = getPreparedTooltip({tooltip}); const preparedLegend = getPreparedLegend({legend, series}); + const preparedSeries = getPreparedSeries({series, legend: preparedLegend}); const preparedYAxis = getPreparedYAxis({yAxis}); const preparedXAxis = getPreparedXAxis({xAxis}); const preparedChart = getPreparedChart({ @@ -33,6 +35,7 @@ export const useChartOptions = (args: Args): ChartOptions => { tooltip: preparedTooltip, xAxis: preparedXAxis, yAxis: preparedYAxis, + series: preparedSeries, }; }, [chart, legend, title, tooltip, series, xAxis, yAxis]); diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/legend.ts b/src/plugins/d3/renderer/hooks/useChartOptions/legend.ts index b4e21e16..8d0b3b13 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/legend.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/legend.ts @@ -3,7 +3,6 @@ import type {ChartKitWidgetData} from '../../../../../types/widget-data'; import type {PreparedLegend} from './types'; const LEGEND_LINE_HEIGHT = 15; -const LEGEND_SYMBOL_SIZE = 10; export const getPreparedLegend = (args: { legend: ChartKitWidgetData['legend']; @@ -12,19 +11,11 @@ export const getPreparedLegend = (args: { const {legend, series} = args; const enabled = typeof legend?.enabled === 'boolean' ? legend?.enabled : series.data.length > 1; const height = enabled ? LEGEND_LINE_HEIGHT : 0; - const symbolHeight = legend?.symbol?.height || LEGEND_SYMBOL_SIZE; return { align: legend?.align || 'center', enabled, itemDistance: legend?.itemDistance || 20, - symbol: { - width: legend?.symbol?.width || LEGEND_SYMBOL_SIZE, - height: symbolHeight, - radius: legend?.symbol?.width || symbolHeight / 2, - padding: legend?.symbol?.padding || 5, - }, - height, }; }; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/series.ts b/src/plugins/d3/renderer/hooks/useChartOptions/series.ts new file mode 100644 index 00000000..7325c369 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useChartOptions/series.ts @@ -0,0 +1,48 @@ +import { + ChartKitWidgetData, + ChartKitWidgetSeries, + LegendSymbol, + RectLegendSymbolOptions, +} from '../../../../../types/widget-data'; +import {PreparedLegend, PreparedSeries} from './types'; + +const DEFAULT_LEGEND_SYMBOL_SIZE = 10; + +function prepareLegendSymbol(series: ChartKitWidgetSeries): LegendSymbol { + switch (series.type) { + default: { + const symbolOptions: RectLegendSymbolOptions = series.legend?.symbol || {}; + const symbolHeight = symbolOptions?.height || DEFAULT_LEGEND_SYMBOL_SIZE; + + return { + shape: 'rect', + width: symbolOptions?.width || DEFAULT_LEGEND_SYMBOL_SIZE, + height: symbolHeight, + radius: symbolOptions?.radius || symbolHeight / 2, + padding: symbolOptions?.padding || 5, + }; + } + } +} + +export const getPreparedSeries = (args: { + legend: PreparedLegend; + series: ChartKitWidgetData['series']; +}): PreparedSeries[] => { + const {legend, series} = args; + + return series.data.map((singleSeries) => { + const legendEnabled = + typeof singleSeries.legend?.enabled === 'boolean' + ? singleSeries.legend.enabled + : legend.enabled; + + return { + ...singleSeries, + legend: { + enabled: legendEnabled, + symbol: prepareLegendSymbol(singleSeries), + }, + }; + }); +}; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts index f57816d8..073dfaa6 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts @@ -4,8 +4,10 @@ import type { ChartKitWidgetAxis, ChartKitWidgetAxisType, ChartKitWidgetAxisLabels, + ChartKitWidgetSeries, ChartKitWidgetLegend, ChartMargin, + LegendSymbol, } from '../../../../../types/widget-data'; type PreparedAxisLabels = Omit & @@ -18,7 +20,6 @@ export type PreparedChart = { }; export type PreparedLegend = Required & { - symbol: Required; height: number; }; @@ -48,6 +49,12 @@ export type PreparedTooltip = ChartKitWidgetData['tooltip'] & { enabled: boolean; }; +export type PreparedSeries = ChartKitWidgetSeries & { + legend: ChartKitWidgetSeries['legend'] & { + symbol: LegendSymbol; + }; +}; + export type ChartOptions = { chart: PreparedChart; legend: PreparedLegend; @@ -55,4 +62,5 @@ export type ChartOptions = { xAxis: PreparedAxis; yAxis: PreparedAxis[]; title?: PreparedTitle; + series: PreparedSeries[]; }; diff --git a/src/plugins/d3/renderer/hooks/useSeries/index.ts b/src/plugins/d3/renderer/hooks/useSeries/index.ts index 76a4de0b..f6a0cc5b 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/index.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/index.ts @@ -11,8 +11,9 @@ import type { import {DEFAULT_PALETTE} from '../../constants'; import {getSeriesNames, isAxisRelatedSeries} from '../../utils'; +import {PreparedSeries} from '../useChartOptions/types'; -export type ChartSeries = ChartKitWidgetSeries & { +export type ChartSeries = PreparedSeries & { color: string; name: string; visible: boolean; diff --git a/src/types/widget-data/bar-x.ts b/src/types/widget-data/bar-x.ts index c0628937..4cfdb944 100644 --- a/src/types/widget-data/bar-x.ts +++ b/src/types/widget-data/bar-x.ts @@ -1,5 +1,6 @@ import type {BaseSeries, BaseSeriesData} from './base'; import type {ChartKitWidgetSeriesOptions} from './series'; +import {ChartKitWidgetLegend, RectLegendSymbol} from './legend'; export type BarXSeriesData = BaseSeriesData & { /** The x value of the point */ @@ -39,4 +40,9 @@ export type BarXSeries = BaseSeries & { /** Whether to align the data label inside the box or to the actual value point */ inside?: boolean; }; + + /** Individual series legend options. Has higher priority than legend options in widget data */ + legend?: ChartKitWidgetLegend & { + symbol?: RectLegendSymbol; + }; }; diff --git a/src/types/widget-data/base.ts b/src/types/widget-data/base.ts index f687ab5c..54b55123 100644 --- a/src/types/widget-data/base.ts +++ b/src/types/widget-data/base.ts @@ -1,8 +1,4 @@ -import type {ChartKitWidgetLegend} from './legend'; - export type BaseSeries = { - /** Individual series legend options. Has higher priority than legend options in widget data */ - legend?: ChartKitWidgetLegend; /** Initial visibility of the series */ visible?: boolean; }; diff --git a/src/types/widget-data/legend.ts b/src/types/widget-data/legend.ts index 3c469b88..a8ac35e8 100644 --- a/src/types/widget-data/legend.ts +++ b/src/types/widget-data/legend.ts @@ -12,28 +12,36 @@ export type ChartKitWidgetLegend = { * @default 20 * */ itemDistance?: number; +}; - symbol?: { - /** 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 - * - * @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. */ - radius?: number; - - /** The pixel padding between the legend item symbol and the legend item text. - * - * @default 5 - * */ - padding?: number; - }; +export type BaseLegendSymbol = { + /** The pixel padding between the legend item symbol and the legend item text. + * + * @default 5 + * */ + padding?: number; }; + +export type RectLegendSymbolOptions = BaseLegendSymbol & { + /** 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 + * + * @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. */ + radius?: number; +}; + +export type RectLegendSymbol = { + shape: 'rect'; +} & Required; + +export type LegendSymbol = RectLegendSymbol; diff --git a/src/types/widget-data/pie.ts b/src/types/widget-data/pie.ts index 10a875cc..fda51091 100644 --- a/src/types/widget-data/pie.ts +++ b/src/types/widget-data/pie.ts @@ -1,4 +1,5 @@ import type {BaseSeries, BaseSeriesData} from './base'; +import {ChartKitWidgetLegend, RectLegendSymbol} from './legend'; export type PieSeriesData = BaseSeriesData & { /** The value of the pie segment. */ @@ -26,4 +27,9 @@ export type PieSeries = BaseSeries & { innerRadius?: string | number; /** The radius of the pie relative to the chart area. The default behaviour is to scale to the chart area. */ radius?: string | number; + + /** Individual series legend options. Has higher priority than legend options in widget data */ + legend?: ChartKitWidgetLegend & { + symbol?: RectLegendSymbol; + }; }; diff --git a/src/types/widget-data/scatter.ts b/src/types/widget-data/scatter.ts index 3d42d3de..8e20d8f4 100644 --- a/src/types/widget-data/scatter.ts +++ b/src/types/widget-data/scatter.ts @@ -1,4 +1,5 @@ import type {BaseSeries, BaseSeriesData} from './base'; +import type {ChartKitWidgetLegend, RectLegendSymbol} from './legend'; export type ScatterSeriesData = BaseSeriesData & { /** The x value of the point */ @@ -20,4 +21,9 @@ export type ScatterSeries = BaseSeries & { /** A predefined shape or symbol for the dot */ symbol?: string; // yAxisIndex?: number; + + /** Individual series legend options. Has higher priority than legend options in widget data */ + legend?: ChartKitWidgetLegend & { + symbol?: RectLegendSymbol; + }; }; From eb8304400efd2b39d091e7cbe22197d355f9dfbe Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Thu, 24 Aug 2023 18:56:49 +0300 Subject: [PATCH 4/5] fix getLegendItems --- src/plugins/d3/renderer/components/Legend.tsx | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index 4dc82469..0004b01a 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -23,7 +23,7 @@ type LegendItem = { color: string; name: string; visible?: boolean; - legend: {enabled: boolean; symbol: LegendSymbol}; + symbol: LegendSymbol; }; const getLegendItems = (series: ChartSeries[]) => { @@ -33,12 +33,15 @@ const getLegendItems = (series: ChartSeries[]) => { if (legendEnabled) { if (isAxisRelated) { - acc.push(s as LegendItem); + acc.push({ + ...s, + symbol: s.legend.symbol, + }); } else { const legendItems = s.data.map((item) => { return { ...item, - legend: s.legend, + symbol: s.legend.symbol, } as LegendItem; }); acc.push(...legendItems); @@ -107,18 +110,18 @@ export const Legend = (props: Props) => { .append('rect') .attr('x', function (legendItem, i) { return ( - i * legendItem.legend.symbol.width + + i * legendItem.symbol.width + i * legend.itemDistance + - i * legendItem.legend.symbol.padding + + i * legendItem.symbol.padding + textWidths.slice(0, i + 1).reduce((acc, tw) => acc + tw, 0) ); }) - .attr('y', (legendItem) => offsetHeight - legendItem.legend.symbol.height / 2) + .attr('y', (legendItem) => offsetHeight - legendItem.symbol.height / 2) .attr('width', (legendItem) => { - return legendItem.legend.symbol.width; + return legendItem.symbol.width; }) - .attr('height', (legendItem) => legendItem.legend.symbol.height) - .attr('rx', (legendItem) => legendItem.legend.symbol.radius) + .attr('height', (legendItem) => legendItem.symbol.height) + .attr('rx', (legendItem) => legendItem.symbol.radius) .attr('class', b('item-shape')) .style('fill', function (d) { return d.color; @@ -127,11 +130,11 @@ export const Legend = (props: Props) => { .append('text') .attr('x', function (legendItem, i) { return ( - i * legendItem.legend.symbol.width + + i * legendItem.symbol.width + i * legend.itemDistance + - i * legendItem.legend.symbol.padding + - legendItem.legend.symbol.width + - legendItem.legend.symbol.padding + + i * legendItem.symbol.padding + + legendItem.symbol.width + + legendItem.symbol.padding + textWidths.slice(0, i + 1).reduce((acc, tw) => acc + tw, 0) ); }) @@ -147,7 +150,7 @@ export const Legend = (props: Props) => { const contentWidth = sum(textWidths) + - sum(legendItems, (item) => item.legend.symbol.width + item.legend.symbol.padding) + + sum(legendItems, (item) => item.symbol.width + item.symbol.padding) + legend.itemDistance * (legendItems.length - 1); const {left} = getLegendPosition({ From 630db27964351bb059d45fcab1829e156e18f802 Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Thu, 24 Aug 2023 19:35:30 +0300 Subject: [PATCH 5/5] fix useSeries --- src/plugins/d3/renderer/components/Chart.tsx | 8 +- src/plugins/d3/renderer/components/Legend.tsx | 14 +-- src/plugins/d3/renderer/hooks/index.ts | 2 +- .../renderer/hooks/useChartOptions/index.ts | 3 - .../renderer/hooks/useChartOptions/series.ts | 48 ---------- .../renderer/hooks/useChartOptions/types.ts | 9 -- .../d3/renderer/hooks/useLegend/index.ts | 79 ----------------- .../d3/renderer/hooks/useSeries/index.ts | 87 ++++++++++++++----- .../d3/renderer/hooks/useSeries/types.ts | 16 ++++ .../d3/renderer/hooks/useSeries/utils.ts | 57 ++++++++++++ .../d3/renderer/hooks/useShapes/index.tsx | 4 +- .../d3/renderer/hooks/useShapes/scatter.tsx | 5 +- src/types/widget-data/bar-x.ts | 4 +- src/types/widget-data/legend.ts | 6 -- src/types/widget-data/pie.ts | 4 +- src/types/widget-data/scatter.ts | 4 +- 16 files changed, 162 insertions(+), 188 deletions(-) delete mode 100644 src/plugins/d3/renderer/hooks/useChartOptions/series.ts delete mode 100644 src/plugins/d3/renderer/hooks/useLegend/index.ts create mode 100644 src/plugins/d3/renderer/hooks/useSeries/types.ts create mode 100644 src/plugins/d3/renderer/hooks/useSeries/utils.ts diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index 7cb085dd..fdd15757 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -12,7 +12,6 @@ import { useChartDimensions, useChartEvents, useChartOptions, - useLegend, useAxisScales, useSeries, useShapes, @@ -37,7 +36,7 @@ export const Chart = (props: Props) => { // FIXME: add data validation const svgRef = React.createRef(); const {chartHovered, handleMouseEnter, handleMouseLeave} = useChartEvents(); - const {chart, legend, title, tooltip, xAxis, yAxis, series} = useChartOptions(data); + const {chart, legend, title, tooltip, xAxis, yAxis} = useChartOptions(data); const {boundsWidth, boundsHeight} = useChartDimensions({ width, height, @@ -47,8 +46,7 @@ export const Chart = (props: Props) => { xAxis, yAxis, }); - const {activeLegendItems, handleLegendItemClick} = useLegend({series: series}); - const {chartSeries} = useSeries({activeLegendItems, series: series}); + const {chartSeries, handleLegendItemClick} = useSeries({series: data.series, legend}); const {xScale, yScale} = useAxisScales({ boundsWidth, boundsHeight, @@ -73,7 +71,7 @@ export const Chart = (props: Props) => { onSeriesMouseMove: handleSeriesMouseMove, onSeriesMouseLeave: handleSeriesMouseLeave, }); - const hasAxisRelatedSeries = series.some(isAxisRelatedSeries); + const hasAxisRelatedSeries = chartSeries.some(isAxisRelatedSeries); return ( diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index 0004b01a..cb537d0d 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -3,9 +3,13 @@ import {select, sum} from 'd3'; import get from 'lodash/get'; import {block} from '../../../../utils/cn'; -import type {ChartSeries, OnLegendItemClick, PreparedLegend} from '../hooks'; +import type { + OnLegendItemClick, + PreparedLegend, + PreparedLegendSymbol, + PreparedSeries, +} from '../hooks'; import {isAxisRelatedSeries} from '../utils'; -import {LegendSymbol} from '../../../../types/widget-data'; const b = block('d3-legend'); @@ -15,7 +19,7 @@ type Props = { legend: PreparedLegend; offsetWidth: number; offsetHeight: number; - chartSeries: ChartSeries[]; + chartSeries: PreparedSeries[]; onItemClick: OnLegendItemClick; }; @@ -23,10 +27,10 @@ type LegendItem = { color: string; name: string; visible?: boolean; - symbol: LegendSymbol; + symbol: PreparedLegendSymbol; }; -const getLegendItems = (series: ChartSeries[]) => { +const getLegendItems = (series: PreparedSeries[]) => { return series.reduce((acc, s) => { const isAxisRelated = isAxisRelatedSeries(s); const legendEnabled = get(s, 'legend.enabled', true); diff --git a/src/plugins/d3/renderer/hooks/index.ts b/src/plugins/d3/renderer/hooks/index.ts index c2dcaa5d..498be3df 100644 --- a/src/plugins/d3/renderer/hooks/index.ts +++ b/src/plugins/d3/renderer/hooks/index.ts @@ -2,9 +2,9 @@ export * from './useChartDimensions'; export * from './useChartEvents'; export * from './useChartOptions'; export * from './useChartOptions/types'; -export * from './useLegend'; export * from './useAxisScales'; export * from './useSeries'; +export * from './useSeries/types'; export * from './useShapes'; export * from './useTooltip'; export * from './useTooltip/types'; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/index.ts b/src/plugins/d3/renderer/hooks/useChartOptions/index.ts index 41025190..85bd5932 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/index.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/index.ts @@ -9,7 +9,6 @@ import {getPreparedTooltip} from './tooltip'; import {getPreparedXAxis} from './x-axis'; import {getPreparedYAxis} from './y-axis'; import type {ChartOptions} from './types'; -import {getPreparedSeries} from './series'; type Args = ChartKitWidgetData; @@ -19,7 +18,6 @@ export const useChartOptions = (args: Args): ChartOptions => { const preparedTitle = getPreparedTitle({title}); const preparedTooltip = getPreparedTooltip({tooltip}); const preparedLegend = getPreparedLegend({legend, series}); - const preparedSeries = getPreparedSeries({series, legend: preparedLegend}); const preparedYAxis = getPreparedYAxis({yAxis}); const preparedXAxis = getPreparedXAxis({xAxis}); const preparedChart = getPreparedChart({ @@ -35,7 +33,6 @@ export const useChartOptions = (args: Args): ChartOptions => { tooltip: preparedTooltip, xAxis: preparedXAxis, yAxis: preparedYAxis, - series: preparedSeries, }; }, [chart, legend, title, tooltip, series, xAxis, yAxis]); diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/series.ts b/src/plugins/d3/renderer/hooks/useChartOptions/series.ts deleted file mode 100644 index 7325c369..00000000 --- a/src/plugins/d3/renderer/hooks/useChartOptions/series.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - ChartKitWidgetData, - ChartKitWidgetSeries, - LegendSymbol, - RectLegendSymbolOptions, -} from '../../../../../types/widget-data'; -import {PreparedLegend, PreparedSeries} from './types'; - -const DEFAULT_LEGEND_SYMBOL_SIZE = 10; - -function prepareLegendSymbol(series: ChartKitWidgetSeries): LegendSymbol { - switch (series.type) { - default: { - const symbolOptions: RectLegendSymbolOptions = series.legend?.symbol || {}; - const symbolHeight = symbolOptions?.height || DEFAULT_LEGEND_SYMBOL_SIZE; - - return { - shape: 'rect', - width: symbolOptions?.width || DEFAULT_LEGEND_SYMBOL_SIZE, - height: symbolHeight, - radius: symbolOptions?.radius || symbolHeight / 2, - padding: symbolOptions?.padding || 5, - }; - } - } -} - -export const getPreparedSeries = (args: { - legend: PreparedLegend; - series: ChartKitWidgetData['series']; -}): PreparedSeries[] => { - const {legend, series} = args; - - return series.data.map((singleSeries) => { - const legendEnabled = - typeof singleSeries.legend?.enabled === 'boolean' - ? singleSeries.legend.enabled - : legend.enabled; - - return { - ...singleSeries, - legend: { - enabled: legendEnabled, - symbol: prepareLegendSymbol(singleSeries), - }, - }; - }); -}; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts index 073dfaa6..0962c79f 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/types.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/types.ts @@ -4,10 +4,8 @@ import type { ChartKitWidgetAxis, ChartKitWidgetAxisType, ChartKitWidgetAxisLabels, - ChartKitWidgetSeries, ChartKitWidgetLegend, ChartMargin, - LegendSymbol, } from '../../../../../types/widget-data'; type PreparedAxisLabels = Omit & @@ -49,12 +47,6 @@ export type PreparedTooltip = ChartKitWidgetData['tooltip'] & { enabled: boolean; }; -export type PreparedSeries = ChartKitWidgetSeries & { - legend: ChartKitWidgetSeries['legend'] & { - symbol: LegendSymbol; - }; -}; - export type ChartOptions = { chart: PreparedChart; legend: PreparedLegend; @@ -62,5 +54,4 @@ export type ChartOptions = { xAxis: PreparedAxis; yAxis: PreparedAxis[]; title?: PreparedTitle; - series: PreparedSeries[]; }; diff --git a/src/plugins/d3/renderer/hooks/useLegend/index.ts b/src/plugins/d3/renderer/hooks/useLegend/index.ts deleted file mode 100644 index 288b6b72..00000000 --- a/src/plugins/d3/renderer/hooks/useLegend/index.ts +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react'; -import get from 'lodash/get'; - -import type {ChartKitWidgetSeries} from '../../../../../types/widget-data'; - -import {isAxisRelatedSeries} from '../../utils'; - -export type OnLegendItemClick = (data: {name: string; metaKey: boolean}) => void; - -type Args = { - series: ChartKitWidgetSeries[]; -}; - -const getActiveLegendItems = (series: ChartKitWidgetSeries[]) => { - return series.reduce((acc, s) => { - const isAxisRelated = isAxisRelatedSeries(s); - const isLegendEnabled = get(s, 'legend.enabled', true); - const isSeriesVisible = get(s, 'visible', true); - - if (isLegendEnabled && isAxisRelated && isSeriesVisible && 'name' in s) { - acc.push(s.name); - } else if (isLegendEnabled && !isAxisRelated) { - s.data.forEach((d) => { - const isDataVisible = get(d, 'visible', true); - - if (isDataVisible && 'name' in d) { - acc.push(d.name); - } - }); - } - - return acc; - }, []); -}; - -const getAllLegendItems = (series: ChartKitWidgetSeries[]) => { - return series.reduce((acc, s) => { - if (isAxisRelatedSeries(s) && 'name' in s) { - acc.push(s.name); - } else { - acc.push(...s.data.map((d) => ('name' in d && d.name) || '')); - } - - return acc; - }, []); -}; - -export const useLegend = (args: Args) => { - const {series} = args; - const [activeLegendItems, setActiveLegendItems] = React.useState(getActiveLegendItems(series)); - - const handleLegendItemClick: OnLegendItemClick = React.useCallback( - ({name, metaKey}) => { - const onlyItemSelected = - activeLegendItems.length === 1 && activeLegendItems.includes(name); - let nextActiveLegendItems: string[]; - - if (metaKey && activeLegendItems.includes(name)) { - nextActiveLegendItems = activeLegendItems.filter((item) => item !== name); - } else if (metaKey && !activeLegendItems.includes(name)) { - nextActiveLegendItems = activeLegendItems.concat(name); - } else if (onlyItemSelected) { - nextActiveLegendItems = getAllLegendItems(series); - } else { - nextActiveLegendItems = [name]; - } - - setActiveLegendItems(nextActiveLegendItems); - }, - [series, activeLegendItems], - ); - - // FIXME: remove effect. It initiates extra rerender - React.useEffect(() => { - setActiveLegendItems(getActiveLegendItems(series)); - }, [series]); - - return {activeLegendItems, handleLegendItemClick}; -}; diff --git a/src/plugins/d3/renderer/hooks/useSeries/index.ts b/src/plugins/d3/renderer/hooks/useSeries/index.ts index f6a0cc5b..f291ddc6 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/index.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/index.ts @@ -4,6 +4,7 @@ import get from 'lodash/get'; import {ScaleOrdinal, scaleOrdinal} from 'd3'; import type { + ChartKitWidgetData, ChartKitWidgetSeries, PieSeries, PieSeriesData, @@ -11,41 +12,48 @@ import type { import {DEFAULT_PALETTE} from '../../constants'; import {getSeriesNames, isAxisRelatedSeries} from '../../utils'; -import {PreparedSeries} from '../useChartOptions/types'; +import {PreparedLegend} from '../useChartOptions/types'; +import {getActiveLegendItems, getAllLegendItems, prepareLegendSymbol} from './utils'; +import {PreparedSeries} from './types'; -export type ChartSeries = PreparedSeries & { - color: string; - name: string; - visible: boolean; -}; +export type OnLegendItemClick = (data: {name: string; metaKey: boolean}) => void; type Args = { - activeLegendItems: string[]; - series: ChartKitWidgetSeries[]; + legend: PreparedLegend; + series: ChartKitWidgetData['series']; }; const prepareAxisRelatedSeries = (args: { activeLegendItems: string[]; colorScale: ScaleOrdinal; series: ChartKitWidgetSeries; + legend: PreparedLegend; }) => { - const {activeLegendItems, colorScale, series} = args; - const preparedSeries = cloneDeep(series) as ChartSeries; - const legendEnabled = get(preparedSeries, 'legend.enabled', true); + const {activeLegendItems, colorScale, series, legend} = args; + const preparedSeries = cloneDeep(series) as PreparedSeries; + const legendEnabled = get(preparedSeries, 'legend.enabled', legend.enabled); const defaultVisible = get(preparedSeries, 'visible', true); const name = 'name' in series && series.name ? series.name : ''; const color = 'color' in series && series.color ? series.color : colorScale(name); preparedSeries.color = color; preparedSeries.name = name; preparedSeries.visible = legendEnabled ? activeLegendItems.includes(name) : defaultVisible; + preparedSeries.legend = { + enabled: legendEnabled, + symbol: prepareLegendSymbol(preparedSeries), + }; return preparedSeries; }; -const preparePieSeries = (args: {activeLegendItems: string[]; series: PieSeries}) => { - const {activeLegendItems, series} = args; - const preparedSeries = cloneDeep(series) as ChartSeries; - const legendEnabled = get(preparedSeries, 'legend.enabled', true); +const preparePieSeries = (args: { + activeLegendItems: string[]; + series: PieSeries; + legend: PreparedLegend; +}) => { + const {activeLegendItems, series, legend} = args; + const preparedSeries = cloneDeep(series) as PreparedSeries; + const legendEnabled = get(preparedSeries, 'legend.enabled', legend.enabled); const dataNames = series.data.map((d) => d.name); const colorScale = scaleOrdinal(dataNames, DEFAULT_PALETTE); preparedSeries.data = (preparedSeries.data as PieSeriesData[]).map((d) => { @@ -57,6 +65,10 @@ const preparePieSeries = (args: {activeLegendItems: string[]; series: PieSeries} // Not axis related series manages their own data visibility inside their data preparedSeries.visible = true; + preparedSeries.legend = { + enabled: legendEnabled, + symbol: prepareLegendSymbol(preparedSeries), + }; return preparedSeries; }; @@ -64,12 +76,13 @@ const preparePieSeries = (args: {activeLegendItems: string[]; series: PieSeries} const prepareNotAxisRelatedSeries = (args: { activeLegendItems: string[]; series: ChartKitWidgetSeries; + legend: PreparedLegend; }) => { - const {activeLegendItems, series} = args; + const {activeLegendItems, series, legend} = args; switch (series.type) { case 'pie': { - return preparePieSeries({activeLegendItems, series}); + return preparePieSeries({activeLegendItems, series, legend}); } default: { throw new Error( @@ -80,20 +93,52 @@ const prepareNotAxisRelatedSeries = (args: { }; export const useSeries = (args: Args) => { - const {activeLegendItems, series} = args; - const chartSeries = React.useMemo(() => { + const { + series: {data: series}, + legend, + } = args; + const [activeLegendItems, setActiveLegendItems] = React.useState(getActiveLegendItems(series)); + + // FIXME: remove effect. It initiates extra rerender + React.useEffect(() => { + setActiveLegendItems(getActiveLegendItems(series)); + }, [series]); + + const chartSeries = React.useMemo(() => { const seriesNames = getSeriesNames(series); const colorScale = scaleOrdinal(seriesNames, DEFAULT_PALETTE); return series.map((s) => { return isAxisRelatedSeries(s) - ? prepareAxisRelatedSeries({activeLegendItems, colorScale, series: s}) + ? prepareAxisRelatedSeries({activeLegendItems, colorScale, series: s, legend}) : prepareNotAxisRelatedSeries({ activeLegendItems, series: s, + legend, }); }); }, [activeLegendItems, series]); - return {chartSeries}; + const handleLegendItemClick: OnLegendItemClick = React.useCallback( + ({name, metaKey}) => { + const onlyItemSelected = + activeLegendItems.length === 1 && activeLegendItems.includes(name); + let nextActiveLegendItems: string[]; + + if (metaKey && activeLegendItems.includes(name)) { + nextActiveLegendItems = activeLegendItems.filter((item) => item !== name); + } else if (metaKey && !activeLegendItems.includes(name)) { + nextActiveLegendItems = activeLegendItems.concat(name); + } else if (onlyItemSelected) { + nextActiveLegendItems = getAllLegendItems(series); + } else { + nextActiveLegendItems = [name]; + } + + setActiveLegendItems(nextActiveLegendItems); + }, + [series, activeLegendItems], + ); + + return {chartSeries, handleLegendItemClick}; }; diff --git a/src/plugins/d3/renderer/hooks/useSeries/types.ts b/src/plugins/d3/renderer/hooks/useSeries/types.ts new file mode 100644 index 00000000..47d996c4 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useSeries/types.ts @@ -0,0 +1,16 @@ +import {ChartKitWidgetSeries, RectLegendSymbolOptions} from '../../../../../types/widget-data'; + +export type RectLegendSymbol = { + shape: 'rect'; +} & Required; + +export type PreparedLegendSymbol = RectLegendSymbol; + +export type PreparedSeries = ChartKitWidgetSeries & { + color: string; + name: string; + visible: boolean; + legend: ChartKitWidgetSeries['legend'] & { + symbol: PreparedLegendSymbol; + }; +}; diff --git a/src/plugins/d3/renderer/hooks/useSeries/utils.ts b/src/plugins/d3/renderer/hooks/useSeries/utils.ts new file mode 100644 index 00000000..c49b3b02 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useSeries/utils.ts @@ -0,0 +1,57 @@ +import {ChartKitWidgetSeries, RectLegendSymbolOptions} from '../../../../../types/widget-data'; +import {isAxisRelatedSeries} from '../../utils'; +import get from 'lodash/get'; +import {PreparedLegendSymbol} from './types'; + +export const getActiveLegendItems = (series: ChartKitWidgetSeries[]) => { + return series.reduce((acc, s) => { + const isAxisRelated = isAxisRelatedSeries(s); + const isLegendEnabled = get(s, 'legend.enabled', true); + const isSeriesVisible = get(s, 'visible', true); + + if (isLegendEnabled && isAxisRelated && isSeriesVisible && 'name' in s) { + acc.push(s.name); + } else if (isLegendEnabled && !isAxisRelated) { + s.data.forEach((d) => { + const isDataVisible = get(d, 'visible', true); + + if (isDataVisible && 'name' in d) { + acc.push(d.name); + } + }); + } + + return acc; + }, []); +}; + +export const getAllLegendItems = (series: ChartKitWidgetSeries[]) => { + return series.reduce((acc, s) => { + if (isAxisRelatedSeries(s) && 'name' in s) { + acc.push(s.name); + } else { + acc.push(...s.data.map((d) => ('name' in d && d.name) || '')); + } + + return acc; + }, []); +}; + +const DEFAULT_LEGEND_SYMBOL_SIZE = 10; + +export function prepareLegendSymbol(series: ChartKitWidgetSeries): PreparedLegendSymbol { + switch (series.type) { + default: { + const symbolOptions: RectLegendSymbolOptions = series.legend?.symbol || {}; + const symbolHeight = symbolOptions?.height || DEFAULT_LEGEND_SYMBOL_SIZE; + + return { + shape: 'rect', + width: symbolOptions?.width || DEFAULT_LEGEND_SYMBOL_SIZE, + height: symbolHeight, + radius: symbolOptions?.radius || symbolHeight / 2, + padding: symbolOptions?.padding || 5, + }; + } + } +} diff --git a/src/plugins/d3/renderer/hooks/useShapes/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/index.tsx index 56cb3689..f5e09aa8 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -6,7 +6,7 @@ import type {BarXSeries, PieSeries, ScatterSeries} from '../../../../../types/wi import {getOnlyVisibleSeries} from '../../utils'; import type {ChartOptions} from '../useChartOptions/types'; import type {ChartScale} from '../useAxisScales'; -import type {ChartSeries} from '../useSeries'; +import type {PreparedSeries} from '../'; import type {OnSeriesMouseMove, OnSeriesMouseLeave} from '../useTooltip/types'; import {prepareBarXSeries} from './bar-x'; import {prepareScatterSeries} from './scatter'; @@ -19,7 +19,7 @@ type Args = { left: number; boundsWidth: number; boundsHeight: number; - series: ChartSeries[]; + series: PreparedSeries[]; xAxis: ChartOptions['xAxis']; yAxis: ChartOptions['yAxis']; svgContainer: SVGSVGElement | null; diff --git a/src/plugins/d3/renderer/hooks/useShapes/scatter.tsx b/src/plugins/d3/renderer/hooks/useShapes/scatter.tsx index a08d35d9..850e5506 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/scatter.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/scatter.tsx @@ -17,7 +17,6 @@ type PrepareScatterSeriesArgs = { svgContainer: SVGSVGElement | null; onSeriesMouseMove?: OnSeriesMouseMove; onSeriesMouseLeave?: OnSeriesMouseLeave; - key?: string; }; const b = block('d3-scatter'); @@ -73,11 +72,11 @@ export function prepareScatterSeries(args: PrepareScatterSeriesArgs) { yScale, onSeriesMouseMove, onSeriesMouseLeave, - key, svgContainer, } = args; return series.reduce((result, s) => { + const randomKey = Math.random().toString(); const preparedData = xAxis.type === 'category' || yAxis[0]?.type === 'category' ? prepareCategoricalScatterData(s.data) @@ -95,7 +94,7 @@ export function prepareScatterSeries(args: PrepareScatterSeriesArgs) { return ( = BaseSeriesData & { /** The x value of the point */ @@ -43,6 +43,6 @@ export type BarXSeries = BaseSeries & { /** Individual series legend options. Has higher priority than legend options in widget data */ legend?: ChartKitWidgetLegend & { - symbol?: RectLegendSymbol; + symbol?: RectLegendSymbolOptions; }; }; diff --git a/src/types/widget-data/legend.ts b/src/types/widget-data/legend.ts index a8ac35e8..d3a9e241 100644 --- a/src/types/widget-data/legend.ts +++ b/src/types/widget-data/legend.ts @@ -39,9 +39,3 @@ export type RectLegendSymbolOptions = BaseLegendSymbol & { * Defaults to half the symbolHeight, effectively creating a circle. */ radius?: number; }; - -export type RectLegendSymbol = { - shape: 'rect'; -} & Required; - -export type LegendSymbol = RectLegendSymbol; diff --git a/src/types/widget-data/pie.ts b/src/types/widget-data/pie.ts index fda51091..b55b17c0 100644 --- a/src/types/widget-data/pie.ts +++ b/src/types/widget-data/pie.ts @@ -1,5 +1,5 @@ import type {BaseSeries, BaseSeriesData} from './base'; -import {ChartKitWidgetLegend, RectLegendSymbol} from './legend'; +import {ChartKitWidgetLegend, RectLegendSymbolOptions} from './legend'; export type PieSeriesData = BaseSeriesData & { /** The value of the pie segment. */ @@ -30,6 +30,6 @@ export type PieSeries = BaseSeries & { /** Individual series legend options. Has higher priority than legend options in widget data */ legend?: ChartKitWidgetLegend & { - symbol?: RectLegendSymbol; + symbol?: RectLegendSymbolOptions; }; }; diff --git a/src/types/widget-data/scatter.ts b/src/types/widget-data/scatter.ts index 8e20d8f4..90143501 100644 --- a/src/types/widget-data/scatter.ts +++ b/src/types/widget-data/scatter.ts @@ -1,5 +1,5 @@ import type {BaseSeries, BaseSeriesData} from './base'; -import type {ChartKitWidgetLegend, RectLegendSymbol} from './legend'; +import type {ChartKitWidgetLegend, RectLegendSymbolOptions} from './legend'; export type ScatterSeriesData = BaseSeriesData & { /** The x value of the point */ @@ -24,6 +24,6 @@ export type ScatterSeries = BaseSeries & { /** Individual series legend options. Has higher priority than legend options in widget data */ legend?: ChartKitWidgetLegend & { - symbol?: RectLegendSymbol; + symbol?: RectLegendSymbolOptions; }; };