Добавлено отображение подсказок

This commit is contained in:
Александр Сироткин 2022-06-26 17:16:12 +05:00
parent c952abb2b8
commit 5109d73013
4 changed files with 125 additions and 24 deletions

View File

@ -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<D3ChartProps>(({
}, [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<D3ChartProps>(({
.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<D3ChartProps>(({
.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<D3ChartProps>(({
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<D3ChartProps>(({
<rect width={width - offset.left - offset.right} height={height - offset.top - offset.bottom} fill={backgroundColor} />
</g>
<D3MouseZone width={width} height={height} offset={offset}>
{(plugins?.tooltip?.enabled ?? true) && ( <D3Tooltip {...plugins?.tooltip} data={data} charts={charts} /> )}
{(plugins?.cursor?.enabled ?? true) && ( <D3Cursor {...plugins?.cursor} /> )}
{(plugins?.tooltip?.enabled ?? true) && ( <D3Tooltip {...plugins?.tooltip} charts={charts} /> )}
</D3MouseZone>
</svg>
</D3ContextMenu>

View File

@ -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<any, any, null, undefined>) | null
zoneRect: DOMRect | null
subscribe: SubscribeFunction
}
export type D3MouseZoneProps = {
@ -29,7 +32,8 @@ export const D3MouseZoneContext = createContext<D3MouseZoneContext>({
visible: false
},
zone: null,
zoneRect: null
zoneRect: null,
subscribe: () => null
})
export const useD3MouseZone = () => useContext(D3MouseZoneContext)
@ -40,7 +44,7 @@ export const D3MouseZone = memo<D3MouseZoneProps>(({ width, height, offset, chil
const [state, setState] = useState<D3MouseState>({ 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 () => {

View File

@ -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<DataType> = {
render?: (data: DataType) => ReactNode
render?: (data: DataType, target: d3.Selection<any, any, null, undefined>, 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<DataType> = BaseTooltip<DataType> & (AccurateToolt
export type D3TooltipProps<DataType = Record<string, any>> = Partial<D3TooltipSettings<DataType>> & {
charts: any[],
data: DataType[]
}
export const D3Tooltip = wrapPlugin<D3TooltipProps>(({ type = 'accurate', width, height, render, data, charts }) => {
const { mouseState, zoneRect } = useD3MouseZone()
const defaultRender = <DataType,>(data: DataType, target: any, mouseState: D3MouseState) => (
<>
X: {mouseState.x} Y: {mouseState.y}
<br/>
Data: {JSON.stringify(data)}
</>
)
export const D3Tooltip = wrapPlugin<D3TooltipProps>(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<any>()
const [style, setStyle] = useState<CSSProperties>(_style)
const [position, setPosition] = useState<D3TooltipPosition>(_position ?? 'bottom')
const [fixed, setFixed] = useState(false)
const tooltipRef = useRef<HTMLDivElement>(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 (
<foreignObject>
{mouseState.visible && render && render(data)}
<foreignObject
x={style.left}
y={style.top}
width={width}
height={height}
opacity={style.opacity}
pointerEvents={fixed ? 'all' : 'none'}
>
<div
{...other}
ref={tooltipRef}
className={`tooltip ${position} ${className}`}
>
{tooltipBody}
</div>
</foreignObject>
)
})

View File

@ -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;