From 5109d73013e9c9a3b966377c51c88aebbb3cee4a Mon Sep 17 00:00:00 2001 From: goodmice Date: Sun, 26 Jun 2022 17:16:12 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/d3/D3Chart.tsx | 28 ++++--- src/components/d3/D3MouseZone.tsx | 8 +- src/components/d3/plugins/D3Tooltip.tsx | 105 ++++++++++++++++++++++-- src/styles/d3.less | 8 +- 4 files changed, 125 insertions(+), 24 deletions(-) diff --git a/src/components/d3/D3Chart.tsx b/src/components/d3/D3Chart.tsx index eb017da..0e9e48e 100644 --- a/src/components/d3/D3Chart.tsx +++ b/src/components/d3/D3Chart.tsx @@ -5,7 +5,7 @@ import { Property } from 'csstype' import * as d3 from 'd3' import LoaderPortal from '@components/LoaderPortal' -import { formatDate, makePointsOptimizator, usePartialProps } from '@utils' +import { formatDate, isDev, makePointsOptimizator, usePartialProps } from '@utils' import D3MouseZone from './D3MouseZone' import { @@ -247,6 +247,12 @@ export const D3Chart = memo(({ }, [yAxisArea, yAxis, animDurationMs, width, offset, ticks]) useEffect(() => { + if (isDev()) + for (let i = 0; i < datasets.length - 1; i++) + for (let j = i + 1; j < datasets.length; j++) + if (datasets[i].key === datasets[j].key) + console.warn(`Ключ датасета "${datasets[i].key}" неуникален (индексы ${i} и ${j})!`) + setCharts((oldCharts) => { const charts: ChartRegistry[] = [] @@ -293,17 +299,18 @@ export const D3Chart = memo(({ .attr('fill', 'none') let d = data + let elms switch (chart.type) { - case 'needle': { - const bars = chart() + case 'needle': + elms = chart() .selectAll('line') .data(data) - bars.exit().remove() - bars.enter().append('line') + elms.exit().remove() + elms.enter().append('line') - const newBars = chart() + elms = chart() .selectAll('line') .transition() .duration(chart.animDurationMs ?? animDurationMs) @@ -312,9 +319,7 @@ export const D3Chart = memo(({ .attr('y1', height - offset.bottom - offset.top) .attr('y2', (d: any) => yAxis(chart.y(d))) - chart.afterDraw?.(newBars) break - } case 'line': { let line = d3.line() .x(d => xAxis(getX(d))) @@ -339,17 +344,18 @@ export const D3Chart = memo(({ if (chart().selectAll('path').empty()) chart().append('path') - const lineElm = chart().selectAll('path') + elms = chart().selectAll('path') .transition() .duration(chart.animDurationMs ?? animDurationMs) .attr('d', line(d as any)) - chart.afterDraw?.(lineElm) break } default: break } + + chart.afterDraw?.(elms) }) }, [charts, data, xAxis, yAxis, height]) @@ -377,8 +383,8 @@ export const D3Chart = memo(({ - {(plugins?.tooltip?.enabled ?? true) && ( )} {(plugins?.cursor?.enabled ?? true) && ( )} + {(plugins?.tooltip?.enabled ?? true) && ( )} diff --git a/src/components/d3/D3MouseZone.tsx b/src/components/d3/D3MouseZone.tsx index ab8cdeb..1a9dd32 100644 --- a/src/components/d3/D3MouseZone.tsx +++ b/src/components/d3/D3MouseZone.tsx @@ -9,10 +9,13 @@ export type D3MouseState = { visible: boolean } +type SubscribeFunction = (name: keyof SVGElementEventMap, handler: EventListenerOrEventListenerObject) => null | (() => boolean) + export type D3MouseZoneContext = { mouseState: D3MouseState, zone: (() => d3.Selection) | null zoneRect: DOMRect | null + subscribe: SubscribeFunction } export type D3MouseZoneProps = { @@ -29,7 +32,8 @@ export const D3MouseZoneContext = createContext({ visible: false }, zone: null, - zoneRect: null + zoneRect: null, + subscribe: () => null }) export const useD3MouseZone = () => useContext(D3MouseZoneContext) @@ -40,7 +44,7 @@ export const D3MouseZone = memo(({ width, height, offset, chil const [state, setState] = useState({ x: 0, y: 0, visible: false }) - const subscribeEvent = useCallback((name: keyof SVGElementEventMap, handler: EventListenerOrEventListenerObject) => { + const subscribeEvent: SubscribeFunction = useCallback((name, handler) => { if (!rectRef.current) return null rectRef.current.addEventListener(name, handler) return () => { diff --git a/src/components/d3/plugins/D3Tooltip.tsx b/src/components/d3/plugins/D3Tooltip.tsx index aab0571..82a186d 100644 --- a/src/components/d3/plugins/D3Tooltip.tsx +++ b/src/components/d3/plugins/D3Tooltip.tsx @@ -1,15 +1,22 @@ -import { ReactNode } from 'react' +import { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react' import * as d3 from 'd3' -import { useD3MouseZone } from '../D3MouseZone' +import { isDev } from '@utils' + +import { D3MouseState, useD3MouseZone } from '../D3MouseZone' import { wrapPlugin } from './base' import '@styles/d3.less' +type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none' + export type BaseTooltip = { - render?: (data: DataType) => ReactNode + render?: (data: DataType, target: d3.Selection, mouseState: D3MouseState) => ReactNode width?: number | string height?: number | string + style?: CSSProperties + position?: D3TooltipPosition + className?: string } export type AccurateTooltip = { type: 'accurate' } @@ -22,15 +29,97 @@ export type D3TooltipSettings = BaseTooltip & (AccurateToolt export type D3TooltipProps> = Partial> & { charts: any[], - data: DataType[] } -export const D3Tooltip = wrapPlugin(({ type = 'accurate', width, height, render, data, charts }) => { - const { mouseState, zoneRect } = useD3MouseZone() +const defaultRender = (data: DataType, target: any, mouseState: D3MouseState) => ( + <> + X: {mouseState.x} Y: {mouseState.y} +
+ Data: {JSON.stringify(data)} + +) + +export const D3Tooltip = wrapPlugin(function D3Tooltip({ + type = 'accurate', + width = 200, + height = 100, + render = defaultRender, + charts, + position: _position = 'bottom', + className, + style: _style = {}, + ...other +}) { + const { mouseState, zoneRect, subscribe } = useD3MouseZone() + const [tooltipBody, setTooltipBody] = useState() + const [style, setStyle] = useState(_style) + const [position, setPosition] = useState(_position ?? 'bottom') + const [fixed, setFixed] = useState(false) + + const tooltipRef = useRef(null) + + useEffect(() => { + const unsubscribe = subscribe('auxclick', (e) => { + if ((e as MouseEvent).button === 1) + setFixed((prev) => !prev) + }) + + return () => { + if (unsubscribe) + if (!unsubscribe() && isDev()) + console.warn('Не удалось отвязать эвент') + } + }, []) + + useEffect(() => { + if (!tooltipRef.current || !zoneRect || fixed) return + const rect = tooltipRef.current.getBoundingClientRect() + + if (!mouseState.visible) { + setStyle((prev) => ({ + ...prev, + left: -rect.width, + top: -rect.height, + opacity: 0, + })) + return + } + + const offsetX = -rect.width / 2 // По центру + const offsetY = 30 // Чуть выше курсора + + const left = Math.max(0, Math.min(zoneRect.width - rect.width, mouseState.x + offsetX)) + const top = mouseState.y - offsetY - rect.height + + setStyle((prev) => ({ + ...prev, + left, + top, + opacity: 1 + })) + }, [tooltipRef.current, mouseState, zoneRect, fixed]) + + useEffect(() => { + if (fixed) return + setTooltipBody(render({}, d3.select('.nothing'), mouseState)) + }, [mouseState, charts, fixed]) return ( - - {mouseState.visible && render && render(data)} + +
+ {tooltipBody} +
) }) diff --git a/src/styles/d3.less b/src/styles/d3.less index 1bdc757..8f339f7 100644 --- a/src/styles/d3.less +++ b/src/styles/d3.less @@ -3,18 +3,20 @@ & .tooltip { @color: white; @bg-color: rgba(0, 0, 0, .75); + @arrow-size: 8px; + + width: 100%; + height: 100% - @arrow-size; color: @color; position: absolute; - padding: 0; + padding: 5px; border: none; border-radius: 2px; background-color: @bg-color; transition: opacity .1s ease-out; - @arrow-size: 8px; - &::after { content: ' '; position: absolute;