diff --git a/src/plugins/d3/__stories__/pie/BasicDonut.stories.tsx b/src/plugins/d3/__stories__/pie/BasicDonut.stories.tsx new file mode 100644 index 00000000..314a6b01 --- /dev/null +++ b/src/plugins/d3/__stories__/pie/BasicDonut.stories.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import {Meta, Story} from '@storybook/react'; +import {withKnobs, object} from '@storybook/addon-knobs'; +import {Button} from '@gravity-ui/uikit'; +import {settings} from '../../../../libs'; +import {ChartKit} from '../../../../components/ChartKit'; +import type {ChartKitRef} from '../../../../types'; +import type {ChartKitWidgetData} from '../../../../types/widget-data'; +import {D3Plugin} from '../..'; + +const Template: Story = () => { + const [shown, setShown] = React.useState(false); + const chartkitRef = React.useRef(); + const data: ChartKitWidgetData = { + series: { + data: [ + { + type: 'pie', + innerRadius: '50%', + data: [ + { + name: 'One', + value: 50, + }, + { + name: 'Two', + value: 20, + }, + { + name: 'Three', + value: 90, + }, + ], + }, + ], + }, + title: {text: 'Basic donut'}, + legend: {enabled: false}, + tooltip: {enabled: false}, + }; + + if (!shown) { + settings.set({plugins: [D3Plugin]}); + return ; + } + + return ( +
+ ('data', data)} /> +
+ ); +}; + +export const BasicDonut = Template.bind({}); + +const meta: Meta = { + title: 'Plugins/D3/Pie', + decorators: [withKnobs], +}; + +export default meta; diff --git a/src/plugins/d3/__stories__/pie/BasicPie.stories.tsx b/src/plugins/d3/__stories__/pie/BasicPie.stories.tsx new file mode 100644 index 00000000..d6d8f54e --- /dev/null +++ b/src/plugins/d3/__stories__/pie/BasicPie.stories.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import {Meta, Story} from '@storybook/react'; +import {withKnobs, object} from '@storybook/addon-knobs'; +import {Button} from '@gravity-ui/uikit'; +import {settings} from '../../../../libs'; +import {ChartKit} from '../../../../components/ChartKit'; +import type {ChartKitRef} from '../../../../types'; +import type {ChartKitWidgetData} from '../../../../types/widget-data'; +import {D3Plugin} from '../..'; + +const Template: Story = () => { + const [shown, setShown] = React.useState(false); + const chartkitRef = React.useRef(); + const data: ChartKitWidgetData = { + series: { + data: [ + { + type: 'pie', + data: [ + { + name: 'One', + value: 50, + }, + { + name: 'Two', + value: 20, + }, + { + name: 'Three', + value: 90, + }, + ], + }, + ], + }, + title: {text: 'Basic pie'}, + legend: {enabled: false}, + tooltip: {enabled: false}, + }; + + if (!shown) { + settings.set({plugins: [D3Plugin]}); + return ; + } + + return ( +
+ ('data', data)} /> +
+ ); +}; + +export const BasicPie = Template.bind({}); + +const meta: Meta = { + title: 'Plugins/D3/Pie', + decorators: [withKnobs], +}; + +export default meta; diff --git a/src/plugins/d3/__stories__/pie/Styled.stories.tsx b/src/plugins/d3/__stories__/pie/Styled.stories.tsx new file mode 100644 index 00000000..873a7865 --- /dev/null +++ b/src/plugins/d3/__stories__/pie/Styled.stories.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import {Meta, Story} from '@storybook/react'; +import {withKnobs, object} from '@storybook/addon-knobs'; +import {Button} from '@gravity-ui/uikit'; +import {settings} from '../../../../libs'; +import {ChartKit} from '../../../../components/ChartKit'; +import type {ChartKitRef} from '../../../../types'; +import type {ChartKitWidgetData} from '../../../../types/widget-data'; +import {D3Plugin} from '../..'; + +const Template: Story = () => { + const [shown, setShown] = React.useState(false); + const chartkitRef = React.useRef(); + const data: ChartKitWidgetData = { + series: { + data: [ + { + type: 'pie', + borderRadius: 5, + borderWidth: 3, + center: ['25%', null], + radius: '75%', + data: [ + { + name: 'One 1', + value: 50, + }, + { + name: 'Two 1', + value: 20, + }, + { + name: 'Three 1', + value: 90, + }, + ], + }, + { + type: 'pie', + borderRadius: 5, + borderWidth: 3, + center: ['75%', null], + innerRadius: '50%', + radius: '75%', + data: [ + { + name: 'One 2', + value: 50, + }, + { + name: 'Two 2', + value: 20, + }, + { + name: 'Three 2', + value: 90, + }, + ], + }, + ], + }, + title: {text: 'Styled pies'}, + legend: {enabled: false}, + tooltip: {enabled: false}, + }; + + if (!shown) { + settings.set({plugins: [D3Plugin]}); + return ; + } + + return ( +
+ ('data', data)} /> +
+ ); +}; + +export const Styled = Template.bind({}); + +const meta: Meta = { + title: 'Plugins/D3/Pie', + decorators: [withKnobs], +}; + +export default meta; diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index 64211b0c..2b4e1a84 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -31,7 +31,6 @@ type Props = { }; export const Chart = ({width, height, data}: Props) => { - // FIXME: add data validation const {series} = data; const svgRef = React.createRef(); const hasAxisRelatedSeries = series.data.some(isAxisRelatedSeries); @@ -59,6 +58,8 @@ export const Chart = ({width, height, data}: Props) => { tooltip, }); const {shapes} = useShapes({ + boundsWidth, + boundsHeight, series: chartSeries, xAxis, xScale, diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index 4f616722..cdaa36cb 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -1,8 +1,10 @@ import React from 'react'; import {select} from 'd3'; +import get from 'lodash/get'; import {block} from '../../../../utils/cn'; import type {ChartSeries, OnLegendItemClick} from '../hooks'; +import {isAxisRelatedSeries} from '../utils'; const b = block('d3-legend'); @@ -15,78 +17,94 @@ type Props = { onItemClick: OnLegendItemClick; }; +type LegendItem = {color: string; name: string; visible?: boolean}; + +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[])); + } + + return acc; + }, []); +}; + export const Legend = (props: Props) => { const {width, offsetWidth, height, offsetHeight, chartSeries, onItemClick} = props; + const ref = React.useRef(null); + + React.useEffect(() => { + if (!ref.current) { + return; + } - return ( - { - if (!node) { - return; - } + const legendItems = getLegendItems(chartSeries); + const size = 10; + 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(); - const size = 10; - const textWidths: number[] = [0]; - const svgElement = select(node); - svgElement.selectAll('*').remove(); - const legendItemTemplate = svgElement - .selectAll('legend-history') - .data(chartSeries) - .enter() - .append('g') - .attr('class', b('item')) - .on('click', function (e, d) { - onItemClick({name: d.name, metaKey: e.metaKey}); - }); - svgElement - .selectAll('*') - .data(chartSeries) - .append('text') - .text(function (d) { - return d.name; - }) - .each(function () { - textWidths.push(this.getComputedTextLength()); - }) - .remove(); + legendItemTemplate + .append('rect') + .attr('x', function (_d, i) { + return ( + offsetWidth + + i * size + + textWidths.slice(0, i + 1).reduce((acc, tw) => acc + tw, 0) + ); + }) + .attr('y', offsetHeight - size / 2) + .attr('width', size) + .attr('height', size) + .attr('class', b('item-shape')) + .style('fill', function (d) { + return d.color; + }); + legendItemTemplate + .append('text') + .attr('x', function (_d, i) { + return ( + offsetWidth + + i * size + + size + + 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'); + }, [width, offsetWidth, height, offsetHeight, chartSeries, onItemClick]); - legendItemTemplate - .append('rect') - .attr('x', function (_d, i) { - return ( - offsetWidth + - i * size + - textWidths.slice(0, i + 1).reduce((acc, tw) => acc + tw, 0) - ); - }) - .attr('y', offsetHeight - size / 2) - .attr('width', size) - .attr('height', size) - .style('fill', function (d) { - return d.color; - }); - legendItemTemplate - .append('text') - .attr('x', function (_d, i) { - return ( - offsetWidth + - i * size + - size + - 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'); - }} - /> - ); + return ; }; diff --git a/src/plugins/d3/renderer/components/styles.scss b/src/plugins/d3/renderer/components/styles.scss index a704afea..b25c5bc8 100644 --- a/src/plugins/d3/renderer/components/styles.scss +++ b/src/plugins/d3/renderer/components/styles.scss @@ -22,6 +22,10 @@ user-select: none; } + &__item-shape { + fill: var(--g-color-base-misc-medium); + } + &__item-text { fill: var(--g-color-text-secondary); @@ -35,21 +39,6 @@ } } -.chartkit-d3-scatter { - &__point { - stroke-width: 1px; - - .chartkit-d3_hovered & { - opacity: 0.5; - } - - &:hover { - stroke: #fff; - opacity: 1; - } - } -} - .chartkit-d3-title { font-size: var(--g-text-subheader-2-font-size); font-weight: var(--g-text-subheader-font-weight); diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts b/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts index 79faed31..a8b792d5 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/chart.ts @@ -4,7 +4,7 @@ import get from 'lodash/get'; import type {ChartKitWidgetData, ChartKitWidgetSeries} from '../../../../../types/widget-data'; -import {formatAxisTickLabel, getDomainDataYBySeries} from '../../utils'; +import {formatAxisTickLabel, getDomainDataYBySeries, isAxisRelatedSeries} from '../../utils'; import type {PreparedAxis, PreparedChart} from './types'; import {getHorisontalSvgTextDimensions} from './utils'; @@ -64,21 +64,25 @@ export const getPreparedChart = (args: { preparedY1Axis: PreparedAxis; }): PreparedChart => { const {chart, series, preparedXAxis, preparedY1Axis} = args; - const marginBottom = - get(chart, 'margin.bottom', 0) + - preparedXAxis.labels.padding + - getHorisontalSvgTextDimensions({text: 'Tmp', style: preparedXAxis.labels.style}); - const marginLeft = - get(chart, 'margin.left', AXIS_WIDTH) + - preparedY1Axis.labels.padding + - getAxisLabelMaxWidth({axis: preparedY1Axis, series: series.data}) + - (preparedY1Axis.title.height || 0); - const marginTop = - get(chart, 'margin.top', 0) + - getHorisontalSvgTextDimensions({text: 'Tmp', style: preparedY1Axis.labels.style}) / 2; - const marginRight = - get(chart, 'margin.right', 0) + - getAxisLabelMaxWidth({axis: preparedXAxis, series: series.data}) / 2; + const hasAxisRelatedSeries = series.data.some(isAxisRelatedSeries); + let marginBottom = get(chart, 'margin.bottom', 0); + let marginLeft = get(chart, 'margin.left', 0); + let marginTop = get(chart, 'margin.top', 0); + let marginRight = get(chart, 'margin.right', 0); + + if (hasAxisRelatedSeries) { + marginBottom += + preparedXAxis.labels.padding + + getHorisontalSvgTextDimensions({text: 'Tmp', style: preparedXAxis.labels.style}); + marginLeft += + AXIS_WIDTH + + preparedY1Axis.labels.padding + + getAxisLabelMaxWidth({axis: preparedY1Axis, series: series.data}) + + (preparedY1Axis.title.height || 0); + marginTop += + getHorisontalSvgTextDimensions({text: 'Tmp', style: preparedY1Axis.labels.style}) / 2; + marginRight += getAxisLabelMaxWidth({axis: preparedXAxis, series: series.data}) / 2; + } return { margin: { diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/index.ts b/src/plugins/d3/renderer/hooks/useChartOptions/index.ts index ea088f62..85bd5932 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/index.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/index.ts @@ -10,7 +10,9 @@ import {getPreparedXAxis} from './x-axis'; import {getPreparedYAxis} from './y-axis'; import type {ChartOptions} from './types'; -export const useChartOptions = (args: ChartKitWidgetData): ChartOptions => { +type Args = ChartKitWidgetData; + +export const useChartOptions = (args: Args): ChartOptions => { const {chart, series, legend, title, tooltip, xAxis, yAxis} = args; const options: ChartOptions = React.useMemo(() => { const preparedTitle = getPreparedTitle({title}); diff --git a/src/plugins/d3/renderer/hooks/useLegend/index.ts b/src/plugins/d3/renderer/hooks/useLegend/index.ts index 57381681..288b6b72 100644 --- a/src/plugins/d3/renderer/hooks/useLegend/index.ts +++ b/src/plugins/d3/renderer/hooks/useLegend/index.ts @@ -1,8 +1,9 @@ import React from 'react'; +import get from 'lodash/get'; import type {ChartKitWidgetSeries} from '../../../../../types/widget-data'; -import {getVisibleSeriesNames} from '../../utils'; +import {isAxisRelatedSeries} from '../../utils'; export type OnLegendItemClick = (data: {name: string; metaKey: boolean}) => void; @@ -10,9 +11,43 @@ 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(getVisibleSeriesNames(series)); + const [activeLegendItems, setActiveLegendItems] = React.useState(getActiveLegendItems(series)); const handleLegendItemClick: OnLegendItemClick = React.useCallback( ({name, metaKey}) => { @@ -25,7 +60,7 @@ export const useLegend = (args: Args) => { } else if (metaKey && !activeLegendItems.includes(name)) { nextActiveLegendItems = activeLegendItems.concat(name); } else if (onlyItemSelected) { - nextActiveLegendItems = getVisibleSeriesNames(series); + nextActiveLegendItems = getAllLegendItems(series); } else { nextActiveLegendItems = [name]; } @@ -37,7 +72,7 @@ export const useLegend = (args: Args) => { // FIXME: remove effect. It initiates extra rerender React.useEffect(() => { - setActiveLegendItems(getVisibleSeriesNames(series)); + 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 095224f1..76a4de0b 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/index.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/index.ts @@ -1,11 +1,16 @@ import React from 'react'; -import clone from 'lodash/clone'; -import {scaleOrdinal} from 'd3'; +import cloneDeep from 'lodash/cloneDeep'; +import get from 'lodash/get'; +import {ScaleOrdinal, scaleOrdinal} from 'd3'; -import type {ChartKitWidgetSeries} from '../../../../../types/widget-data'; +import type { + ChartKitWidgetSeries, + PieSeries, + PieSeriesData, +} from '../../../../../types/widget-data'; import {DEFAULT_PALETTE} from '../../constants'; -import {getSeriesNames} from '../../utils'; +import {getSeriesNames, isAxisRelatedSeries} from '../../utils'; export type ChartSeries = ChartKitWidgetSeries & { color: string; @@ -18,22 +23,74 @@ type Args = { series: ChartKitWidgetSeries[]; }; +const prepareAxisRelatedSeries = (args: { + activeLegendItems: string[]; + colorScale: ScaleOrdinal; + series: ChartKitWidgetSeries; +}) => { + const {activeLegendItems, colorScale, series} = args; + const preparedSeries = cloneDeep(series) as ChartSeries; + const legendEnabled = get(preparedSeries, 'legend.enabled', true); + 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; + + 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 dataNames = series.data.map((d) => d.name); + const colorScale = scaleOrdinal(dataNames, DEFAULT_PALETTE); + preparedSeries.data = (preparedSeries.data as PieSeriesData[]).map((d) => { + const defaultVisible = get(d, 'visible', true); + d.color = d.color || colorScale(d.name); + d.visible = legendEnabled ? activeLegendItems.includes(d.name) : defaultVisible; + return d; + }); + + // Not axis related series manages their own data visibility inside their data + preparedSeries.visible = true; + + return preparedSeries; +}; + +const prepareNotAxisRelatedSeries = (args: { + activeLegendItems: string[]; + series: ChartKitWidgetSeries; +}) => { + const {activeLegendItems, series} = args; + + switch (series.type) { + case 'pie': { + return preparePieSeries({activeLegendItems, series}); + } + default: { + throw new Error( + `Series type ${series.type} does not support data preparation for series that do not support the presence of axes`, + ); + } + } +}; + export const useSeries = (args: Args) => { const {activeLegendItems, series} = args; - // FIXME: handle case with one pie chart series const chartSeries = React.useMemo(() => { const seriesNames = getSeriesNames(series); const colorScale = scaleOrdinal(seriesNames, DEFAULT_PALETTE); return series.map((s) => { - const preparedSeries = clone(s) as ChartSeries; - const name = 'name' in s ? s.name : ''; - const color = 'color' in s && s.color ? s.color : colorScale(name); - preparedSeries.color = color; - preparedSeries.name = name; - preparedSeries.visible = activeLegendItems.includes(name); - - return preparedSeries; + return isAxisRelatedSeries(s) + ? prepareAxisRelatedSeries({activeLegendItems, colorScale, series: s}) + : prepareNotAxisRelatedSeries({ + activeLegendItems, + series: s, + }); }); }, [activeLegendItems, series]); diff --git a/src/plugins/d3/renderer/hooks/useShapes/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/index.tsx index 2efb48b7..a440c229 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {group} from 'd3'; -import type {BarXSeries, ScatterSeries} from '../../../../../types/widget-data'; +import type {BarXSeries, PieSeries, ScatterSeries} from '../../../../../types/widget-data'; import {getOnlyVisibleSeries} from '../../utils'; import type {ChartOptions} from '../useChartOptions/types'; @@ -10,8 +10,13 @@ import type {ChartSeries} from '../useSeries'; import type {OnSeriesMouseMove, OnSeriesMouseLeave} from '../useTooltip/types'; import {prepareBarXSeries} from './bar-x'; import {prepareScatterSeries} from './scatter'; +import {PieSeriesComponent} from './pie'; + +import './styles.scss'; type Args = { + boundsWidth: number; + boundsHeight: number; series: ChartSeries[]; xAxis: ChartOptions['xAxis']; yAxis: ChartOptions['yAxis']; @@ -24,6 +29,8 @@ type Args = { export const useShapes = (args: Args) => { const { + boundsWidth, + boundsHeight, series, xAxis, xScale, @@ -73,10 +80,36 @@ export const useShapes = (args: Args) => { } break; } + case 'pie': { + acc.push( + ...(chartSeries as PieSeries[]).map((cs, i) => ( + + )), + ); + } } return acc; }, []); - }, [series, xAxis, xScale, yAxis, yScale, svgContainer, onSeriesMouseMove, onSeriesMouseLeave]); + }, [ + boundsWidth, + boundsHeight, + series, + xAxis, + xScale, + yAxis, + yScale, + svgContainer, + onSeriesMouseMove, + onSeriesMouseLeave, + ]); return {shapes}; }; diff --git a/src/plugins/d3/renderer/hooks/useShapes/pie.tsx b/src/plugins/d3/renderer/hooks/useShapes/pie.tsx new file mode 100644 index 00000000..09c95163 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/pie.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import {arc, pie, select} from 'd3'; +import type {PieArcDatum} from 'd3'; + +import type {PieSeries, PieSeriesData} from '../../../../../types/widget-data'; +import {block} from '../../../../../utils/cn'; + +import {calculateNumericProperty} from '../../utils'; +import type {OnSeriesMouseLeave, OnSeriesMouseMove} from '../useTooltip/types'; + +type PreparePieSeriesArgs = { + boundsWidth: number; + boundsHeight: number; + series: PieSeries; + svgContainer: SVGSVGElement | null; + onSeriesMouseMove?: OnSeriesMouseMove; + onSeriesMouseLeave?: OnSeriesMouseLeave; +}; + +const b = block('d3-pie'); + +const getCenter = ( + boundsWidth: number, + boundsHeight: number, + center?: PieSeries['center'], +): [number, number] => { + const defaultX = boundsWidth * 0.5; + const defaultY = boundsHeight * 0.5; + + if (!center) { + return [defaultX, defaultY]; + } + + const [x, y] = center; + const resultX = calculateNumericProperty({value: x, base: boundsWidth}) ?? defaultX; + const resultY = calculateNumericProperty({value: y, base: boundsHeight}) ?? defaultY; + + return [resultX, resultY]; +}; + +export function PieSeriesComponent(args: PreparePieSeriesArgs) { + const {boundsWidth, boundsHeight, series, onSeriesMouseMove, onSeriesMouseLeave, svgContainer} = + args; + const ref = React.useRef(null); + const [x, y] = getCenter(boundsWidth, boundsHeight, series.center); + + React.useEffect(() => { + if (!ref.current) { + return; + } + + const svgElement = select(ref.current); + const radiusRelatedToChart = Math.min(boundsWidth, boundsHeight) / 2; + const radius = + calculateNumericProperty({value: series.radius, base: radiusRelatedToChart}) ?? + radiusRelatedToChart; + const innerRadius = + calculateNumericProperty({value: series.innerRadius, base: radius}) ?? 0; + const pieGenerator = pie().value((d) => d.value); + const visibleData = series.data.filter((d) => d.visible); + const dataReady = pieGenerator(visibleData); + const arcGenerator = arc>() + .innerRadius(innerRadius) + .outerRadius(radius) + .cornerRadius(series.borderRadius ?? 0); + svgElement.selectAll('*').remove(); + + svgElement + .selectAll('*') + .data(dataReady) + .enter() + .append('path') + .attr('d', arcGenerator) + .attr('class', b('segment')) + .attr('fill', (d) => d.data.color || '') + .style('stroke', series.borderColor || '') + .style('stroke-width', series.borderWidth ?? 1); + }, [boundsWidth, boundsHeight, series, onSeriesMouseMove, onSeriesMouseLeave, svgContainer]); + + return ; +} diff --git a/src/plugins/d3/renderer/hooks/useShapes/styles.scss b/src/plugins/d3/renderer/hooks/useShapes/styles.scss new file mode 100644 index 00000000..bc4ce052 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/styles.scss @@ -0,0 +1,20 @@ +.chartkit-d3-scatter { + &__point { + stroke-width: 1px; + + .chartkit-d3_hovered & { + opacity: 0.5; + } + + &:hover { + stroke: #fff; + opacity: 1; + } + } +} + +.chartkit-d3-pie { + &__segment { + stroke: var(--g-color-base-background); + } +} diff --git a/src/plugins/d3/renderer/utils/index.ts b/src/plugins/d3/renderer/utils/index.ts index 456bf478..1d1856a2 100644 --- a/src/plugins/d3/renderer/utils/index.ts +++ b/src/plugins/d3/renderer/utils/index.ts @@ -9,9 +9,16 @@ import type { import {formatNumber} from '../../../shared'; import type {FormatNumberOptions} from '../../../shared'; +export * from './math'; + const CHARTS_WITHOUT_AXIS: ChartKitWidgetSeries['type'][] = ['pie']; -// Сhecks whether the series should be drawn with axes +/** + * Checks whether the series should be drawn with axes. + * + * @param series - The series object to check. + * @returns `true` if the series should be drawn with axes, `false` otherwise. + */ export const isAxisRelatedSeries = (series: ChartKitWidgetSeries) => { return !CHARTS_WITHOUT_AXIS.includes(series.type); }; @@ -41,19 +48,6 @@ export const getSeriesNames = (series: ChartKitWidgetSeries[]) => { }, []); }; -// Uses to get all visible series names array (except `pie` charts) -export const getVisibleSeriesNames = (series: ChartKitWidgetSeries[]) => { - return series.reduce((acc, s) => { - const visible = s.visible ?? true; - - if ('name' in s && typeof s.name === 'string' && visible) { - acc.push(s.name); - } - - return acc; - }, []); -}; - export const getOnlyVisibleSeries = (series: T[]) => { return series.filter((s) => s.visible); }; diff --git a/src/plugins/d3/renderer/utils/math.ts b/src/plugins/d3/renderer/utils/math.ts new file mode 100644 index 00000000..f6ee3060 --- /dev/null +++ b/src/plugins/d3/renderer/utils/math.ts @@ -0,0 +1,51 @@ +import isNil from 'lodash/isNil'; + +const isStringValueInPercent = (value = '') => { + return value.endsWith('%') && !Number.isNaN(Number.parseFloat(value)); +}; + +const isStringValueInPixel = (value = '') => { + return value.endsWith('px') && !Number.isNaN(Number.parseFloat(value)); +}; + +/** + * Calculates a numeric property based on the given arguments. + * + * @param {Object} args - The arguments for the calculation. + * @param {string | number | null} args.value - The value to calculate the property for. + * @param {number} args.base - The base value to use in the calculation. + * @return {number | undefined} The calculated numeric property, or undefined if the value is invalid. + * @example + * const result1 = calculateNumericProperty({value: 1}); + * console.log(result1); // Output: 1 + * const result2 = calculateNumericProperty({value: '10px'}); + * console.log(result2); // Output: 10 + * const result3 = calculateNumericProperty({value: '50%', base: 200}); + * console.log(result3); // Output: 100 + * const result4 = calculateNumericProperty({value: '50%'}); + * console.log(result4); // Output: undefined + * const result5 = calculateNumericProperty({value: 'invalid_value'}); + * console.log(result5); // Output: undefined + */ +export const calculateNumericProperty = (args: {value?: string | number | null; base?: number}) => { + const {value = '', base} = args; + + if (isNil(value)) { + return undefined; + } + + if (typeof value === 'string') { + if (isStringValueInPercent(value) && typeof base === 'number') { + const fraction = Number.parseFloat(value) / 100; + return base * fraction; + } + + if (isStringValueInPixel(value)) { + return Number.parseFloat(value); + } + + return undefined; + } + + return value; +}; diff --git a/src/types/widget-data/bar-x.ts b/src/types/widget-data/bar-x.ts index c7639d11..c0628937 100644 --- a/src/types/widget-data/bar-x.ts +++ b/src/types/widget-data/bar-x.ts @@ -14,7 +14,7 @@ export type BarXSeries = BaseSeries & { type: 'bar-x'; data: BarXSeriesData[]; - /** The name of the series (used in legend) */ + /** The name of the series (used in legend, tooltip etc) */ name: string; /** The main color of the series (hex, rgba) */ diff --git a/src/types/widget-data/base.ts b/src/types/widget-data/base.ts index 54b55123..f687ab5c 100644 --- a/src/types/widget-data/base.ts +++ b/src/types/widget-data/base.ts @@ -1,4 +1,8 @@ +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/pie.ts b/src/types/widget-data/pie.ts index 73b86422..10a875cc 100644 --- a/src/types/widget-data/pie.ts +++ b/src/types/widget-data/pie.ts @@ -1,12 +1,29 @@ import type {BaseSeries, BaseSeriesData} from './base'; export type PieSeriesData = BaseSeriesData & { + /** The value of the pie segment. */ value: number; - color: string; + /** The name of the pie segment (used in legend, tooltip etc). */ name: string; + /** Individual color for the pie segment. */ + color?: string; + /** Initial visibility of the pie segment. */ + visible?: boolean; }; export type PieSeries = BaseSeries & { type: 'pie'; data: PieSeriesData[]; + /** The color of the border surrounding each segment. Default `--g-color-base-background` from @gravity-ui/uikit. */ + borderColor?: string; + /** The width of the border surrounding each segment. Default 1px. */ + borderWidth?: number; + /** The corner radius of the border surrounding each segment. Default 0. */ + borderRadius?: number; + /** The center of the pie chart relative to the chart area. */ + center?: [string | number | null, string | number | null]; + /** The inner radius of the pie. Default 0. */ + 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; }; diff --git a/src/types/widget-data/scatter.ts b/src/types/widget-data/scatter.ts index 901980f3..3d42d3de 100644 --- a/src/types/widget-data/scatter.ts +++ b/src/types/widget-data/scatter.ts @@ -13,7 +13,7 @@ export type ScatterSeriesData = BaseSeriesData & { export type ScatterSeries = BaseSeries & { type: 'scatter'; data: ScatterSeriesData[]; - /** The name of the series (used in legend) */ + /** The name of the series (used in legend, tooltip etc) */ name: string; /** The main color of the series (hex, rgba) */ color?: string;