From a1a76f8dd5014e13e61a13821c01ce8a852b89b7 Mon Sep 17 00:00:00 2001 From: "Mr.Dr.Professor Patrick" Date: Mon, 25 Sep 2023 18:54:00 +0200 Subject: [PATCH] feat(D3 plugin): move tooltip management to d3 events (#300) * feat(D3 plugin): move tooltip management to d3 events * fix: TooltipTriggerArea fixes * fix: add window page offset * fix: remove TooltipDataChunkType * fix: fix d3 data management * fix: other review fixes * fix: remove scatter stroke on hover * fix: remove redundant comment --- .../d3/__stories__/pie/Styled.stories.tsx | 2 +- .../__stories__/scatter/BigLegend.stories.tsx | 2 +- .../__stories__/scatter/Timestamp.stories.tsx | 2 +- src/plugins/d3/examples/scatter/Basic.tsx | 7 +- src/plugins/d3/renderer/components/Chart.tsx | 68 ++++--- .../components/Tooltip/DefaultContent.tsx | 16 +- .../components/Tooltip/TooltipTriggerArea.tsx | 110 +++++++++++ .../d3/renderer/components/Tooltip/index.tsx | 13 +- .../d3/renderer/constants/defaults/index.ts | 1 + .../constants/defaults/series-options.ts | 49 +++++ src/plugins/d3/renderer/d3-dispatcher.ts | 5 + src/plugins/d3/renderer/hooks/index.ts | 1 - .../d3/renderer/hooks/useChartEvents/index.ts | 19 -- .../d3/renderer/hooks/useSeries/index.ts | 11 +- .../hooks/useSeries/prepare-options.ts | 12 ++ .../renderer/hooks/useSeries/prepareSeries.ts | 19 +- .../d3/renderer/hooks/useSeries/types.ts | 8 +- .../renderer/hooks/useShapes/bar-x/index.tsx | 124 ++++++++++++ .../{bar-x.tsx => bar-x/prepare-data.ts} | 157 ++------------- .../d3/renderer/hooks/useShapes/defaults.ts | 5 - .../d3/renderer/hooks/useShapes/index.tsx | 106 +++++----- .../d3/renderer/hooks/useShapes/pie.tsx | 186 +++++++++++++++--- .../d3/renderer/hooks/useShapes/scatter.tsx | 137 ------------- .../hooks/useShapes/scatter/index.tsx | 170 ++++++++++++++++ .../hooks/useShapes/scatter/prepare-data.ts | 84 ++++++++ .../d3/renderer/hooks/useShapes/styles.scss | 10 +- .../d3/renderer/hooks/useTooltip/index.ts | 55 +++--- .../d3/renderer/hooks/useTooltip/types.ts | 9 - src/plugins/d3/renderer/utils/index.ts | 11 ++ src/types/widget-data/series.ts | 58 +++++- src/types/widget-data/tooltip.ts | 27 ++- 31 files changed, 1002 insertions(+), 482 deletions(-) create mode 100644 src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx create mode 100644 src/plugins/d3/renderer/constants/defaults/series-options.ts create mode 100644 src/plugins/d3/renderer/d3-dispatcher.ts delete mode 100644 src/plugins/d3/renderer/hooks/useChartEvents/index.ts create mode 100644 src/plugins/d3/renderer/hooks/useSeries/prepare-options.ts create mode 100644 src/plugins/d3/renderer/hooks/useShapes/bar-x/index.tsx rename src/plugins/d3/renderer/hooks/useShapes/{bar-x.tsx => bar-x/prepare-data.ts} (52%) delete mode 100644 src/plugins/d3/renderer/hooks/useShapes/defaults.ts delete mode 100644 src/plugins/d3/renderer/hooks/useShapes/scatter.tsx create mode 100644 src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx create mode 100644 src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts diff --git a/src/plugins/d3/__stories__/pie/Styled.stories.tsx b/src/plugins/d3/__stories__/pie/Styled.stories.tsx index 85374892..fde3aa9d 100644 --- a/src/plugins/d3/__stories__/pie/Styled.stories.tsx +++ b/src/plugins/d3/__stories__/pie/Styled.stories.tsx @@ -63,7 +63,7 @@ const Template: Story = () => { }, title: {text: 'Styled pies'}, legend: {enabled: false}, - tooltip: {enabled: false}, + tooltip: {enabled: true}, }; if (!shown) { diff --git a/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx b/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx index fb5256d7..6d1eadf9 100644 --- a/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx +++ b/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx @@ -36,7 +36,7 @@ const shapeData = (): ChartKitWidgetData => { itemDistance: number('Item distance', 20, undefined, 'legend'), }, series: { - data: generateSeriesData(number('Amount of series', 100, undefined, 'legend')), + data: generateSeriesData(number('Amount of series', 1000, undefined, 'legend')), }, xAxis: { labels: { diff --git a/src/plugins/d3/__stories__/scatter/Timestamp.stories.tsx b/src/plugins/d3/__stories__/scatter/Timestamp.stories.tsx index d0127d38..1bfb0437 100644 --- a/src/plugins/d3/__stories__/scatter/Timestamp.stories.tsx +++ b/src/plugins/d3/__stories__/scatter/Timestamp.stories.tsx @@ -79,7 +79,7 @@ const shapeData = (data: Record[]): ChartKitWidgetData => { ], tooltip: { renderer: ({hovered}) => { - const d = hovered.data as ScatterSeriesData; + const d = hovered[0].data as ScatterSeriesData; return
{dateTime({input: d.x}).format('LL')}
; }, }, diff --git a/src/plugins/d3/examples/scatter/Basic.tsx b/src/plugins/d3/examples/scatter/Basic.tsx index 828d9db6..deb4bb2e 100644 --- a/src/plugins/d3/examples/scatter/Basic.tsx +++ b/src/plugins/d3/examples/scatter/Basic.tsx @@ -48,7 +48,12 @@ export const Basic = () => { }, tooltip: { renderer: (d) => { - const point = d.hovered.data as ScatterSeriesData; + const point = d.hovered[0]?.data as ScatterSeriesData; + + if (!point) { + return null; + } + const title = point.custom.title; const score = point.custom.user_score; const date = dateTime({input: point.custom.date}).format('DD MMM YYYY'); diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index 43f40095..f438cb97 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -3,10 +3,10 @@ import React from 'react'; import type {ChartKitWidgetData} from '../../../../types'; import {block} from '../../../../utils/cn'; +import {getD3Dispatcher} from '../d3-dispatcher'; import { useAxisScales, useChartDimensions, - useChartEvents, useChartOptions, useSeries, useShapes, @@ -16,7 +16,7 @@ import {AxisY} from './AxisY'; import {AxisX} from './AxisX'; import {Legend} from './Legend'; import {Title} from './Title'; -import {Tooltip} from './Tooltip'; +import {Tooltip, TooltipTriggerArea} from './Tooltip'; import './styles.scss'; @@ -34,19 +34,27 @@ export const Chart = (props: Props) => { // FIXME: add data validation const {top, left, width, height, data} = props; const svgRef = React.createRef(); - const {chartHovered, handleMouseEnter, handleMouseLeave} = useChartEvents(); + const dispatcher = React.useMemo(() => { + return getD3Dispatcher(); + }, []); 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 { + legendItems, + legendConfig, + preparedSeries, + preparedSeriesOptions, + preparedLegend, + handleLegendItemClick, + } = useSeries({ + chartWidth: width, + chartHeight: height, + chartMargin: chart.margin, + series: data.series, + legend: data.legend, + preparedYAxis: yAxis, + }); const {boundsWidth, boundsHeight} = useChartDimensions({ width, height, @@ -63,35 +71,25 @@ export const Chart = (props: Props) => { xAxis, yAxis, }); - const {hovered, pointerPosition, handleSeriesMouseMove, handleSeriesMouseLeave} = useTooltip({ - tooltip, - }); - const {shapes} = useShapes({ + const {hovered, pointerPosition} = useTooltip({dispatcher, tooltip}); + const {shapes, shapesData} = useShapes({ top, left, boundsWidth, boundsHeight, + dispatcher, series: preparedSeries, - seriesOptions: data.series.options, + seriesOptions: preparedSeriesOptions, xAxis, xScale, yAxis, yScale, svgContainer: svgRef.current, - onSeriesMouseMove: handleSeriesMouseMove, - onSeriesMouseLeave: handleSeriesMouseLeave, }); return ( - + {title && } { )} {shapes} + {tooltip?.enabled && Boolean(shapesData.length) && ( + + )} {preparedLegend.enabled && ( { )} ); diff --git a/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx b/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx index 435203af..315f87c6 100644 --- a/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx +++ b/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx @@ -1,13 +1,13 @@ import React from 'react'; import get from 'lodash/get'; import {dateTime} from '@gravity-ui/date-utils'; -import type {ChartKitWidgetSeriesData, TooltipHoveredData} from '../../../../../types'; +import type {ChartKitWidgetSeriesData, TooltipDataChunk} from '../../../../../types'; import {formatNumber} from '../../../../shared'; -import type {PreparedAxis} from '../../hooks'; +import type {PreparedAxis, PreparedPieSeries} from '../../hooks'; import {getDataCategoryValue} from '../../utils'; type Props = { - hovered: TooltipHoveredData; + hovered: TooltipDataChunk; xAxis: PreparedAxis; yAxis: PreparedAxis; }; @@ -75,6 +75,16 @@ export const DefaultContent = ({hovered, xAxis, yAxis}: Props) => { ); } + case 'pie': { + const pieSeries = series as PreparedPieSeries; + + return ( +
+ {pieSeries.name || pieSeries.id}  + {pieSeries.value} +
+ ); + } default: { return null; } diff --git a/src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx b/src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx new file mode 100644 index 00000000..3029662f --- /dev/null +++ b/src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import throttle from 'lodash/throttle'; +import {bisector, pointer, sort} from 'd3'; +import type {Dispatch} from 'd3'; + +import type {ShapeData, PreparedBarXData, PointerPosition} from '../../hooks'; +import {extractD3DataFromNode, isNodeContainsD3Data} from '../../utils'; +import type {NodeWithD3Data} from '../../utils'; + +const THROTTLE_DELAY = 50; + +type Args = { + boundsWidth: number; + boundsHeight: number; + dispatcher: Dispatch; + offsetTop: number; + offsetLeft: number; + shapesData: ShapeData[]; + svgContainer: SVGSVGElement | null; +}; + +type CalculationType = 'x-primary' | 'none'; + +const isNodeContainsData = (node?: Element): node is NodeWithD3Data => { + return isNodeContainsD3Data(node); +}; + +const getCalculationType = (shapesData: ShapeData[]): CalculationType => { + if (shapesData.every((d) => d.series.type === 'bar-x')) { + return 'x-primary'; + } + + return 'none'; +}; + +export const TooltipTriggerArea = (args: Args) => { + const {boundsWidth, boundsHeight, dispatcher, offsetTop, offsetLeft, shapesData, svgContainer} = + args; + const rectRef = React.useRef(null); + const calculationType = React.useMemo(() => { + return getCalculationType(shapesData); + }, [shapesData]); + const xData = React.useMemo(() => { + return calculationType === 'x-primary' + ? sort(new Set((shapesData as PreparedBarXData[]).map((d) => d.x))) + : []; + }, [shapesData, calculationType]); + + const handleXprimaryMouseMove: React.MouseEventHandler = (e) => { + const {left, top} = rectRef.current?.getBoundingClientRect() || {left: 0, top: 0}; + const [pointerX, pointerY] = pointer(e, svgContainer); + const barWidthOffset = (shapesData[0] as PreparedBarXData).width / 2; + const xPosition = pointerX - left - barWidthOffset - window.pageXOffset; + const xDataIndex = bisector((d) => d).center(xData, xPosition); + const xNodes = Array.from( + rectRef.current?.parentElement?.querySelectorAll(`[x="${xData[xDataIndex]}"]`) || [], + ); + + let hoverShapeData: ShapeData[] | undefined; + + if (xNodes.length === 1 && isNodeContainsData(xNodes[0])) { + hoverShapeData = [extractD3DataFromNode(xNodes[0])]; + } else if (xNodes.length > 1 && xNodes.every(isNodeContainsData)) { + const yPosition = pointerY - top - window.pageYOffset; + const xyNode = xNodes.find((node, i) => { + const {y, height} = extractD3DataFromNode(node) as PreparedBarXData; + if (i === xNodes.length - 1) { + return yPosition <= y + height; + } + return yPosition >= y && yPosition <= y + height; + }); + + if (xyNode) { + hoverShapeData = [extractD3DataFromNode(xyNode)]; + } + } + + if (hoverShapeData) { + const position: PointerPosition = [pointerX - offsetLeft, pointerY - offsetTop]; + dispatcher.call('hover-shape', e.target, hoverShapeData, position); + } + }; + + const handleMouseMove: React.MouseEventHandler = (e) => { + switch (calculationType) { + case 'x-primary': { + handleXprimaryMouseMove(e); + return; + } + } + }; + + const throttledHandleMouseMove = throttle(handleMouseMove, THROTTLE_DELAY); + + const handleMouseLeave = () => { + throttledHandleMouseMove.cancel(); + dispatcher.call('hover-shape', {}, undefined); + }; + + return ( + + ); +}; diff --git a/src/plugins/d3/renderer/components/Tooltip/index.tsx b/src/plugins/d3/renderer/components/Tooltip/index.tsx index c8053c62..63a870b9 100644 --- a/src/plugins/d3/renderer/components/Tooltip/index.tsx +++ b/src/plugins/d3/renderer/components/Tooltip/index.tsx @@ -1,25 +1,29 @@ import React from 'react'; import isNil from 'lodash/isNil'; +import type {Dispatch} from 'd3'; -import type {TooltipHoveredData} from '../../../../../types/widget-data'; +import type {TooltipDataChunk} from '../../../../../types/widget-data'; import {block} from '../../../../../utils/cn'; import type {PointerPosition, PreparedAxis, PreparedTooltip} from '../../hooks'; import {DefaultContent} from './DefaultContent'; +export * from './TooltipTriggerArea'; + const b = block('d3-tooltip'); const POINTER_OFFSET_X = 20; type TooltipProps = { + dispatcher: Dispatch; tooltip: PreparedTooltip; xAxis: PreparedAxis; yAxis: PreparedAxis; - hovered?: TooltipHoveredData; + hovered?: TooltipDataChunk[]; pointerPosition?: PointerPosition; }; export const Tooltip = (props: TooltipProps) => { - const {hovered, pointerPosition, tooltip, xAxis, yAxis} = props; + const {tooltip, xAxis, yAxis, hovered, pointerPosition} = props; const ref = React.useRef(null); const size = React.useMemo(() => { if (ref.current && hovered) { @@ -27,6 +31,7 @@ export const Tooltip = (props: TooltipProps) => { return {width, height}; } return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [hovered, pointerPosition]); const position = React.useMemo(() => { if (hovered && pointerPosition && size) { @@ -54,7 +59,7 @@ export const Tooltip = (props: TooltipProps) => { const customTooltip = tooltip.renderer?.({hovered}); return isNil(customTooltip) ? ( - + ) : ( customTooltip ); diff --git a/src/plugins/d3/renderer/constants/defaults/index.ts b/src/plugins/d3/renderer/constants/defaults/index.ts index bdb02733..7dfc56d3 100644 --- a/src/plugins/d3/renderer/constants/defaults/index.ts +++ b/src/plugins/d3/renderer/constants/defaults/index.ts @@ -1,2 +1,3 @@ export * from './axis'; export * from './legend'; +export * from './series-options'; diff --git a/src/plugins/d3/renderer/constants/defaults/series-options.ts b/src/plugins/d3/renderer/constants/defaults/series-options.ts new file mode 100644 index 00000000..ebfef8eb --- /dev/null +++ b/src/plugins/d3/renderer/constants/defaults/series-options.ts @@ -0,0 +1,49 @@ +import type {ChartKitWidgetSeriesOptions} from '../../../../../types'; + +type DefauleBarXSeriesOptions = Partial & { + 'bar-x': {barMaxWidth: number; barPadding: number; groupPadding: number}; +}; + +export type SeriesOptionsDefaults = Partial & DefauleBarXSeriesOptions; + +export const seriesOptionsDefaults: SeriesOptionsDefaults = { + 'bar-x': { + barMaxWidth: 50, + barPadding: 0.1, + groupPadding: 0.2, + states: { + hover: { + enabled: true, + brightness: 0.3, + }, + inactive: { + enabled: true, + opacity: 0.5, + }, + }, + }, + pie: { + states: { + hover: { + enabled: true, + brightness: 0.3, + }, + inactive: { + enabled: true, + opacity: 0.5, + }, + }, + }, + scatter: { + states: { + hover: { + enabled: true, + brightness: 0.3, + }, + inactive: { + enabled: true, + opacity: 0.5, + }, + }, + }, +}; diff --git a/src/plugins/d3/renderer/d3-dispatcher.ts b/src/plugins/d3/renderer/d3-dispatcher.ts new file mode 100644 index 00000000..19d62a72 --- /dev/null +++ b/src/plugins/d3/renderer/d3-dispatcher.ts @@ -0,0 +1,5 @@ +import {dispatch} from 'd3'; + +export const getD3Dispatcher = () => { + return dispatch('hover-shape'); +}; diff --git a/src/plugins/d3/renderer/hooks/index.ts b/src/plugins/d3/renderer/hooks/index.ts index 498be3df..3e7cb54c 100644 --- a/src/plugins/d3/renderer/hooks/index.ts +++ b/src/plugins/d3/renderer/hooks/index.ts @@ -1,5 +1,4 @@ export * from './useChartDimensions'; -export * from './useChartEvents'; export * from './useChartOptions'; export * from './useChartOptions/types'; export * from './useAxisScales'; diff --git a/src/plugins/d3/renderer/hooks/useChartEvents/index.ts b/src/plugins/d3/renderer/hooks/useChartEvents/index.ts deleted file mode 100644 index 88abbade..00000000 --- a/src/plugins/d3/renderer/hooks/useChartEvents/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -export const useChartEvents = () => { - const [chartHovered, setChartHovered] = React.useState(false); - - const handleMouseEnter = React.useCallback(() => { - setChartHovered(true); - }, []); - - const handleMouseLeave = React.useCallback(() => { - setChartHovered(false); - }, []); - - return { - chartHovered, - handleMouseEnter, - handleMouseLeave, - }; -}; diff --git a/src/plugins/d3/renderer/hooks/useSeries/index.ts b/src/plugins/d3/renderer/hooks/useSeries/index.ts index a6e2c4ce..319f6d9e 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/index.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/index.ts @@ -9,6 +9,7 @@ import type {PreparedAxis, PreparedChart} from '../useChartOptions/types'; import {getActiveLegendItems, getAllLegendItems} from './utils'; import type {PreparedSeries, OnLegendItemClick} from './types'; import {getPreparedLegend, getLegendComponents} from './prepare-legend'; +import {getPreparedOptions} from './prepare-options'; import {prepareSeries} from './prepareSeries'; type Args = { @@ -27,7 +28,7 @@ export const useSeries = (args: Args) => { chartMargin, legend, preparedYAxis, - series: {data: series}, + series: {data: series, options: seriesOptions}, } = args; const preparedLegend = React.useMemo( () => getPreparedLegend({legend, series}), @@ -53,11 +54,16 @@ export const useSeries = (args: Args) => { [], ); }, [series, preparedLegend]); + const preparedSeriesOptions = React.useMemo(() => { + return getPreparedOptions(seriesOptions); + }, [seriesOptions]); const [activeLegendItems, setActiveLegendItems] = React.useState( getActiveLegendItems(preparedSeries), ); const chartSeries = React.useMemo(() => { - return preparedSeries.map((singleSeries) => { + return preparedSeries.map((singleSeries, i) => { + singleSeries.id = `Series ${i + 1}`; + if (singleSeries.legend.enabled) { return { ...singleSeries, @@ -110,6 +116,7 @@ export const useSeries = (args: Args) => { legendConfig, preparedLegend, preparedSeries: chartSeries, + preparedSeriesOptions, handleLegendItemClick, }; }; diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-options.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-options.ts new file mode 100644 index 00000000..3d121e1d --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-options.ts @@ -0,0 +1,12 @@ +import merge from 'lodash/merge'; + +import type {ChartKitWidgetSeriesOptions} from '../../../../../types/widget-data'; + +import {seriesOptionsDefaults} from '../../constants'; +import type {PreparedSeriesOptions} from './types'; + +export const getPreparedOptions = ( + options?: ChartKitWidgetSeriesOptions, +): PreparedSeriesOptions => { + return merge({}, seriesOptionsDefaults, options); +}; diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts index fd378427..7f0fa9f1 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts @@ -1,12 +1,19 @@ +import cloneDeep from 'lodash/cloneDeep'; +import get from 'lodash/get'; import type {ScaleOrdinal} from 'd3'; import {scaleOrdinal} from 'd3'; + import type { BarXSeries, ChartKitWidgetSeries, PieSeries, RectLegendSymbolOptions, } from '../../../../../types/widget-data'; -import cloneDeep from 'lodash/cloneDeep'; +import {getRandomCKId} from '../../../../../utils'; +import {BaseTextStyle} from '../../../../../types/widget-data'; + +import {DEFAULT_PALETTE} from '../../constants'; +import {DEFAULT_LEGEND_SYMBOL_SIZE} from './constants'; import type { PreparedBarXSeries, PreparedLegend, @@ -14,11 +21,6 @@ import type { PreparedPieSeries, PreparedSeries, } from './types'; -import get from 'lodash/get'; -import {DEFAULT_PALETTE} from '../../constants'; -import {DEFAULT_LEGEND_SYMBOL_SIZE} from './constants'; -import {getRandomCKId} from '../../../../../utils'; -import {BaseTextStyle} from '../../../../../types/widget-data'; const DEFAULT_DATALABELS_STYLE: BaseTextStyle = { fontSize: '11px', @@ -87,6 +89,7 @@ function prepareBarXSeries(args: PrepareBarXSeriesArgs): PreparedSeries[] { type: series.type, color: color, name: name, + id: '', visible: get(series, 'visible', true), legend: { enabled: get(series, 'legend.enabled', legend.enabled), @@ -121,13 +124,15 @@ function preparePieSeries(args: PreparePieSeriesArgs) { const preparedSeries: PreparedSeries[] = series.data.map((dataItem) => { const result: PreparedPieSeries = { type: 'pie', - data: dataItem.value, + data: dataItem, dataLabels: { enabled: get(series, 'dataLabels.enabled', true), }, label: dataItem.label, + value: dataItem.value, visible: typeof dataItem.visible === 'boolean' ? dataItem.visible : true, name: dataItem.name, + id: '', color: dataItem.color || colorScale(dataItem.name), legend: { enabled: get(series, 'legend.enabled', legend.enabled), diff --git a/src/plugins/d3/renderer/hooks/useSeries/types.ts b/src/plugins/d3/renderer/hooks/useSeries/types.ts index 066ced75..ff002efb 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/types.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/types.ts @@ -10,6 +10,8 @@ import { ScatterSeriesData, } from '../../../../../types/widget-data'; +import type {SeriesOptionsDefaults} from '../../constants'; + export type RectLegendSymbol = { shape: 'rect'; } & Required; @@ -45,6 +47,7 @@ export type LegendConfig = { type BasePreparedSeries = { color: string; name: string; + id: string; visible: boolean; legend: { enabled: boolean; @@ -70,9 +73,12 @@ export type PreparedBarXSeries = { export type PreparedPieSeries = BasePreparedSeries & Required> & { - data: PieSeriesData['value']; + data: PieSeriesData; + value: PieSeriesData['value']; stackId: string; label?: PieSeriesData['label']; }; export type PreparedSeries = PreparedScatterSeries | PreparedBarXSeries | PreparedPieSeries; + +export type PreparedSeriesOptions = SeriesOptionsDefaults; diff --git a/src/plugins/d3/renderer/hooks/useShapes/bar-x/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/bar-x/index.tsx new file mode 100644 index 00000000..08597bed --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/bar-x/index.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import get from 'lodash/get'; +import {color, select} from 'd3'; +import type {Dispatch} from 'd3'; + +import {block} from '../../../../../../utils/cn'; + +import type {PreparedSeriesOptions} from '../../useSeries/types'; +import type {PreparedBarXData} from './prepare-data'; + +export {prepareBarXData} from './prepare-data'; +export type {PreparedBarXData} from './prepare-data'; + +const DEFAULT_LABEL_PADDING = 7; + +const b = block('d3-bar-x'); + +type Args = { + dispatcher: Dispatch; + preparedData: PreparedBarXData[]; + seriesOptions: PreparedSeriesOptions; +}; + +export const BarXSeriesShapes = (args: Args) => { + const {dispatcher, preparedData, seriesOptions} = args; + + const ref = React.useRef(null); + + React.useEffect(() => { + if (!ref.current) { + return () => {}; + } + + const svgElement = select(ref.current); + const hoverOptions = get(seriesOptions, 'bar-x.states.hover'); + const inactiveOptions = get(seriesOptions, 'bar-x.states.inactive'); + svgElement.selectAll('*').remove(); + const rectSelection = svgElement + .selectAll('allRects') + .data(preparedData) + .join('rect') + .attr('class', b('segment')) + .attr('x', (d) => d.x) + .attr('y', (d) => d.y) + .attr('height', (d) => d.height) + .attr('width', (d) => d.width) + .attr('fill', (d) => d.data.color || d.series.color); + + const dataLabels = preparedData.filter((d) => d.series.dataLabels.enabled); + + const labelSelection = svgElement + .selectAll('allLabels') + .data(dataLabels) + .join('text') + .text((d) => String(d.data.label || d.data.y)) + .attr('class', b('label')) + .attr('x', (d) => d.x + d.width / 2) + .attr('y', (d) => { + if (d.series.dataLabels.inside) { + return d.y + d.height / 2; + } + + return d.y - DEFAULT_LABEL_PADDING; + }) + .attr('text-anchor', 'middle') + .style('font-size', (d) => d.series.dataLabels.style.fontSize) + .style('font-weight', (d) => d.series.dataLabels.style.fontWeight || null) + .style('fill', (d) => d.series.dataLabels.style.fontColor || null); + + dispatcher.on('hover-shape.bar-x', (data?: PreparedBarXData[]) => { + const hoverEnabled = hoverOptions?.enabled; + const inactiveEnabled = inactiveOptions?.enabled; + + if (!data) { + if (hoverEnabled) { + rectSelection.attr('fill', (d) => d.data.color || d.series.color); + } + + if (inactiveEnabled) { + rectSelection.attr('opacity', null); + labelSelection.attr('opacity', null); + } + + return; + } + + if (hoverEnabled) { + const hoveredValues = data.map((d) => d.data.x); + rectSelection.attr('fill', (d) => { + const fillColor = d.data.color || d.series.color; + + if (hoveredValues.includes(d.data.x)) { + return ( + color(fillColor)?.brighter(hoverOptions?.brightness).toString() || + fillColor + ); + } + + return fillColor; + }); + } + + if (inactiveEnabled) { + const hoveredSeries = data.map((d) => d.series.id); + rectSelection.attr('opacity', (d) => { + return hoveredSeries.includes(d.series.id) + ? null + : inactiveOptions?.opacity || null; + }); + labelSelection.attr('opacity', (d) => { + return hoveredSeries.includes(d.series.id) + ? null + : inactiveOptions?.opacity || null; + }); + } + }); + + return () => { + dispatcher.on('hover-shape.bar-x', null); + }; + }, [dispatcher, preparedData, seriesOptions]); + + return ; +}; diff --git a/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx b/src/plugins/d3/renderer/hooks/useShapes/bar-x/prepare-data.ts similarity index 52% rename from src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx rename to src/plugins/d3/renderer/hooks/useShapes/bar-x/prepare-data.ts index 02d184da..ad789cb5 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/bar-x/prepare-data.ts @@ -1,67 +1,39 @@ -import {ascending, descending, max, pointer, select, sort} from 'd3'; +import {ascending, descending, max, sort} from 'd3'; import type {ScaleBand, ScaleLinear, ScaleTime} from 'd3'; -import React from 'react'; import get from 'lodash/get'; -import type {BarXSeriesData, ChartKitWidgetSeriesOptions} from '../../../../../types'; -import {block} from '../../../../../utils/cn'; +import type {BarXSeriesData, TooltipDataChunkBarX} from '../../../../../../types'; -import {getDataCategoryValue} from '../../utils'; -import type {ChartScale} from '../useAxisScales'; -import type {ChartOptions} from '../useChartOptions/types'; -import type {OnSeriesMouseLeave, OnSeriesMouseMove} from '../useTooltip/types'; -import type {PreparedBarXSeries} from '../useSeries/types'; -import {DEFAULT_BAR_X_SERIES_OPTIONS} from './defaults'; +import {getDataCategoryValue} from '../../../utils'; +import type {ChartScale} from '../../useAxisScales'; +import type {ChartOptions} from '../../useChartOptions/types'; +import type {PreparedBarXSeries, PreparedSeriesOptions} from '../../useSeries/types'; const MIN_RECT_WIDTH = 1; const MIN_RECT_GAP = 1; const MIN_GROUP_GAP = 1; -const DEFAULT_LABEL_PADDING = 7; -const b = block('d3-bar-x'); - -type Args = { - top: number; - left: number; - series: PreparedBarXSeries[]; - seriesOptions?: ChartKitWidgetSeriesOptions; - xAxis: ChartOptions['xAxis']; - xScale: ChartScale; - yAxis: ChartOptions['yAxis']; - yScale: ChartScale; - onSeriesMouseMove?: OnSeriesMouseMove; - onSeriesMouseLeave?: OnSeriesMouseLeave; - svgContainer: SVGSVGElement | null; -}; - -type ShapeData = { +export type PreparedBarXData = Omit & { x: number; y: number; width: number; height: number; - data: BarXSeriesData; series: PreparedBarXSeries; }; -function prepareData(args: { +export const prepareBarXData = (args: { series: PreparedBarXSeries[]; - seriesOptions?: ChartKitWidgetSeriesOptions; + seriesOptions: PreparedSeriesOptions; xAxis: ChartOptions['xAxis']; xScale: ChartScale; yAxis: ChartOptions['yAxis']; yScale: ChartScale; -}) { +}): PreparedBarXData[] => { const {series, seriesOptions, xAxis, xScale, yScale} = args; const categories = get(xAxis, 'categories', [] as string[]); - const { - barMaxWidth: defaultBarMaxWidth, - barPadding: defaultBarPadding, - groupPadding: defaultGroupPadding, - } = DEFAULT_BAR_X_SERIES_OPTIONS; - const barMaxWidth = get(seriesOptions, 'bar-x.barMaxWidth', defaultBarMaxWidth); - const barPadding = get(seriesOptions, 'bar-x.barPadding', defaultBarPadding); - const groupPadding = get(seriesOptions, 'bar-x.groupPadding', defaultGroupPadding); - + const barMaxWidth = get(seriesOptions, 'bar-x.barMaxWidth'); + const barPadding = get(seriesOptions, 'bar-x.barPadding'); + const groupPadding = get(seriesOptions, 'bar-x.groupPadding'); const sortingOptions = get(seriesOptions, 'bar-x.dataSorting'); const comparator = sortingOptions?.direction === 'desc' ? descending : ascending; const sortKey = (() => { @@ -136,7 +108,7 @@ function prepareData(args: { Math.min(groupWidth / maxGroupSize - rectGap, barMaxWidth), ); - const result: ShapeData[] = []; + const result: PreparedBarXData[] = []; Object.entries(data).forEach(([xValue, val]) => { const stacks = Object.values(val); @@ -149,6 +121,7 @@ function prepareData(args: { : yValues; sortedData.forEach((yValue) => { let xCenter; + if (xAxis.type === 'category') { const xBandScale = xScale as ScaleBand; xCenter = (xBandScale(xValue as string) || 0) + xBandScale.bandwidth() / 2; @@ -158,8 +131,8 @@ function prepareData(args: { | ScaleTime; xCenter = xLinearScale(Number(xValue)); } - const x = xCenter - currentGroupWidth / 2 + (rectWidth + rectGap) * groupItemIndex; + const x = xCenter - currentGroupWidth / 2 + (rectWidth + rectGap) * groupItemIndex; const yLinearScale = yScale as ScaleLinear; const y = yLinearScale(yValue.data.y as number); const height = yLinearScale(yLinearScale.domain()[0]) - y; @@ -179,100 +152,4 @@ function prepareData(args: { }); return result; -} - -export function BarXSeriesShapes(args: Args) { - const { - top, - left, - series, - seriesOptions, - xAxis, - xScale, - yAxis, - yScale, - onSeriesMouseMove, - onSeriesMouseLeave, - svgContainer, - } = args; - - const ref = React.useRef(null); - - React.useEffect(() => { - if (!ref.current) { - return; - } - - const svgElement = select(ref.current); - svgElement.selectAll('*').remove(); - - const shapes = prepareData({ - series, - seriesOptions, - xAxis, - xScale, - yAxis, - yScale, - }); - - svgElement - .selectAll('allRects') - .data(shapes) - .join('rect') - .attr('class', b('segment')) - .attr('x', (d) => d.x) - .attr('y', (d) => d.y) - .attr('height', (d) => d.height) - .attr('width', (d) => d.width) - .attr('fill', (d) => d.data.color || d.series.color) - .on('mousemove', (e, d) => { - const [x, y] = pointer(e, svgContainer); - onSeriesMouseMove?.({ - hovered: { - data: d.data, - series: d.series, - }, - pointerPosition: [x - left, y - top], - }); - }) - .on('mouseleave', () => { - if (onSeriesMouseLeave) { - onSeriesMouseLeave(); - } - }); - - const dataLabels = shapes.filter((s) => s.series.dataLabels.enabled); - - svgElement - .selectAll('allLabels') - .data(dataLabels) - .join('text') - .text((d) => String(d.data.label || d.data.y)) - .attr('class', b('label')) - .attr('x', (d) => d.x + d.width / 2) - .attr('y', (d) => { - if (d.series.dataLabels.inside) { - return d.y + d.height / 2; - } - - return d.y - DEFAULT_LABEL_PADDING; - }) - .attr('text-anchor', 'middle') - .style('font-size', (d) => d.series.dataLabels.style.fontSize) - .style('font-weight', (d) => d.series.dataLabels.style.fontWeight || null) - .style('fill', (d) => d.series.dataLabels.style.fontColor || null); - }, [ - onSeriesMouseMove, - onSeriesMouseLeave, - svgContainer, - xAxis, - xScale, - yAxis, - yScale, - series, - left, - top, - ]); - - return ; -} +}; diff --git a/src/plugins/d3/renderer/hooks/useShapes/defaults.ts b/src/plugins/d3/renderer/hooks/useShapes/defaults.ts deleted file mode 100644 index a6b8fd4c..00000000 --- a/src/plugins/d3/renderer/hooks/useShapes/defaults.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const DEFAULT_BAR_X_SERIES_OPTIONS = { - barMaxWidth: 50, - barPadding: 0.1, - groupPadding: 0.2, -}; diff --git a/src/plugins/d3/renderer/hooks/useShapes/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/index.tsx index adf732eb..704a2016 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -1,31 +1,39 @@ import React from 'react'; -import {group} from 'd3'; - -import type {ChartKitWidgetSeriesOptions, ScatterSeries} from '../../../../../types'; +import {Dispatch, group} from 'd3'; import {getOnlyVisibleSeries} from '../../utils'; import type {ChartOptions} from '../useChartOptions/types'; import type {ChartScale} from '../useAxisScales'; -import type {PreparedBarXSeries, PreparedPieSeries, PreparedSeries} from '../'; -import type {OnSeriesMouseMove, OnSeriesMouseLeave} from '../useTooltip/types'; -import {BarXSeriesShapes} from './bar-x'; -import {ScatterSeriesShape} from './scatter'; +import type { + PreparedBarXSeries, + PreparedPieSeries, + PreparedScatterSeries, + PreparedSeries, + PreparedSeriesOptions, +} from '../'; +import {BarXSeriesShapes, prepareBarXData} from './bar-x'; +import type {PreparedBarXData} from './bar-x'; +import {ScatterSeriesShape, prepareScatterData} from './scatter'; +import type {PreparedScatterData} from './scatter'; import {PieSeriesComponent} from './pie'; import './styles.scss'; +export type {PreparedBarXData} from './bar-x'; +export type {PreparedScatterData} from './scatter'; +export type ShapeData = PreparedBarXData | PreparedScatterData; + type Args = { top: number; left: number; boundsWidth: number; boundsHeight: number; + dispatcher: Dispatch; series: PreparedSeries[]; - seriesOptions?: ChartKitWidgetSeriesOptions; + seriesOptions: PreparedSeriesOptions; xAxis: ChartOptions['xAxis']; yAxis: ChartOptions['yAxis']; svgContainer: SVGSVGElement | null; - onSeriesMouseMove?: OnSeriesMouseMove; - onSeriesMouseLeave?: OnSeriesMouseLeave; xScale?: ChartScale; yScale?: ChartScale; }; @@ -36,6 +44,7 @@ export const useShapes = (args: Args) => { left, boundsWidth, boundsHeight, + dispatcher, series, seriesOptions, xAxis, @@ -43,58 +52,57 @@ export const useShapes = (args: Args) => { yAxis, yScale, svgContainer, - onSeriesMouseMove, - onSeriesMouseLeave, } = args; - const shapes = React.useMemo(() => { + const shapesComponents = React.useMemo(() => { const visibleSeries = getOnlyVisibleSeries(series); const groupedSeries = group(visibleSeries, (item) => item.type); - - return Array.from(groupedSeries).reduce((acc, item) => { + const shapesData: ShapeData[] = []; + const shapes = Array.from(groupedSeries).reduce((acc, item) => { const [seriesType, chartSeries] = item; switch (seriesType) { case 'bar-x': { if (xScale && yScale) { + const preparedData = prepareBarXData({ + series: chartSeries as PreparedBarXSeries[], + seriesOptions, + xAxis, + xScale, + yAxis, + yScale, + }); acc.push( , ); + shapesData.push(...preparedData); } break; } case 'scatter': { if (xScale && yScale) { - const scatterShapes = chartSeries.map((scatterSeries, i) => { - return ( - - ); + const preparedDatas = prepareScatterData({ + series: chartSeries as PreparedScatterSeries[], + xAxis, + xScale, + yAxis: yAxis[0], + yScale, }); - acc.push(...scatterShapes); + acc.push( + , + ); } break; } @@ -110,9 +118,11 @@ export const useShapes = (args: Args) => { key={`pie-${key}`} boundsWidth={boundsWidth} boundsHeight={boundsHeight} + dispatcher={dispatcher} + top={top} + left={left} series={pieSeries} - onSeriesMouseMove={onSeriesMouseMove} - onSeriesMouseLeave={onSeriesMouseLeave} + seriesOptions={seriesOptions} svgContainer={svgContainer} /> ); @@ -122,10 +132,14 @@ export const useShapes = (args: Args) => { } return acc; }, []); + + return {shapes, shapesData}; }, [ boundsWidth, boundsHeight, + dispatcher, series, + seriesOptions, xAxis, xScale, yAxis, @@ -133,9 +147,7 @@ export const useShapes = (args: Args) => { svgContainer, left, top, - onSeriesMouseMove, - onSeriesMouseLeave, ]); - return {shapes}; + return {shapes: shapesComponents.shapes, shapesData: shapesComponents.shapesData}; }; diff --git a/src/plugins/d3/renderer/hooks/useShapes/pie.tsx b/src/plugins/d3/renderer/hooks/useShapes/pie.tsx index d115829b..6d212801 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/pie.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/pie.tsx @@ -1,24 +1,55 @@ import React from 'react'; -import {arc, pie, select} from 'd3'; -import type {PieArcDatum} from 'd3'; +import get from 'lodash/get'; +import kebabCase from 'lodash/kebabCase'; +import {arc, color, pie, pointer, select} from 'd3'; +import type {BaseType, Dispatch, PieArcDatum, Selection} from 'd3'; -import type {PieSeries} from '../../../../../types/widget-data'; +import type {PieSeries, TooltipDataChunkPie} from '../../../../../types/widget-data'; import {block} from '../../../../../utils/cn'; -import {calculateNumericProperty, getHorisontalSvgTextHeight} from '../../utils'; -import type {OnSeriesMouseLeave, OnSeriesMouseMove} from '../useTooltip/types'; -import {PreparedPieSeries} from '../useSeries/types'; +import { + calculateNumericProperty, + extractD3DataFromNode, + getHorisontalSvgTextHeight, + isNodeContainsD3Data, +} from '../../utils'; +import type {NodeWithD3Data} from '../../utils'; +import {PreparedPieSeries, PreparedSeriesOptions} from '../useSeries/types'; + +const b = block('d3-pie'); type PreparePieSeriesArgs = { boundsWidth: number; boundsHeight: number; + dispatcher: Dispatch; + top: number; + left: number; series: PreparedPieSeries[]; + seriesOptions: PreparedSeriesOptions; svgContainer: SVGSVGElement | null; - onSeriesMouseMove?: OnSeriesMouseMove; - onSeriesMouseLeave?: OnSeriesMouseLeave; }; -const b = block('d3-pie'); +type PreparedPieData = Omit & { + series: PreparedPieSeries; +}; + +type PolylineSelection = Selection< + SVGPolylineElement, + PieArcDatum, + SVGGElement, + unknown +>; + +type LabelSelection = Selection< + BaseType | SVGTextElement, + PieArcDatum, + SVGGElement, + unknown +>; + +const preparePieData = (series: PreparedPieSeries[]): PreparedPieData[] => { + return series.map((s) => ({series: s, data: s.data})); +}; const getCenter = ( boundsWidth: number, @@ -39,18 +70,40 @@ const getCenter = ( return [resultX, resultY]; }; +const getOpacity = (args: { + data: PreparedPieData; + hoveredData?: PreparedPieData; + opacity?: number; +}) => { + const {data, hoveredData, opacity} = args; + + if (data.series.id !== hoveredData?.series.id) { + return opacity || null; + } + + return null; +}; + +const isNodeContainsPieData = ( + node?: Element, +): node is NodeWithD3Data> => { + return isNodeContainsD3Data(node); +}; + export function PieSeriesComponent(args: PreparePieSeriesArgs) { - const {boundsWidth, boundsHeight, series, onSeriesMouseMove, onSeriesMouseLeave, svgContainer} = + const {boundsWidth, boundsHeight, dispatcher, top, left, series, seriesOptions, svgContainer} = args; const ref = React.useRef(null); const [x, y] = getCenter(boundsWidth, boundsHeight, series[0]?.center); React.useEffect(() => { if (!ref.current) { - return; + return () => {}; } const svgElement = select(ref.current); + const hoverOptions = get(seriesOptions, 'pie.states.hover'); + const inactiveOptions = get(seriesOptions, 'pie.states.inactive'); const isLabelsEnabled = series[0]?.dataLabels?.enabled; let radiusRelatedToChart = Math.min(boundsWidth, boundsHeight) / 2; @@ -71,43 +124,49 @@ export function PieSeriesComponent(args: PreparePieSeriesArgs) { const innerRadius = calculateNumericProperty({value: series[0].innerRadius, base: radius}) ?? 0; - const pieGenerator = pie().value((d) => d.data); - const visibleData = series.filter((d) => d.visible); + const preparedData = preparePieData(series); + const pieGenerator = pie().value((d) => d.data.value); + const visibleData = preparedData.filter((d) => d.series.visible); const dataReady = pieGenerator(visibleData); - const arcGenerator = arc>() + const arcGenerator = arc>() .innerRadius(innerRadius) .outerRadius(radius) - .cornerRadius((d) => d.data.borderRadius); + .cornerRadius((d) => d.data.series.borderRadius); svgElement.selectAll('*').remove(); - svgElement - .selectAll('allSlices') + const segmentSelection = svgElement + .selectAll('segments') .data(dataReady) .enter() .append('path') .attr('d', arcGenerator) .attr('class', b('segment')) - .attr('fill', (d) => d.data.color || '') + .attr('fill', (d) => d.data.series.color) .style('stroke', series[0]?.borderColor || '') .style('stroke-width', series[0]?.borderWidth ?? 1); + let polylineSelection: PolylineSelection | undefined; + let labelSelection: LabelSelection | undefined; + if (series[0]?.dataLabels?.enabled) { const labelHeight = getHorisontalSvgTextHeight({text: 'tmp'}); - const outerArc = arc>() + const outerArc = arc>() .innerRadius(labelsArcRadius) .outerRadius(labelsArcRadius); + const polylineArc = arc>() + .innerRadius(radius) + .outerRadius(radius); // Add the polylines between chart and labels - svgElement - .selectAll('allPolylines') + polylineSelection = svgElement + .selectAll('polylines') .data(dataReady) .enter() .append('polyline') - .attr('stroke', (d) => d.data.color || '') - .style('fill', 'none') + .attr('stroke', (d) => d.data.series.color || '') .attr('stroke-width', 1) .attr('points', (d) => { // Line insertion in the slice - const posA = arcGenerator.centroid(d); + const posA = polylineArc.centroid(d); // Line break: we use the other arc generator that has been built only for that const posB = outerArc.centroid(d); const posC = outerArc.centroid(d); @@ -136,14 +195,16 @@ export function PieSeriesComponent(args: PreparePieSeriesArgs) { } return result.join(' '); - }); + }) + .attr('pointer-events', 'none') + .style('fill', 'none'); // Add the polylines between chart and labels - svgElement - .selectAll('allLabels') + labelSelection = svgElement + .selectAll('labels') .data(dataReady) .join('text') - .text((d) => d.data.label || d.value) + .text((d) => d.data.series.label || d.value) .attr('class', b('label')) .attr('transform', (d) => { const pos = outerArc.centroid(d); @@ -152,12 +213,79 @@ export function PieSeriesComponent(args: PreparePieSeriesArgs) { pos[1] += labelHeight / 4; return `translate(${pos})`; }) + .attr('pointer-events', 'none') .style('text-anchor', (d) => { const midangle = d.startAngle + (d.endAngle - d.startAngle) / 2; return midangle < Math.PI ? 'start' : 'end'; }); } - }, [boundsWidth, boundsHeight, series, onSeriesMouseMove, onSeriesMouseLeave, svgContainer]); + + svgElement + .on('mousemove', (e) => { + const segment = e.target; + + if (!isNodeContainsPieData(segment)) { + return; + } + + const [pointerX, pointerY] = pointer(e, svgContainer); + const segmentData = extractD3DataFromNode(segment).data; + dispatcher.call( + 'hover-shape', + {}, + [segmentData], + [pointerX - left, pointerY - top], + ); + }) + .on('mouseleave', () => { + dispatcher.call('hover-shape', {}, undefined); + }); + + const eventName = `hover-shape.pie-${kebabCase(preparedData[0].series.id)}`; + dispatcher.on(eventName, (datas?: PreparedPieData[]) => { + const data = datas?.[0]; + const hoverEnabled = hoverOptions?.enabled; + const inactiveEnabled = inactiveOptions?.enabled; + + if (hoverEnabled && data) { + segmentSelection.attr('fill', (d) => { + const fillColor = d.data.series.color; + + if (d.data.series.id === data.series.id) { + return ( + color(fillColor)?.brighter(hoverOptions?.brightness).toString() || + fillColor + ); + } + + return fillColor; + }); + } else if (hoverEnabled) { + segmentSelection.attr('fill', (d) => d.data.series.color); + } + + if (inactiveEnabled && data) { + const opacity = inactiveOptions?.opacity; + segmentSelection.attr('opacity', (d) => { + return getOpacity({data: d.data, hoveredData: data, opacity}); + }); + polylineSelection?.attr('opacity', (d) => { + return getOpacity({data: d.data, hoveredData: data, opacity}); + }); + labelSelection?.attr('opacity', (d) => { + return getOpacity({data: d.data, hoveredData: data, opacity}); + }); + } else if (inactiveEnabled) { + segmentSelection.attr('opacity', null); + polylineSelection?.attr('opacity', null); + labelSelection?.attr('opacity', null); + } + }); + + return () => { + dispatcher.on(eventName, null); + }; + }, [boundsWidth, boundsHeight, dispatcher, top, left, series, seriesOptions, svgContainer]); return ; } diff --git a/src/plugins/d3/renderer/hooks/useShapes/scatter.tsx b/src/plugins/d3/renderer/hooks/useShapes/scatter.tsx deleted file mode 100644 index 54bbc17a..00000000 --- a/src/plugins/d3/renderer/hooks/useShapes/scatter.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React from 'react'; -import {pointer, select} from 'd3'; -import type {ScaleBand, ScaleLinear, ScaleTime} from 'd3'; -import get from 'lodash/get'; - -import type {ScatterSeries, ScatterSeriesData} from '../../../../../types/widget-data'; -import {block} from '../../../../../utils/cn'; - -import {getDataCategoryValue} from '../../utils'; -import type {ChartScale} from '../useAxisScales'; -import type {PreparedAxis} from '../useChartOptions/types'; -import type {OnSeriesMouseLeave, OnSeriesMouseMove} from '../useTooltip/types'; - -type ScatterSeriesShapeProps = { - top: number; - left: number; - series: ScatterSeries; - xAxis: PreparedAxis; - xScale: ChartScale; - yAxis: PreparedAxis[]; - yScale: ChartScale; - svgContainer: SVGSVGElement | null; - onSeriesMouseMove?: OnSeriesMouseMove; - onSeriesMouseLeave?: OnSeriesMouseLeave; -}; - -const b = block('d3-scatter'); -const DEFAULT_SCATTER_POINT_RADIUS = 4; - -const prepareLinearScatterData = (data: ScatterSeriesData[]) => { - return data.filter((d) => typeof d.x === 'number' && typeof d.y === 'number'); -}; - -const getCxAttr = (args: {point: ScatterSeriesData; xAxis: PreparedAxis; xScale: ChartScale}) => { - const {point, xAxis, xScale} = args; - - let cx: number; - - if (xAxis.type === 'category') { - const xBandScale = xScale as ScaleBand; - const categories = get(xAxis, 'categories', [] as string[]); - const dataCategory = getDataCategoryValue({axisDirection: 'x', categories, data: point}); - cx = (xBandScale(dataCategory) || 0) + xBandScale.step() / 2; - } else { - const xLinearScale = xScale as ScaleLinear | ScaleTime; - cx = xLinearScale(point.x as number); - } - - return cx; -}; - -const getCyAttr = (args: {point: ScatterSeriesData; yAxis: PreparedAxis; yScale: ChartScale}) => { - const {point, yAxis, yScale} = args; - - let cy: number; - - if (yAxis.type === 'category') { - const yBandScale = yScale as ScaleBand; - const categories = get(yAxis, 'categories', [] as string[]); - const dataCategory = getDataCategoryValue({axisDirection: 'y', categories, data: point}); - cy = (yBandScale(dataCategory) || 0) + yBandScale.step() / 2; - } else { - const yLinearScale = yScale as ScaleLinear | ScaleTime; - cy = yLinearScale(point.y as number); - } - - return cy; -}; - -export function ScatterSeriesShape(props: ScatterSeriesShapeProps) { - const { - series, - xAxis, - xScale, - yAxis, - yScale, - svgContainer, - left, - top, - onSeriesMouseMove, - onSeriesMouseLeave, - } = props; - const ref = React.useRef(null); - - React.useEffect(() => { - if (!ref.current) { - return; - } - - const svgElement = select(ref.current); - const preparedData = - xAxis.type === 'category' || yAxis[0]?.type === 'category' - ? series.data - : prepareLinearScatterData(series.data); - - svgElement - .selectAll('circle') - .data(preparedData) - .join( - (enter) => enter.append('circle').attr('class', b('point')), - (update) => update, - (exit) => exit.remove(), - ) - .attr('fill', (d) => d.color || series.color || '') - .attr('r', (d) => d.radius || DEFAULT_SCATTER_POINT_RADIUS) - .attr('cx', (d) => getCxAttr({point: d, xAxis, xScale})) - .attr('cy', (d) => getCyAttr({point: d, yAxis: yAxis[0], yScale})) - .on('mousemove', (e, d) => { - const [x, y] = pointer(e, svgContainer); - onSeriesMouseMove?.({ - hovered: { - data: d, - series, - }, - pointerPosition: [x - left, y - top], - }); - }) - .on('mouseleave', () => { - if (onSeriesMouseLeave) { - onSeriesMouseLeave(); - } - }); - }, [ - series, - xAxis, - xScale, - yAxis, - yScale, - svgContainer, - left, - top, - onSeriesMouseMove, - onSeriesMouseLeave, - ]); - - return ; -} diff --git a/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx new file mode 100644 index 00000000..c37d37b5 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import get from 'lodash/get'; +import {color, pointer, select} from 'd3'; +import type {BaseType, Dispatch, Selection} from 'd3'; + +import {block} from '../../../../../../utils/cn'; + +import {extractD3DataFromNode, isNodeContainsD3Data} from '../../../utils'; +import type {NodeWithD3Data} from '../../../utils'; +import {PreparedSeriesOptions} from '../../useSeries/types'; +import type {PreparedScatterData} from './prepare-data'; + +export {prepareScatterData} from './prepare-data'; +export type {PreparedScatterData} from './prepare-data'; + +type ScatterSeriesShapeProps = { + dispatcher: Dispatch; + top: number; + left: number; + preparedDatas: PreparedScatterData[][]; + seriesOptions: PreparedSeriesOptions; + svgContainer: SVGSVGElement | null; +}; + +type SeriesState = Record< + string, + { + selection: Selection; + hovered: boolean; + inactive: boolean; + } +>; + +type ChartState = { + hoveredSelections: Selection[]; + seriesState: SeriesState; +}; + +const b = block('d3-scatter'); +const DEFAULT_SCATTER_POINT_RADIUS = 4; + +const isNodeContainsScatterData = (node?: Element): node is NodeWithD3Data => { + return isNodeContainsD3Data(node); +}; + +export function ScatterSeriesShape(props: ScatterSeriesShapeProps) { + const {dispatcher, top, left, preparedDatas, seriesOptions, svgContainer} = props; + const ref = React.useRef(null); + const stateRef = React.useRef({hoveredSelections: [], seriesState: {}}); + + React.useEffect(() => { + if (!ref.current) { + return () => {}; + } + + const svgElement = select(ref.current); + const hoverOptions = get(seriesOptions, 'scatter.states.hover'); + const inactiveOptions = get(seriesOptions, 'scatter.states.inactive'); + svgElement.selectAll('*').remove(); + preparedDatas.forEach((preparedData, i) => { + const selection = svgElement + .selectAll(`points-${i}`) + .data(preparedData) + .join( + (enter) => enter.append('circle').attr('class', b('point')), + (update) => update, + (exit) => exit.remove(), + ) + .attr('fill', (d) => d.data.color || d.series.color || '') + .attr('r', (d) => d.data.radius || DEFAULT_SCATTER_POINT_RADIUS) + .attr('cx', (d) => d.cx) + .attr('cy', (d) => d.cy); + + stateRef.current.seriesState[preparedData[0].series.id] = { + selection, + hovered: false, + inactive: false, + }; + }); + + svgElement + .on('mousemove', (e) => { + const point = e.target; + + if (!isNodeContainsScatterData(point)) { + return; + } + + const [pointerX, pointerY] = pointer(e, svgContainer); + const segmentData = extractD3DataFromNode(point); + dispatcher.call( + 'hover-shape', + {}, + [segmentData], + [pointerX - left, pointerY - top], + ); + }) + .on('mouseleave', () => { + dispatcher.call('hover-shape', {}, undefined); + }); + + dispatcher.on('hover-shape.scatter', (data?: PreparedScatterData[]) => { + const hoverEnabled = hoverOptions?.enabled; + const inactiveEnabled = inactiveOptions?.enabled; + + if (hoverEnabled) { + stateRef.current.hoveredSelections.forEach((selection) => { + selection.attr('fill', (d) => d.series.color); + }); + } + + if (data?.[0]) { + const className = b('point'); + const points = svgElement.selectAll( + `.${className}[cx="${data[0].cx}"][cy="${data[0].cy}"]`, + ); + + if (hoverEnabled) { + points.attr('fill', (d) => { + const fillColor = d.series.color; + return ( + color(fillColor)?.brighter(hoverOptions?.brightness).toString() || + fillColor + ); + }); + } + + stateRef.current.hoveredSelections = [points]; + + // Hovered and inactive styles uses only in case of multiple series + if (Object.keys(stateRef.current.seriesState).length > 1) { + const hoveredSeriesName = data[0].series.id; + + Object.entries(stateRef.current.seriesState).forEach(([name, state]) => { + if (hoveredSeriesName === name && !state.hovered) { + stateRef.current.seriesState[name].hovered = true; + stateRef.current.seriesState[name].inactive = false; + + if (inactiveEnabled) { + state.selection.attr('opacity', null); + } + } else if (hoveredSeriesName !== name) { + stateRef.current.seriesState[name].hovered = false; + stateRef.current.seriesState[name].inactive = true; + + if (inactiveEnabled) { + state.selection.attr('opacity', inactiveOptions.opacity || null); + } + } + }); + } + } else if (!data) { + Object.entries(stateRef.current.seriesState).forEach(([name, state]) => { + if (inactiveEnabled) { + state.selection.attr('opacity', 1); + } + + stateRef.current.seriesState[name].hovered = false; + stateRef.current.seriesState[name].inactive = false; + }); + } + }); + + return () => { + dispatcher.on('hover-shape.scatter', null); + }; + }, [dispatcher, top, left, preparedDatas, seriesOptions, svgContainer]); + + return ; +} diff --git a/src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts b/src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts new file mode 100644 index 00000000..e6b4d990 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts @@ -0,0 +1,84 @@ +import type {ScaleBand, ScaleLinear, ScaleTime} from 'd3'; +import get from 'lodash/get'; + +import type {TooltipDataChunkScatter, ScatterSeriesData} from '../../../../../../types/widget-data'; + +import {getDataCategoryValue} from '../../../utils'; +import type {ChartScale} from '../../useAxisScales'; +import type {PreparedAxis} from '../../useChartOptions/types'; +import {PreparedScatterSeries} from '../../useSeries/types'; + +export type PreparedScatterData = Omit & { + cx: number; + cy: number; + series: PreparedScatterSeries; +}; + +const getCxAttr = (args: {point: ScatterSeriesData; xAxis: PreparedAxis; xScale: ChartScale}) => { + const {point, xAxis, xScale} = args; + + let cx: number; + + if (xAxis.type === 'category') { + const xBandScale = xScale as ScaleBand; + const categories = get(xAxis, 'categories', [] as string[]); + const dataCategory = getDataCategoryValue({axisDirection: 'x', categories, data: point}); + cx = (xBandScale(dataCategory) || 0) + xBandScale.step() / 2; + } else { + const xLinearScale = xScale as ScaleLinear | ScaleTime; + cx = xLinearScale(point.x as number); + } + + return cx; +}; + +const getCyAttr = (args: {point: ScatterSeriesData; yAxis: PreparedAxis; yScale: ChartScale}) => { + const {point, yAxis, yScale} = args; + + let cy: number; + + if (yAxis.type === 'category') { + const yBandScale = yScale as ScaleBand; + const categories = get(yAxis, 'categories', [] as string[]); + const dataCategory = getDataCategoryValue({axisDirection: 'y', categories, data: point}); + cy = (yBandScale(dataCategory) || 0) + yBandScale.step() / 2; + } else { + const yLinearScale = yScale as ScaleLinear | ScaleTime; + cy = yLinearScale(point.y as number); + } + + return cy; +}; + +const getFilteredLinearScatterData = (data: ScatterSeriesData[]) => { + return data.filter((d) => typeof d.x === 'number' && typeof d.y === 'number'); +}; + +export const prepareScatterData = (args: { + series: PreparedScatterSeries[]; + xAxis: PreparedAxis; + xScale: ChartScale; + yAxis: PreparedAxis; + yScale: ChartScale; +}): PreparedScatterData[][] => { + const {series, xAxis, xScale, yAxis, yScale} = args; + + return series.reduce((acc, s) => { + const filteredData = + xAxis.type === 'category' || yAxis.type === 'category' + ? s.data + : getFilteredLinearScatterData(s.data); + const preparedData: PreparedScatterData[] = filteredData.map((d) => { + return { + data: d, + series: s, + cx: getCxAttr({point: d, xAxis, xScale}), + cy: getCyAttr({point: d, yAxis, yScale}), + }; + }); + + acc.push(preparedData); + + return acc; + }, []); +}; diff --git a/src/plugins/d3/renderer/hooks/useShapes/styles.scss b/src/plugins/d3/renderer/hooks/useShapes/styles.scss index 9da1ec72..783534bc 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/styles.scss +++ b/src/plugins/d3/renderer/hooks/useShapes/styles.scss @@ -1,15 +1,6 @@ .chartkit-d3-scatter { &__point { stroke-width: 1px; - - .chartkit-d3_hovered & { - opacity: 0.5; - } - - &:hover { - stroke: #fff; - opacity: 1; - } } } @@ -28,5 +19,6 @@ .chartkit-d3-bar-x { &__label { fill: var(--g-color-text-complementary); + user-select: none; } } diff --git a/src/plugins/d3/renderer/hooks/useTooltip/index.ts b/src/plugins/d3/renderer/hooks/useTooltip/index.ts index cb6f499d..4b00440e 100644 --- a/src/plugins/d3/renderer/hooks/useTooltip/index.ts +++ b/src/plugins/d3/renderer/hooks/useTooltip/index.ts @@ -1,35 +1,40 @@ import React from 'react'; +import type {Dispatch} from 'd3'; -import type {TooltipHoveredData} from '../../../../../types/widget-data'; +import type {TooltipDataChunk} from '../../../../../types/widget-data'; import {PreparedTooltip} from '../useChartOptions/types'; -import type {PointerPosition, OnSeriesMouseMove, OnSeriesMouseLeave} from './types'; +import type {PointerPosition} from './types'; type Args = { + dispatcher: Dispatch; tooltip: PreparedTooltip; }; -export const useTooltip = ({tooltip}: Args) => { - const [hovered, setTooltipHoveredData] = React.useState(); - const [pointerPosition, setPointerPosition] = React.useState(); - - const handleSeriesMouseMove = React.useCallback( - ({pointerPosition: nextPointerPosition, hovered: nextHovered}) => { - setTooltipHoveredData(nextHovered); - setPointerPosition(nextPointerPosition); - }, - [], - ); - - const handleSeriesMouseLeave = React.useCallback(() => { - setTooltipHoveredData(undefined); - setPointerPosition(undefined); - }, []); - - return { - hovered, - pointerPosition, - handleSeriesMouseMove: tooltip.enabled ? handleSeriesMouseMove : undefined, - handleSeriesMouseLeave: tooltip.enabled ? handleSeriesMouseLeave : undefined, - }; +type TooltipState = { + hovered?: TooltipDataChunk[]; + pointerPosition?: PointerPosition; +}; + +export const useTooltip = ({dispatcher, tooltip}: Args) => { + const [{hovered, pointerPosition}, setTooltipState] = React.useState({}); + + React.useEffect(() => { + if (tooltip?.enabled) { + dispatcher.on( + 'hover-shape.tooltip', + (nextHovered?: TooltipDataChunk[], nextPointerPosition?: PointerPosition) => { + setTooltipState({hovered: nextHovered, pointerPosition: nextPointerPosition}); + }, + ); + } + + return () => { + if (tooltip?.enabled) { + dispatcher.on('hover-shape.tooltip', null); + } + }; + }, [dispatcher, tooltip]); + + return {hovered, pointerPosition}; }; diff --git a/src/plugins/d3/renderer/hooks/useTooltip/types.ts b/src/plugins/d3/renderer/hooks/useTooltip/types.ts index b02d8740..ae2a5065 100644 --- a/src/plugins/d3/renderer/hooks/useTooltip/types.ts +++ b/src/plugins/d3/renderer/hooks/useTooltip/types.ts @@ -1,10 +1 @@ -import type {TooltipHoveredData} from '../../../../../types/widget-data'; - export type PointerPosition = [number, number]; - -export type OnSeriesMouseMove = (args: { - hovered: TooltipHoveredData; - pointerPosition?: PointerPosition; -}) => void; - -export type OnSeriesMouseLeave = () => void; diff --git a/src/plugins/d3/renderer/utils/index.ts b/src/plugins/d3/renderer/utils/index.ts index c65ab052..94968f39 100644 --- a/src/plugins/d3/renderer/utils/index.ts +++ b/src/plugins/d3/renderer/utils/index.ts @@ -24,6 +24,8 @@ const CHARTS_WITHOUT_AXIS: ChartKitWidgetSeries['type'][] = ['pie']; export type AxisDirection = 'x' | 'y'; +export type NodeWithD3Data = Element & {__data__: T}; + type UnknownSeries = {type: ChartKitWidgetSeries['type']; data: unknown}; /** @@ -241,3 +243,12 @@ export function getClosestPointsRange(axis: PreparedAxis, points: AxisDomain[]) return (points[1] as number) - (points[0] as number); } + +// https://d3js.org/d3-selection/joining#selection_data +export const isNodeContainsD3Data = (node?: Element | null): node is NodeWithD3Data => { + return Boolean(node && '__data__' in node); +}; + +export const extractD3DataFromNode = (node: NodeWithD3Data) => { + return node.__data__; +}; diff --git a/src/types/widget-data/series.ts b/src/types/widget-data/series.ts index 31d35caf..006b01aa 100644 --- a/src/types/widget-data/series.ts +++ b/src/types/widget-data/series.ts @@ -14,17 +14,48 @@ export type DataLabelRendererData = { data: ChartKitWidgetSeriesData; }; +type BasicHoverState = { + /** + * Enable separate styles for the hovered series. + * + * @default true + * */ + enabled?: boolean; + /** + * How much to brighten/darken the point on hover. Use positive to brighten, negative to darken. + * The behavior of this property is dependent on the implementing color space ([more details](https://d3js.org/d3-color#color_brighter)). + * For example in case of using rgb color you can use floating point number from `-5.0` to `5.0`. + * Rgb color space is used by default. + * + * @default 0.3 + */ + brightness?: number; +}; + +type BasicInactiveState = { + /** + * Enable separate styles for the inactive series. + * + * @default true + * */ + enabled?: boolean; + /** + * Opacity of series elements (bars, data labels) + * + * @default 0.5 + * */ + opacity?: number; +}; + export type ChartKitWidgetSeriesOptions = { // todo /** Individual data label for each point. */ dataLabels?: { /** Enable or disable the data labels */ enabled?: boolean; - /** Callback function to render the data label */ renderer?: (args: DataLabelRendererData) => React.SVGTextElementAttributes; }; - 'bar-x'?: { /** The maximum allowed pixel width for a column. * This prevents the columns from becoming too wide when there is a small number of points in the chart. @@ -32,19 +63,16 @@ export type ChartKitWidgetSeriesOptions = { * @default 50 */ barMaxWidth?: number; - /** Padding between each column or bar, in x axis units. * * @default 0.1 * */ barPadding?: number; - /** Padding between each value groups, in x axis units * * @default 0.2 */ groupPadding?: number; - dataSorting?: { /** Determines what data value should be used to sort by. * Possible values are undefined to disable, "name" to sort by series name or "y" @@ -52,12 +80,30 @@ export type ChartKitWidgetSeriesOptions = { * @default undefined * */ key?: 'name' | 'y' | undefined; - /** Sorting direction. * * @default 'asc' * */ direction?: 'asc' | 'desc'; }; + /** Options for the series states that provide additional styling information to the series. */ + states?: { + hover?: BasicHoverState; + inactive?: BasicInactiveState; + }; + }; + pie?: { + /** Options for the series states that provide additional styling information to the series. */ + states?: { + hover?: BasicHoverState; + inactive?: BasicInactiveState; + }; + }; + scatter?: { + /** Options for the series states that provide additional styling information to the series. */ + states?: { + hover?: BasicHoverState; + inactive?: BasicInactiveState; + }; }; }; diff --git a/src/types/widget-data/tooltip.ts b/src/types/widget-data/tooltip.ts index f056a694..f430023e 100644 --- a/src/types/widget-data/tooltip.ts +++ b/src/types/widget-data/tooltip.ts @@ -1,12 +1,29 @@ -import type {ChartKitWidgetSeries, ChartKitWidgetSeriesData} from './series'; +import type {BarXSeries, BarXSeriesData} from './bar-x'; +import type {PieSeries, PieSeriesData} from './pie'; +import type {ScatterSeries, ScatterSeriesData} from './scatter'; -export type TooltipHoveredData = { - data: ChartKitWidgetSeriesData; - series: ChartKitWidgetSeries; +export type TooltipDataChunkBarX = { + data: BarXSeriesData; + series: BarXSeries; }; +export type TooltipDataChunkPie = { + data: PieSeriesData; + series: Omit, 'data'>; +}; + +export type TooltipDataChunkScatter = { + data: ScatterSeriesData; + series: ScatterSeries; +}; + +export type TooltipDataChunk = + | TooltipDataChunkBarX + | TooltipDataChunkPie + | TooltipDataChunkScatter; + export type ChartKitWidgetTooltip = { enabled?: boolean; /** Specifies the renderer for the tooltip. If returned null default tooltip renderer will be used. */ - renderer?: (data: {hovered: TooltipHoveredData}) => React.ReactElement; + renderer?: (args: {hovered: TooltipDataChunk[]}) => React.ReactElement | null; };