diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index 03a570f8..2f434d09 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -1,30 +1,29 @@ -import React from 'react'; +import React, {MouseEventHandler} from 'react'; + +import {pointer} from 'd3'; +import throttle from 'lodash/throttle'; import type {ChartKitWidgetData} from '../../../../types'; import {block} from '../../../../utils/cn'; import {getD3Dispatcher} from '../d3-dispatcher'; -import { - useAxisScales, - useChartDimensions, - useChartOptions, - useSeries, - useShapes, - useTooltip, -} from '../hooks'; +import {useAxisScales, useChartDimensions, useChartOptions, useSeries, useShapes} from '../hooks'; import {getWidthOccupiedByYAxis} from '../hooks/useChartDimensions/utils'; import {getPreparedXAxis} from '../hooks/useChartOptions/x-axis'; import {getPreparedYAxis} from '../hooks/useChartOptions/y-axis'; +import {getClosestPoints} from '../utils/get-closest-data'; import {AxisX} from './AxisX'; import {AxisY} from './AxisY'; import {Legend} from './Legend'; import {Title} from './Title'; -import {Tooltip, TooltipTriggerArea} from './Tooltip'; +import {Tooltip} from './Tooltip'; import './styles.scss'; const b = block('d3'); +const THROTTLE_DELAY = 50; + type Props = { width: number; height: number; @@ -80,7 +79,6 @@ export const Chart = (props: Props) => { xAxis, yAxis, }); - const {hovered, pointerPosition} = useTooltip({dispatcher, tooltip}); const {shapes, shapesData} = useShapes({ boundsWidth, boundsHeight, @@ -91,7 +89,6 @@ export const Chart = (props: Props) => { xScale, yAxis, yScale, - svgContainer: svgRef.current, }); const clickHandler = data.chart?.events?.click; @@ -108,9 +105,38 @@ export const Chart = (props: Props) => { const boundsOffsetTop = chart.margin.top; const boundsOffsetLeft = chart.margin.left + getWidthOccupiedByYAxis({preparedAxis: yAxis}); + const handleMouseMove: MouseEventHandler = (event) => { + const [pointerX, pointerY] = pointer(event, svgRef.current); + const x = pointerX - boundsOffsetLeft; + const y = pointerY - boundsOffsetTop; + if (x < 0 || x > boundsWidth || y < 0 || y > boundsHeight) { + dispatcher.call('hover-shape', {}, undefined); + return; + } + + const closest = getClosestPoints({ + position: [x, y], + shapesData, + }); + dispatcher.call('hover-shape', event.target, closest, [pointerX, pointerY]); + }; + const throttledHandleMouseMove = throttle(handleMouseMove, THROTTLE_DELAY); + + const handleMouseLeave = () => { + throttledHandleMouseMove.cancel(); + dispatcher.call('hover-shape', {}, undefined); + }; + return ( - + {title && } { )} {shapes} - {tooltip?.enabled && Boolean(shapesData.length) && ( - - )} {preparedLegend.enabled && ( { svgContainer={svgRef.current} xAxis={xAxis} yAxis={yAxis[0]} - hovered={hovered} - pointerPosition={pointerPosition} /> ); diff --git a/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx b/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx index 81bfc800..cbd14c49 100644 --- a/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx +++ b/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx @@ -8,10 +8,13 @@ import type { TooltipDataChunk, TreemapSeriesData, } from '../../../../../types'; +import {block} from '../../../../../utils/cn'; import {formatNumber} from '../../../../shared'; import type {PreparedAxis, PreparedPieSeries} from '../../hooks'; import {getDataCategoryValue} from '../../utils'; +const b = block('d3-tooltip'); + type Props = { hovered: TooltipDataChunk[]; xAxis: PreparedAxis; @@ -47,54 +50,67 @@ const getXRowData = (xAxis: PreparedAxis, data: ChartKitWidgetSeriesData) => const getYRowData = (yAxis: PreparedAxis, data: ChartKitWidgetSeriesData) => getRowData('y', yAxis, data); +const getMeasureValue = (data: TooltipDataChunk[], xAxis: PreparedAxis, yAxis: PreparedAxis) => { + if (data.every((item) => item.series.type === 'pie' || item.series.type === 'treemap')) { + return null; + } + + if (data.some((item) => item.series.type === 'bar-y')) { + return getYRowData(yAxis, data[0]?.data); + } + + return getXRowData(xAxis, data[0]?.data); +}; + export const DefaultContent = ({hovered, xAxis, yAxis}: Props) => { + const measureValue = getMeasureValue(hovered, xAxis, yAxis); + return ( <> - {hovered.map(({data, series}, i) => { - const id = get(series, 'id', i); + {measureValue &&
{measureValue}
} + {hovered.map(({data, series, closest}, i) => { + const id = `${get(series, 'id')}_${i}`; + const color = get(series, 'color'); switch (series.type) { case 'scatter': case 'line': case 'area': case 'bar-x': { - const xRow = getXRowData(xAxis, data); - const yRow = getYRowData(yAxis, data); - + const value = ( + + {series.name}: {getYRowData(yAxis, data)} + + ); return ( -
-
{xRow}
-
- - {series.name}: {yRow} - -
+
+
+
{closest ? {value} : {value}}
); } case 'bar-y': { - const xRow = getXRowData(xAxis, data); - const yRow = getYRowData(yAxis, data); - + const value = ( + + {series.name}: {getXRowData(xAxis, data)} + + ); return ( -
-
{yRow}
-
- - {series.name}: {xRow} - -
+
+
+
{closest ? {value} : {value}}
); } case 'pie': case 'treemap': { - const pieSeriesData = data as PreparedPieSeries | TreemapSeriesData; + const seriesData = data as PreparedPieSeries | TreemapSeriesData; return ( -
- {pieSeriesData.name || pieSeriesData.id}  - {pieSeriesData.value} +
+
+ {seriesData.name || seriesData.id}  + {seriesData.value}
); } diff --git a/src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx b/src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx deleted file mode 100644 index ec7633b9..00000000 --- a/src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import React from 'react'; - -import {bisector, group, pointer, sort} from 'd3'; -import type {Dispatch} from 'd3'; -import get from 'lodash/get'; -import throttle from 'lodash/throttle'; - -import {BarYSeriesData, LineSeriesData} from '../../../../../types'; -import type {PointerPosition, PreparedBarXData, PreparedSeries, ShapeData} from '../../hooks'; -import {PreparedBarYData} from '../../hooks/useShapes/bar-y/types'; -import {PreparedLineData} from '../../hooks/useShapes/line/types'; -import {extractD3DataFromNode, isNodeContainsD3Data} from '../../utils'; -import type {NodeWithD3Data} from '../../utils'; - -const THROTTLE_DELAY = 50; - -type Args = { - boundsWidth: number; - boundsHeight: number; - dispatcher: Dispatch; - shapesData: ShapeData[]; - svgContainer: SVGSVGElement | null; -}; - -const isNodeContainsData = (node?: Element): node is NodeWithD3Data => { - return isNodeContainsD3Data(node); -}; - -function getBarXShapeData(args: { - shapesData: ShapeData[]; - point: number[]; - top: number; - left: number; - xData: {x: number; data: ShapeData}[]; - container?: HTMLElement | null; -}) { - const { - shapesData, - point: [pointerX, pointerY], - top, - left, - xData, - container, - } = args; - const barWidthOffset = (shapesData[0] as PreparedBarXData).width / 2; - const xPosition = pointerX - left - barWidthOffset; - const xDataIndex = bisector((d: {x: number; data: ShapeData}) => d.x).center(xData, xPosition); - const xNodes = Array.from(container?.querySelectorAll(`[x="${xData[xDataIndex]?.x}"]`) || []); - - if (xNodes.length === 1 && isNodeContainsData(xNodes[0])) { - return [extractD3DataFromNode(xNodes[0])]; - } - - if (xNodes.length > 1 && xNodes.every(isNodeContainsData)) { - const yPosition = pointerY - top; - 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) { - return [extractD3DataFromNode(xyNode)]; - } - } - - return []; -} - -type XLineData = {x: number; data: LineSeriesData; series: PreparedSeries}; - -function getLineShapesData(args: {xData: XLineData[]; point: number[]}) { - const { - xData, - point: [pointerX], - } = args; - const xDataIndex = bisector((d: {x: number}) => d.x).center(xData, pointerX); - const selectedLineShape = xData[xDataIndex]; - - if (selectedLineShape) { - return [ - { - series: selectedLineShape.series, - data: selectedLineShape.data, - }, - ]; - } - - return []; -} - -type BarYData = { - y: number; - items: {x: number; data: BarYSeriesData; series: PreparedSeries}[]; -}; - -function getBarYData(args: {data: BarYData[]; point: number[]}) { - const { - data, - point: [pointerX, pointerY], - } = args; - const yDataIndex = bisector((d: {y: number}) => d.y).center(data, pointerY); - const shapesByY = data[yDataIndex]?.items || []; - const xDataIndex = bisector((d: {x: number}) => d.x).left(shapesByY, pointerX); - const result = shapesByY[Math.min(xDataIndex, shapesByY.length - 1)]; - - return result - ? [ - { - series: result.series, - data: result.data, - }, - ] - : []; -} - -export const TooltipTriggerArea = (args: Args) => { - const {boundsWidth, boundsHeight, dispatcher, shapesData, svgContainer} = args; - const rectRef = React.useRef(null); - const xBarData = React.useMemo(() => { - const result = shapesData - .filter((sd) => get(sd, 'series.type') === 'bar-x') - .map((sd) => ({x: (sd as PreparedBarXData).x, data: sd})); - - return sort(result, (item) => item.x); - }, [shapesData]); - - const xLineData = React.useMemo(() => { - const result = shapesData - .filter((sd) => ['line', 'area'].includes((sd as PreparedLineData).series.type)) - .reduce((acc, sd) => { - return acc.concat( - (sd as PreparedLineData).points.map((d) => ({ - x: d.x, - data: d.data, - series: d.series, - })), - ); - }, [] as XLineData[]); - - return sort(result, (item) => item.x); - }, [shapesData]); - - const barYData = React.useMemo(() => { - const barYShapeData = shapesData.filter((sd) => get(sd, 'series.type') === 'bar-y'); - const result = Array.from(group(barYShapeData, (sd) => (sd as PreparedBarYData).y)).map( - ([y, shapes]) => { - const yValue = y + (shapes[0] as PreparedBarYData).height / 2; - - return { - y: yValue, - items: sort( - shapes.map((shape) => { - const preparedData = shape as PreparedBarYData; - - return { - x: preparedData.x + preparedData.width, - data: preparedData.data, - series: preparedData.series, - }; - }), - (item) => item.x, - ), - }; - }, - ); - - return sort(result, (item) => item.y); - }, [shapesData]); - - const getShapeData = (point: [number, number]) => { - const {left: ownLeft, top: ownTop} = rectRef.current?.getBoundingClientRect() || { - left: 0, - top: 0, - }; - const {left: containerLeft, top: containerTop} = svgContainer?.getBoundingClientRect() || { - left: 0, - top: 0, - }; - const [pointerX, pointerY] = point; //pointer(e, svgContainer); - const result = []; - - result?.push( - ...getBarXShapeData({ - shapesData, - point: [pointerX, pointerY], - left: ownLeft - containerLeft, - top: ownTop - containerTop, - xData: xBarData, - container: rectRef.current?.parentElement, - }), - ...getLineShapesData({ - xData: xLineData, - point: [pointerX - (ownLeft - containerLeft), pointerY - (ownTop - containerTop)], - }), - ...getBarYData({ - data: barYData, - point: [pointerX - (ownLeft - containerLeft), pointerY - (ownTop - containerTop)], - }), - ); - - return result; - }; - - const handleMouseMove: React.MouseEventHandler = (e) => { - const [pointerX, pointerY] = pointer(e, svgContainer); - const hoverShapeData = getShapeData([pointerX, pointerY]); - - if (hoverShapeData.length) { - const position: PointerPosition = [pointerX, pointerY]; - dispatcher.call('hover-shape', e.target, hoverShapeData, position); - } - }; - - const throttledHandleMouseMove = throttle(handleMouseMove, THROTTLE_DELAY); - - const handleMouseLeave = () => { - throttledHandleMouseMove.cancel(); - dispatcher.call('hover-shape', {}, undefined); - }; - - const handleClick: React.MouseEventHandler = (e) => { - const [pointerX, pointerY] = pointer(e, svgContainer); - const shapeData = getShapeData([pointerX, pointerY]); - - if (shapeData.length) { - dispatcher.call( - 'click-chart', - undefined, - {point: get(shapeData, '[0].data'), series: get(shapeData, '[0].series')}, - e, - ); - } - }; - - return ( - - ); -}; diff --git a/src/plugins/d3/renderer/components/Tooltip/index.tsx b/src/plugins/d3/renderer/components/Tooltip/index.tsx index af10a2c1..8bb77b3c 100644 --- a/src/plugins/d3/renderer/components/Tooltip/index.tsx +++ b/src/plugins/d3/renderer/components/Tooltip/index.tsx @@ -4,14 +4,12 @@ import {Popup, useVirtualElementRef} from '@gravity-ui/uikit'; import type {Dispatch} from 'd3'; import isNil from 'lodash/isNil'; -import type {TooltipDataChunk} from '../../../../../types/widget-data'; import {block} from '../../../../../utils/cn'; -import type {PointerPosition, PreparedAxis, PreparedTooltip} from '../../hooks'; +import type {PreparedAxis, PreparedTooltip} from '../../hooks'; +import {useTooltip} from '../../hooks'; import {DefaultContent} from './DefaultContent'; -export * from './TooltipTriggerArea'; - const b = block('d3-tooltip'); type TooltipProps = { @@ -20,12 +18,11 @@ type TooltipProps = { svgContainer: SVGSVGElement | null; xAxis: PreparedAxis; yAxis: PreparedAxis; - hovered?: TooltipDataChunk[]; - pointerPosition?: PointerPosition; }; export const Tooltip = (props: TooltipProps) => { - const {tooltip, xAxis, yAxis, hovered, svgContainer, pointerPosition} = props; + const {tooltip, xAxis, yAxis, svgContainer, dispatcher} = props; + const {hovered, pointerPosition} = useTooltip({dispatcher, tooltip}); const containerRect = svgContainer?.getBoundingClientRect() || {left: 0, top: 0}; const left = (pointerPosition?.[0] || 0) + containerRect.left; const top = (pointerPosition?.[1] || 0) + containerRect.top; @@ -47,7 +44,7 @@ export const Tooltip = (props: TooltipProps) => { window.dispatchEvent(new CustomEvent('scroll')); }, [left, top]); - return hovered ? ( + return hovered?.length ? ( { const inactiveEnabled = inactiveOptions?.enabled; dispatcher.on('hover-shape.area', (data?: TooltipDataChunkArea[]) => { - const selected = data?.find((d) => d.series.type === 'area'); - const selectedDataItem = selected?.data; - const selectedSeriesId = selected?.series?.id; + const selected = data?.filter((d) => d.series.type === 'area') || []; + const selectedDataItems = selected.map((d) => d.data); + const selectedSeriesIds = selected.map((d) => d.series?.id); shapeSelection.datum((d, index, list) => { const elementSelection = select(list[index]); - const hovered = Boolean(hoverEnabled && d.id === selectedSeriesId); + const hovered = Boolean(hoverEnabled && selectedSeriesIds.includes(d.id)); if (d.hovered !== hovered) { d.hovered = hovered; @@ -135,7 +135,9 @@ export const AreaSeriesShapes = (args: Args) => { element: list[index], state: inactiveOptions, active: Boolean( - !inactiveEnabled || !selectedSeriesId || selectedSeriesId === d.id, + !inactiveEnabled || + !selectedSeriesIds.length || + selectedSeriesIds.includes(d.id), ), datum: d, }); @@ -146,7 +148,9 @@ export const AreaSeriesShapes = (args: Args) => { element: list[index], state: inactiveOptions, active: Boolean( - !inactiveEnabled || !selectedSeriesId || selectedSeriesId === d.series.id, + !inactiveEnabled || + !selectedSeriesIds.length || + selectedSeriesIds.includes(d.series.id), ), datum: d, }); @@ -155,7 +159,7 @@ export const AreaSeriesShapes = (args: Args) => { markerSelection.datum((d, index, list) => { const elementSelection = select(list[index]); - const hovered = Boolean(hoverEnabled && d.point.data === selectedDataItem); + const hovered = Boolean(hoverEnabled && selectedDataItems.includes(d.point.data)); if (d.hovered !== hovered) { d.hovered = hovered; elementSelection.attr('visibility', getMarkerVisibility(d)); @@ -169,8 +173,8 @@ export const AreaSeriesShapes = (args: Args) => { if (d.point.series.marker.states.normal.enabled) { const isActive = Boolean( !inactiveEnabled || - !selectedSeriesId || - selectedSeriesId === d.point.series.id, + !selectedSeriesIds.length || + selectedSeriesIds.includes(d.point.series.id), ); setActiveState({ element: list[index], diff --git a/src/plugins/d3/renderer/hooks/useShapes/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/index.tsx index 44cdf42a..36a01268 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -55,7 +55,6 @@ type Args = { seriesOptions: PreparedSeriesOptions; xAxis: PreparedAxis; yAxis: PreparedAxis[]; - svgContainer: SVGSVGElement | null; xScale?: ChartScale; yScale?: ChartScale; }; @@ -71,7 +70,6 @@ export const useShapes = (args: Args) => { xScale, yAxis, yScale, - svgContainer, } = args; const shapesComponents = React.useMemo(() => { @@ -182,9 +180,9 @@ export const useShapes = (args: Args) => { dispatcher={dispatcher} preparedData={preparedData} seriesOptions={seriesOptions} - svgContainer={svgContainer} />, ); + shapesData.push(...preparedData); } break; } @@ -200,9 +198,9 @@ export const useShapes = (args: Args) => { dispatcher={dispatcher} preparedData={preparedData} seriesOptions={seriesOptions} - svgContainer={svgContainer} />, ); + shapesData.push(...preparedData); break; } case 'treemap': { @@ -219,9 +217,9 @@ export const useShapes = (args: Args) => { dispatcher={dispatcher} preparedData={preparedData} seriesOptions={seriesOptions} - svgContainer={svgContainer} />, ); + shapesData.push(preparedData as unknown as ShapeData); } } return acc; @@ -238,7 +236,6 @@ export const useShapes = (args: Args) => { xScale, yAxis, yScale, - svgContainer, ]); return {shapes: shapesComponents.shapes, shapesData: shapesComponents.shapesData}; diff --git a/src/plugins/d3/renderer/hooks/useShapes/line/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/line/index.tsx index 747bad2d..6f3dc85e 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/line/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/line/index.tsx @@ -95,14 +95,14 @@ export const LineSeriesShapes = (args: Args) => { const inactiveEnabled = inactiveOptions?.enabled; dispatcher.on('hover-shape.line', (data?: TooltipDataChunkLine[]) => { - const selected = data?.find((d) => d.series.type === 'line'); - const selectedDataItem = selected?.data; - const selectedSeriesId = selected?.series?.id; + const selected = data?.filter((d) => d.series.type === 'line') || []; + const selectedDataItems = selected.map((d) => d.data); + const selectedSeriesIds = selected.map((d) => d.series?.id); lineSelection.datum((d, index, list) => { const elementSelection = select(list[index]); - const hovered = Boolean(hoverEnabled && d.id === selectedSeriesId); + const hovered = Boolean(hoverEnabled && selectedSeriesIds.includes(d.id)); if (d.hovered !== hovered) { d.hovered = hovered; elementSelection.attr('stroke', (d) => { @@ -122,7 +122,9 @@ export const LineSeriesShapes = (args: Args) => { element: list[index], state: inactiveOptions, active: Boolean( - !inactiveEnabled || !selectedSeriesId || selectedSeriesId === d.id, + !inactiveEnabled || + !selectedSeriesIds.length || + selectedSeriesIds.includes(d.id), ), datum: d, }); @@ -133,7 +135,9 @@ export const LineSeriesShapes = (args: Args) => { element: list[index], state: inactiveOptions, active: Boolean( - !inactiveEnabled || !selectedSeriesId || selectedSeriesId === d.series.id, + !inactiveEnabled || + !selectedSeriesIds.length || + selectedSeriesIds.includes(d.series.id), ), datum: d, }); @@ -142,7 +146,7 @@ export const LineSeriesShapes = (args: Args) => { markerSelection.datum((d, index, list) => { const elementSelection = select(list[index]); - const hovered = Boolean(hoverEnabled && d.point.data === selectedDataItem); + const hovered = Boolean(hoverEnabled && selectedDataItems.includes(d.point.data)); if (d.hovered !== hovered) { d.hovered = hovered; elementSelection.attr('visibility', getMarkerVisibility(d)); @@ -156,8 +160,8 @@ export const LineSeriesShapes = (args: Args) => { if (d.point.series.marker.states.normal.enabled) { const isActive = Boolean( !inactiveEnabled || - !selectedSeriesId || - selectedSeriesId === d.point.series.id, + !selectedSeriesIds.length || + selectedSeriesIds.includes(d.point.series.id), ); setActiveState({ element: list[index], diff --git a/src/plugins/d3/renderer/hooks/useShapes/pie/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/pie/index.tsx index cf3542fd..7ec1d1dd 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/pie/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/pie/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import {arc, color, line as lineGenerator, pointer, select} from 'd3'; +import {arc, color, line as lineGenerator, select} from 'd3'; import type {BaseType, Dispatch, PieArcDatum} from 'd3'; import get from 'lodash/get'; @@ -20,7 +20,6 @@ type PreparePieSeriesArgs = { dispatcher: Dispatch; preparedData: PreparedPieData[]; seriesOptions: PreparedSeriesOptions; - svgContainer: SVGSVGElement | null; }; export function getHaloVisibility(d: PieArcDatum) { @@ -29,7 +28,7 @@ export function getHaloVisibility(d: PieArcDatum) { } export function PieSeriesShapes(args: PreparePieSeriesArgs) { - const {dispatcher, preparedData, seriesOptions, svgContainer} = args; + const {dispatcher, preparedData, seriesOptions} = args; const ref = React.useRef(null); React.useEffect(() => { @@ -158,40 +157,20 @@ export function PieSeriesShapes(args: PreparePieSeriesArgs) { const eventName = `hover-shape.pie`; const hoverOptions = get(seriesOptions, 'pie.states.hover'); const inactiveOptions = get(seriesOptions, 'pie.states.inactive'); - svgElement - .on('mousemove', (e) => { - const currentSegment = getSelectedSegment(e.target); - - if (currentSegment) { - const data: TooltipDataChunkPie = { - series: { - id: currentSegment.series.id, - type: 'pie', - name: currentSegment.series.name, - }, - data: currentSegment.series.data, - }; - - dispatcher.call('hover-shape', {}, [data], pointer(e, svgContainer)); - } - }) - .on('mouseleave', () => { - dispatcher.call('hover-shape', {}, undefined); - }) - .on('click', (e) => { - const selectedSegment = getSelectedSegment(e.target); - if (selectedSegment) { - dispatcher.call( - 'click-chart', - undefined, - {point: selectedSegment.series.data, series: selectedSegment.series}, - e, - ); - } - }); + svgElement.on('click', (e) => { + const selectedSegment = getSelectedSegment(e.target); + if (selectedSegment) { + dispatcher.call( + 'click-chart', + undefined, + {point: selectedSegment.series.data, series: selectedSegment.series}, + e, + ); + } + }); dispatcher.on(eventName, (data?: TooltipDataChunkPie[]) => { - const selectedSeriesId = data?.[0].series.id; + const selectedSeriesId = data?.[0]?.series?.id; const hoverEnabled = hoverOptions?.enabled; const inactiveEnabled = inactiveOptions?.enabled; @@ -263,7 +242,7 @@ export function PieSeriesShapes(args: PreparePieSeriesArgs) { return () => { dispatcher.on(eventName, null); }; - }, [dispatcher, preparedData, seriesOptions, svgContainer]); + }, [dispatcher, preparedData, seriesOptions]); return ; } diff --git a/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx index c5657366..b67478fa 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import {pointer, select} from 'd3'; +import {select} from 'd3'; import type {BaseType, Dispatch} from 'd3'; import get from 'lodash/get'; @@ -23,13 +23,12 @@ type ScatterSeriesShapeProps = { dispatcher: Dispatch; preparedData: PreparedScatterData[]; seriesOptions: PreparedSeriesOptions; - svgContainer: SVGSVGElement | null; }; const b = block('d3-scatter'); export function ScatterSeriesShape(props: ScatterSeriesShapeProps) { - const {dispatcher, preparedData, seriesOptions, svgContainer} = props; + const {dispatcher, preparedData, seriesOptions} = props; const ref = React.useRef(null); React.useEffect(() => { @@ -56,39 +55,17 @@ export function ScatterSeriesShape(props: ScatterSeriesShapeProps) { return select(element).datum(); }; - svgElement - .on('mousemove', (e) => { - const datum = getSelectedPoint(e.target); - - if (!datum) { - return; - } - - const [pointerX, pointerY] = pointer(e, svgContainer); - const data: TooltipDataChunkScatter = { - series: { - id: datum.point.series.id, - type: 'scatter', - name: datum.point.series.name, - }, - data: datum.point.data, - }; - dispatcher.call('hover-shape', {}, [data], [pointerX, pointerY]); - }) - .on('mouseleave', () => { - dispatcher.call('hover-shape', {}, undefined); - }) - .on('click', (e) => { - const datum = getSelectedPoint(e.target); - if (datum) { - dispatcher.call( - 'click-chart', - undefined, - {point: datum.point.data, series: datum.point.series}, - e, - ); - } - }); + svgElement.on('click', (e) => { + const datum = getSelectedPoint(e.target); + if (datum) { + dispatcher.call( + 'click-chart', + undefined, + {point: datum.point.data, series: datum.point.series}, + e, + ); + } + }); const hoverEnabled = hoverOptions?.enabled; const inactiveEnabled = inactiveOptions?.enabled; @@ -136,7 +113,7 @@ export function ScatterSeriesShape(props: ScatterSeriesShapeProps) { return () => { dispatcher.on('hover-shape.scatter', null); }; - }, [dispatcher, preparedData, seriesOptions, svgContainer]); + }, [dispatcher, preparedData, seriesOptions]); return ; } diff --git a/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx index f76614ea..049f4de9 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import {color, pointer, select} from 'd3'; +import {color, select} from 'd3'; import type {BaseType, Dispatch, HierarchyRectangularNode} from 'd3'; import get from 'lodash/get'; @@ -17,11 +17,10 @@ type ShapeProps = { dispatcher: Dispatch; preparedData: PreparedTreemapData; seriesOptions: PreparedSeriesOptions; - svgContainer: SVGSVGElement | null; }; export const TreemapSeriesShape = (props: ShapeProps) => { - const {dispatcher, preparedData, seriesOptions, svgContainer} = props; + const {dispatcher, preparedData, seriesOptions} = props; const ref = React.useRef(null); React.useEffect(() => { @@ -72,28 +71,15 @@ export const TreemapSeriesShape = (props: ShapeProps) => { const eventName = `hover-shape.treemap`; const hoverOptions = get(seriesOptions, 'treemap.states.hover'); const inactiveOptions = get(seriesOptions, 'treemap.states.inactive'); - svgElement - .on('mousemove', (e) => { - const datum = getSelectedPart(e.target); - dispatcher.call( - 'hover-shape', - {}, - [{data: datum.data, series}], - pointer(e, svgContainer), - ); - }) - .on('mouseleave', () => { - dispatcher.call('hover-shape', {}, undefined); - }) - .on('click', (e) => { - const datum = getSelectedPart(e.target); - dispatcher.call('click-chart', undefined, {point: datum.data, series}, e); - }); + svgElement.on('click', (e) => { + const datum = getSelectedPart(e.target); + dispatcher.call('click-chart', undefined, {point: datum.data, series}, e); + }); dispatcher.on(eventName, (data?: TooltipDataChunkTreemap[]) => { const hoverEnabled = hoverOptions?.enabled; const inactiveEnabled = inactiveOptions?.enabled; - const hoveredData = data?.[0].data; + const hoveredData = data?.[0]?.data; rectSelection.datum((d, index, list) => { const currentRect = select>( list[index], @@ -139,7 +125,7 @@ export const TreemapSeriesShape = (props: ShapeProps) => { return () => { dispatcher.on(eventName, null); }; - }, [dispatcher, preparedData, seriesOptions, svgContainer]); + }, [dispatcher, preparedData, seriesOptions]); return ; }; diff --git a/src/plugins/d3/renderer/utils/get-closest-data.ts b/src/plugins/d3/renderer/utils/get-closest-data.ts new file mode 100644 index 00000000..e0e804a4 --- /dev/null +++ b/src/plugins/d3/renderer/utils/get-closest-data.ts @@ -0,0 +1,231 @@ +import {Delaunay, bisector, sort} from 'd3'; +import get from 'lodash/get'; +import groupBy from 'lodash/groupBy'; + +import type { + AreaSeries, + BarXSeries, + ChartKitWidgetSeries, + ChartKitWidgetSeriesData, + LineSeries, + TooltipDataChunk, + TreemapSeries, +} from '../../../../types'; +import type {PreparedBarXData, PreparedScatterData, ShapeData} from '../hooks'; +import type {PreparedAreaData} from '../hooks/useShapes/area/types'; +import type {PreparedBarYData} from '../hooks/useShapes/bar-y/types'; +import type {PreparedLineData} from '../hooks/useShapes/line/types'; +import type {PreparedPieData} from '../hooks/useShapes/pie/types'; +import type {PreparedTreemapData} from '../hooks/useShapes/treemap/types'; + +type GetClosestPointsArgs = { + position: [number, number]; + shapesData: ShapeData[]; +}; + +export type ShapePoint = { + x: number; + y0: number; + y1: number; + data: ChartKitWidgetSeriesData; + series: ChartKitWidgetSeries; +}; + +function getClosestPointsByXValue(x: number, y: number, points: ShapePoint[]) { + const sorted = sort(points, (p) => p.x); + const closestXIndex = bisector((p) => p.x).center(sorted, x); + if (closestXIndex === -1) { + return []; + } + + const closestX = sorted[closestXIndex].x; + const closestPoints = sort( + points.filter((p) => p.x === closestX), + (p) => p.y0, + ); + + let closestYIndex = -1; + if (y < closestPoints[0]?.y0) { + closestYIndex = 0; + } else if (y > closestPoints[closestPoints.length - 1]?.y1) { + closestYIndex = closestPoints.length - 1; + } else { + closestYIndex = closestPoints.findIndex((p) => y > p.y0 && y < p.y1); + } + + return closestPoints.map((p, i) => ({ + data: p.data, + series: p.series, + closest: i === closestYIndex, + })); +} + +function getSeriesType(shapeData: ShapeData) { + return get(shapeData, 'series.type') || get(shapeData, 'point.series.type'); +} + +export function getClosestPoints(args: GetClosestPointsArgs): TooltipDataChunk[] { + const {position, shapesData} = args; + const [pointerX, pointerY] = position; + + const result: TooltipDataChunk[] = []; + const groups = groupBy(shapesData, getSeriesType); + Object.entries(groups).forEach(([seriesType, list]) => { + switch (seriesType) { + case 'bar-x': { + const points = (list as PreparedBarXData[]).map((d) => ({ + data: d.data, + series: d.series as BarXSeries, + x: d.x + d.width / 2, + y0: d.y, + y1: d.y + d.height, + })); + Array.prototype.push.apply( + result, + getClosestPointsByXValue(pointerX, pointerY, points) as TooltipDataChunk[], + ); + + break; + } + case 'area': { + const points = (list as PreparedAreaData[]).reduce((acc, d) => { + Array.prototype.push.apply( + acc, + d.points.map((p) => ({ + data: p.data, + series: p.series as AreaSeries, + x: p.x, + y0: p.y0, + y1: p.y, + })), + ); + return acc; + }, []); + Array.prototype.push.apply( + result, + getClosestPointsByXValue(pointerX, pointerY, points) as TooltipDataChunk[], + ); + break; + } + case 'line': { + const points = (list as PreparedLineData[]).reduce((acc, d) => { + Array.prototype.push.apply( + acc, + d.points.map((p) => ({ + data: p.data, + series: p.series as LineSeries, + x: p.x, + y0: p.y, + y1: p.y, + })), + ); + return acc; + }, []); + Array.prototype.push.apply( + result, + getClosestPointsByXValue(pointerX, pointerY, points) as TooltipDataChunk[], + ); + break; + } + case 'bar-y': { + const points = list as PreparedBarYData[]; + const sorted = sort(points, (p) => p.y); + const closestYIndex = bisector((p) => p.y).center( + sorted, + pointerY, + ); + + let closestPoints: PreparedBarYData[] = []; + let closestXIndex = -1; + if (closestYIndex !== -1) { + const closestY = sorted[closestYIndex].y; + closestPoints = sort( + points.filter((p) => p.y === closestY), + (p) => p.x, + ); + + const lastPoint = closestPoints[closestPoints.length - 1]; + if (pointerX < closestPoints[0]?.x) { + closestXIndex = 0; + } else if (lastPoint && pointerX > lastPoint.x + lastPoint.width) { + closestXIndex = closestPoints.length - 1; + } else { + closestXIndex = closestPoints.findIndex( + (p) => pointerX > p.x && pointerX < p.x + p.width, + ); + } + } + + Array.prototype.push.apply( + result, + closestPoints.map((p, i) => ({ + data: p.data, + series: p.series, + closest: i === closestXIndex, + })) as TooltipDataChunk[], + ); + break; + } + case 'scatter': { + const points = list as PreparedScatterData[]; + const delaunayX = Delaunay.from( + points, + (d) => d.point.x, + (d) => d.point.y, + ); + const closestPoint = points[delaunayX.find(pointerX, pointerY)]; + if (closestPoint) { + result.push({ + data: closestPoint.point.data, + series: closestPoint.point.series, + closest: true, + }); + } + + break; + } + case 'pie': { + const points = (list as PreparedPieData[]).map((d) => d.segments).flat(); + const closestPoint = points.find((p) => { + const {center, radius} = p.data.pie; + const x = pointerX - center[0]; + const y = pointerY - center[1]; + let angle = Math.atan2(y, x) + 0.5 * Math.PI; + angle = angle < 0 ? Math.PI * 2 + angle : angle; + const polarRadius = Math.sqrt(x * x + y * y); + + return angle >= p.startAngle && angle <= p.endAngle && polarRadius < radius; + }); + + if (closestPoint) { + result.push({ + data: closestPoint.data.series.data, + series: closestPoint.data.series, + closest: true, + }); + } + + break; + } + case 'treemap': { + const data = list as unknown as PreparedTreemapData[]; + const closestPoint = data[0]?.leaves.find((l) => { + return ( + pointerX >= l.x0 && pointerX <= l.x1 && pointerY >= l.y0 && pointerY <= l.y1 + ); + }); + if (closestPoint) { + result.push({ + data: closestPoint.data, + series: data[0].series as TreemapSeries, + closest: true, + }); + } + + break; + } + } + }); + + return result; +} diff --git a/src/plugins/d3/renderer/utils/index.ts b/src/plugins/d3/renderer/utils/index.ts index a8f19b3c..953da716 100644 --- a/src/plugins/d3/renderer/utils/index.ts +++ b/src/plugins/d3/renderer/utils/index.ts @@ -27,8 +27,6 @@ const CHARTS_WITHOUT_AXIS: ChartKitWidgetSeries['type'][] = ['pie', 'treemap']; export type AxisDirection = 'x' | 'y'; -export type NodeWithD3Data = Element & {__data__: T}; - type UnknownSeries = {type: ChartKitWidgetSeries['type']; data: unknown}; /** @@ -250,12 +248,3 @@ 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/tooltip.ts b/src/types/widget-data/tooltip.ts index 6ca5cec4..03a7d1e4 100644 --- a/src/types/widget-data/tooltip.ts +++ b/src/types/widget-data/tooltip.ts @@ -57,14 +57,15 @@ export type TooltipDataChunkTreemap = { series: TreemapSeries; }; -export type TooltipDataChunk = +export type TooltipDataChunk = ( | TooltipDataChunkBarX | TooltipDataChunkBarY | TooltipDataChunkPie | TooltipDataChunkScatter | TooltipDataChunkLine | TooltipDataChunkArea - | TooltipDataChunkTreemap; + | TooltipDataChunkTreemap +) & {closest?: boolean}; export type ChartKitWidgetTooltip = { enabled?: boolean;