diff --git a/src/components/d3/plugins/D3Tooltip.tsx b/src/components/d3/plugins/D3Tooltip.tsx index 7c94dde..a8bca26 100644 --- a/src/components/d3/plugins/D3Tooltip.tsx +++ b/src/components/d3/plugins/D3Tooltip.tsx @@ -1,25 +1,24 @@ -import { CSSProperties, memo, ReactNode, useCallback, useEffect, useRef, useState } from 'react' -import { AreaChartOutlined, BarChartOutlined, DotChartOutlined, LineChartOutlined } from '@ant-design/icons' +import { AreaChartOutlined, BarChartOutlined, BorderOuterOutlined, DotChartOutlined, LineChartOutlined } from '@ant-design/icons' +import { CSSProperties, ReactNode, useCallback, useEffect, useRef, useState } from 'react' import * as d3 from 'd3' import { isDev } from '@utils' import { D3MouseState, useD3MouseZone } from '../D3MouseZone' import { ChartRegistry } from '../types' -import { wrapPlugin } from './base' +import { getTouchedElements, wrapPlugin } from './base' import '@styles/d3.less' -type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none' -type D3TooltipTouchType = 'x' | 'y' | 'all' +export type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none' export type D3RenderData = { chart: ChartRegistry data: DataType[] - selection: d3.Selection -}[] + selection?: d3.Selection +} -export type D3RenderFunction = (data: D3RenderData, mouseState: D3MouseState) => ReactNode +export type D3RenderFunction = (data: D3RenderData[], mouseState: D3MouseState) => ReactNode export type D3TooltipSettings = { render?: D3RenderFunction @@ -28,11 +27,10 @@ export type D3TooltipSettings = { style?: CSSProperties position?: D3TooltipPosition className?: string - touchType?: D3TooltipTouchType limit?: number } -const makeDefaultRender = (): D3RenderFunction => (data, mouseState) => ( +export const makeDefaultRender = (): D3RenderFunction => (data, mouseState) => ( <> {data.length > 0 ? data.map(({ chart, data }) => { let Icon @@ -41,6 +39,7 @@ const makeDefaultRender = (): D3RenderFunction => (data, mo case 'line': Icon = LineChartOutlined; break case 'point': Icon = DotChartOutlined; break case 'area': Icon = AreaChartOutlined; break + case 'rect_area': Icon = BorderOuterOutlined; break // case 'dot': Icon = DotChartOutLined; break } @@ -70,72 +69,6 @@ export type D3TooltipProps = Partial> & { charts: ChartRegistry[], } -const getDistance = (x1: number, y1: number, x2: number, y2: number) => - Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)) - -const makeIsCircleTouched = (x: number, y: number, limit: number) => function (this: d3.BaseType, d: any, i: number) { - const elm = d3.select(this) - const cx = +elm.attr('cx') - const cy = +elm.attr('cy') - const r = +elm.attr('r') - - if (Number.isNaN(cx + cy + r)) return false - const distance = getDistance(x, y, cx, cy) - return (distance - r) <= (limit || 0) -} - -const makeIsLineTouched = (x: number, y: number, limit: number) => function (this: d3.BaseType, d: any, i: number) { - const elm = d3.select(this) - const dx = +elm.attr('x1') - const y1 = +elm.attr('y1') - const y2 = +elm.attr('y2') - - if (Number.isNaN(dx + y1 + y2)) return false - - const ymin = Math.min(y1, y2) - const ymax = Math.max(y1, y2) - - const pd = getDistance(x, y, dx, ymin) // Расстояние до верхней точки - const distance = (ymin <= y && y <= ymax) ? Math.abs(x - dx) : pd - - return distance <= (limit || 0) -} - -const getTouchedElements = ( - chart: ChartRegistry, - x: number, - y: number, - limit: number = 0, - touchType: D3TooltipTouchType = 'all' -): d3.Selection => { - let nodes: d3.Selection - switch (chart.type) { - case 'area': - case 'line': - case 'point': { - const tag = chart.point?.shape ?? 'circle' - nodes = chart().selectAll(tag) - if (touchType === 'all') { - switch (tag) { - case 'circle': - nodes = nodes.filter(makeIsCircleTouched(x, y, chart.tooltip?.limit ?? limit)) - break - case 'line': - nodes = nodes.filter(makeIsLineTouched(x, y, chart.tooltip?.limit ?? limit)) - break - } - } - break - } - case 'needle': - nodes = chart().selectAll('line') - if (touchType === 'all') - nodes = nodes.filter(makeIsLineTouched(x, y, chart.tooltip?.limit ?? limit)) - break - } - return nodes -} - function _D3Tooltip>({ width = 200, height = 120, @@ -144,7 +77,6 @@ function _D3Tooltip>({ position: _position = 'bottom', className = '', style: _style = {}, - touchType = 'all', limit = 2 }: D3TooltipProps) { const { mouseState, zoneRect, subscribe } = useD3MouseZone() @@ -156,12 +88,12 @@ function _D3Tooltip>({ const tooltipRef = useRef(null) - const onMiddleClick = useCallback((e: Event) => { - if ((e as MouseEvent).button === 1 && visible) - setFixed((prev) => !prev) - }, [visible]) - useEffect(() => { + const onMiddleClick = (e: Event) => { + if ((e as MouseEvent).button === 1 && visible) + setFixed((prev) => !prev) + } + const unsubscribe = subscribe('auxclick', onMiddleClick) return () => { @@ -169,7 +101,7 @@ function _D3Tooltip>({ if (!unsubscribe() && isDev()) console.warn('Не удалось отвязать эвент') } - }, [onMiddleClick]) + }, [visible]) useEffect(() => { if (!tooltipRef.current || !zoneRect || fixed) return @@ -197,9 +129,9 @@ function _D3Tooltip>({ if (!mouseState.visible) return setVisible(false) - const data: D3RenderData = [] + const data: D3RenderData[] = [] charts.forEach((chart) => { - const touched = getTouchedElements(chart, mouseState.x, mouseState.y, limit, touchType) + const touched = getTouchedElements(chart, mouseState.x, mouseState.y, limit) if (touched.empty()) return @@ -213,7 +145,7 @@ function _D3Tooltip>({ setVisible(data.length > 0) if (data.length > 0) setTooltipBody(render(data, mouseState)) - }, [charts, touchType, mouseState, fixed, limit]) + }, [charts, mouseState, fixed, limit]) return ( = T extends (...t: [...infer Arg]) => any ? Arg : never -type InferReturn = T extends (...t: [...infer Arg]) => infer Res ? Res : never - export const wrapPlugin = ( Component: FC, defaultEnabled?: boolean @@ -19,3 +21,105 @@ export const wrapPlugin = ( return wrappedComponent } + +const makeIsCircleTouched = (x: number, y: number, limit: number, type: TouchType = 'all') => function (this: d3.BaseType, d: any, i: number) { + const elm = d3.select(this) + const cx = +elm.attr('cx') + const cy = +elm.attr('cy') + const r = +elm.attr('r') + + if (Number.isNaN(cx + cy + r)) return false + + const distance = getDistance(x, y, cx, cy, type) + + return (distance - r) <= limit +} + +const makeIsLineTouched = (x: number, y: number, limit: number, type: TouchType = 'all') => function (this: d3.BaseType, d: any, i: number) { + const elm = d3.select(this) + const dx = +elm.attr('x1') + const y1 = +elm.attr('y1') + const y2 = +elm.attr('y2') + + if (Number.isNaN(dx + y1 + y2)) return false + + const ymin = Math.min(y1, y2) + const ymax = Math.max(y1, y2) + const pd = getDistance(x, y, dx, ymin) // Расстояние до верхней точки + + let distance + switch (type) { + case 'all': + distance = (ymin <= y && y <= ymax) ? Math.abs(x - dx) : pd + break + case 'x': + distance = Math.abs(x - dx) + break + case 'y': + distance = (ymin <= y && y <= ymax) ? 0 : Math.min(Math.abs(y - ymin), Math.abs(y - ymax)) + break + } + + return distance <= limit +} + +const makeIsRectTouched = (x: number, y: number, limit: number, type: TouchType = 'all') => function (this: d3.BaseType, d: any, i: number) { + const elm = d3.select(this) + const dx = +elm.attr('x') + const dy = +elm.attr('y') + const width = +elm.attr('width') + const height = +elm.attr('height') + + if (Number.isNaN(x + y + width + height)) return false + + const isOnHorizont = (dx - limit <= x) && (x <= dx + limit + width) + const isOnVertical = (dy - limit <= y) && (y <= dy + limit + height) + + if (isOnHorizont && isOnVertical) + return true + + switch(type) { + case 'all': { + const dV = Math.min(getDistance(x, y, x, dy), getDistance(x, y, x, dy + height)) + const dH = Math.min(getDistance(x, y, dx, y), getDistance(x, y, dx + width, y)) + return (isOnHorizont && dV <= limit) || (isOnVertical && dH <= limit) + } + case 'x': return isOnHorizont + case 'y': return isOnVertical + } +} + +export const getTouchedElements = ( + chart: ChartRegistry, + x: number, + y: number, + limit: number = 0, + type: TouchType = 'all' +): d3.Selection => { + let nodes: d3.Selection + switch (chart.type) { + case 'area': + case 'line': + case 'point': { + const tag = chart.point?.shape ?? 'circle' + nodes = chart().selectAll(tag) + switch (tag) { + case 'circle': + nodes = nodes.filter(makeIsCircleTouched(x, y, chart.tooltip?.limit ?? limit, type)) + break + case 'line': + nodes = nodes.filter(makeIsLineTouched(x, y, chart.tooltip?.limit ?? limit, type)) + break + } + break + } + case 'needle': + nodes = chart().selectAll('line') + nodes = nodes.filter(makeIsLineTouched(x, y, chart.tooltip?.limit ?? limit, type)) + break + case 'rect_area': + nodes = chart().selectAll('rect') + nodes = nodes.filter(makeIsRectTouched(x, y, chart.tooltip?.limit ?? limit, type)) + } + return nodes +}