К графику определённых операций добавлена всплывающая подсказка для столбиков

This commit is contained in:
goodmice 2022-06-17 19:22:07 +05:00
parent 48723286c7
commit f876e9a51e
4 changed files with 187 additions and 17 deletions

View File

@ -0,0 +1,95 @@
import * as d3 from 'd3'
import { Selection } from 'd3'
import { HTMLAttributes, memo, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import '@styles/d3.less'
export type D3TooltipProps = HTMLAttributes<HTMLDivElement> & {
targets: Selection<any, any, null, undefined>
onTargetHover?: (e: MouseEvent) => void
onTargetOut?: (e: MouseEvent) => void
width?: number
height?: number
content: (data: unknown, target: Selection<any, any, null, undefined>) => ReactNode
}
export const D3Tooltip = memo<D3TooltipProps>(({ targets, width, height, content, onTargetHover, onTargetOut, className, style, ...other }) => {
const [target, setTarget] = useState<Selection<any, any, null, undefined>>()
const [position, setMousePosition] = useState<{ x: number, y: number }>({ x: 0, y: 0 })
const [visible, setVisible] = useState<boolean>(false)
const tooltipRef = useRef<HTMLDivElement>(null)
const onMouseOver = useCallback((e: MouseEvent) => {
onTargetHover?.(e)
setTarget(d3.select(e.target as Element))
setMousePosition({ x: e.pageX, y: e.pageY })
setVisible(true)
}, [onTargetHover])
const onMouseOut = useCallback((e: MouseEvent) => {
onTargetOut?.(e)
setVisible(false)
}, [onTargetOut])
const onMouseMove = useCallback((e: MouseEvent) => {
setMousePosition({ x: e.pageX, y: e.pageY })
}, [])
useEffect(() => {
document.onmousemove = onMouseMove
}, [])
useEffect(() => {
console.log('a')
if (!targets) return
targets
.on('mouseover', onMouseOver)
.on('mouseout', onMouseOut)
return () => {
targets
.on('mouseover', null)
.on('mouseout', null)
}
}, [targets, onMouseOver, onMouseOut])
const data = useMemo(() => {
if (!target) return null
const data = target.data()[0]
return content(data, target)
}, [target])
const tooltipStyle = useMemo(() => {
const rect: DOMRect = tooltipRef.current?.getBoundingClientRect() ?? new DOMRect(0, 0, width, height)
const offsetX = -rect.width / 2 // По центру
const offsetY = 30 // Чуть выше курсора
const left = Math.max(0, Math.min(document.documentElement.clientWidth - rect.width, position.x + offsetX))
const bottom = document.documentElement.clientHeight - Math.max(position.y - offsetY, rect.height + offsetY)
return ({
...style,
width,
height,
left,
bottom,
opacity: visible ? 1 : 0
})
}, [tooltipRef, style, width, height, position, visible])
return (
<div
ref={tooltipRef}
className={`asb-d3-tooltip bottom ${className ?? ''}`}
style={tooltipStyle}
{...other}
>
{data}
</div>
)
})

View File

@ -0,0 +1,3 @@
export * from './D3Tooltip'
export type { D3TooltipProps } from './D3Tooltip'

View File

@ -1,14 +1,40 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react' import { memo, useEffect, useMemo, useRef, useState } from 'react'
import * as d3 from 'd3' import * as d3 from 'd3'
import { D3Tooltip } from '@components/d3'
import { Grid, GridItem } from '@components/Grid'
import { makePointsOptimizator } from '@utils/functions/chart' import { makePointsOptimizator } from '@utils/functions/chart'
import { formatDate } from '@utils'
import '@styles/detected_operations.less' import '@styles/detected_operations.less'
const defaultBar = {
width: 2, /// Толщина столбцов графика
color: 'royalblue', /// Цвет столбца операций
hover: 'red', /// Цвет выделеного столбца операций
}
const defaultTarget = {
width: 1, /// Толщина линий целевых параметров
color: 'red', /// Цвет линий целевых параметров
}
const defaultOffsets = {
top: 10,
bottom: 30,
left: 50,
right: 10,
}
const optimizePoints = makePointsOptimizator((a, b) => a.target === b.target) const optimizePoints = makePointsOptimizator((a, b) => a.target === b.target)
export const OperationsChart = memo(({ data, yDomain, width, height, bottom = 30, left = 50, top = 10, right = 10, color = '#00F', targetColor = '#F00' }) => { export const OperationsChart = memo(({ data, yDomain, width, height, offsets, barsStyle, targetLineStyle }) => {
const [ref, setRef] = useState(null) const [ref, setRef] = useState(null)
const [bars, setBars] = useState()
const offset = useMemo(() => ({ ...defaultOffsets, ...offsets }), [offsets])
const bar = useMemo(() => ({ ...defaultBar, ...barsStyle }), [barsStyle])
const target = useMemo(() => ({ ...defaultTarget, ...targetLineStyle }), [targetLineStyle])
const axisX = useRef(null) const axisX = useRef(null)
const axisY = useRef(null) const axisY = useRef(null)
@ -20,24 +46,24 @@ export const OperationsChart = memo(({ data, yDomain, width, height, bottom = 30
const d = useMemo(() => data.map((row) => ({ const d = useMemo(() => data.map((row) => ({
date: new Date(row.dateStart), date: new Date(row.dateStart),
value: row.durationMinutes, value: row.value,
target: row.operationValue?.targetValue, target: row.operationValue?.targetValue,
})), [data]) // Нормализуем данные для графика })), [data]) // Нормализуем данные для графика
const x = useMemo(() => d3 const x = useMemo(() => d3
.scaleTime() .scaleTime()
.range([0, w - left - right]) .range([0, w - offset.left - offset.right])
.domain([ .domain([
d3.min(d, d => d.date), d3.min(d, d => d.date),
d3.max(d, d => d.date) d3.max(d, d => d.date)
]) ])
, [w, d, left, right]) // Создаём ось X , [w, d, offset]) // Создаём ось X
const y = useMemo(() => d3 const y = useMemo(() => d3
.scaleLinear() .scaleLinear()
.range([h - bottom - top, 0]) .range([h - offset.bottom - offset.top, 0])
.domain([0, yDomain ?? d3.max(d, d => d.value)]) .domain([0, yDomain ?? d3.max(d, d => d.value)])
, [h, d, top, bottom, yDomain]) // Создаём ось Y , [h, d, offset, yDomain]) // Создаём ось Y
const targetLine = useMemo(() => d3.line() const targetLine = useMemo(() => d3.line()
.x(d => x(d.date)) .x(d => x(d.date))
@ -45,7 +71,7 @@ export const OperationsChart = memo(({ data, yDomain, width, height, bottom = 30
.defined(d => (d.target ?? null) !== null && !Number.isNaN(d.target)) .defined(d => (d.target ?? null) !== null && !Number.isNaN(d.target))
, [x, y]) , [x, y])
useEffect(() => { d3.select(targetPath.current).attr('d', targetLine(optimizePoints(d))) }, [d, targetLine, targetColor]) // Рисуем линию целевого значения useEffect(() => { d3.select(targetPath.current).attr('d', targetLine(optimizePoints(d))) }, [d, targetLine, target.color]) // Рисуем линию целевого значения
useEffect(() => { d3.select(axisX.current).call(d3.axisBottom(x)) }, [axisX, x]) // Рисуем ось X useEffect(() => { d3.select(axisX.current).call(d3.axisBottom(x)) }, [axisX, x]) // Рисуем ось X
useEffect(() => { d3.select(axisY.current).call(d3.axisLeft(y)) }, [axisY, y]) // Рисуем ось Y useEffect(() => { d3.select(axisY.current).call(d3.axisLeft(y)) }, [axisY, y]) // Рисуем ось Y
@ -56,27 +82,43 @@ export const OperationsChart = memo(({ data, yDomain, width, height, bottom = 30
.data(d) .data(d)
bars.exit().remove() // Удаляем лишние линии bars.exit().remove() // Удаляем лишние линии
bars.enter().append('line') // Добавляем новые, если нужно
bars.enter() const newBars = d3.select(chartBars.current)
.append('line') // Создаём новые линии, если не хватает .selectAll('line')
.merge(bars) // Объединяем с существующими
.attr('x1', d => x(d.date)) // Присваиваем значения .attr('x1', d => x(d.date)) // Присваиваем значения
.attr('x2', d => x(d.date)) .attr('x2', d => x(d.date))
.attr('y1', h - bottom - top) .attr('y1', h - offset.bottom - offset.top)
.attr('y2', d => y(d.value)) .attr('y2', d => y(d.value))
}, [d, x, y, color, h, bottom, top]) setBars(newBars)
}, [d, x, y, h, offset])
return ( return (
<div className={'page-left'} ref={setRef}> <div className={'page-left'} ref={setRef}>
<svg width={width ?? '100%'} height={height ?? '100%'}> <svg width={width ?? '100%'} height={height ?? '100%'}>
<g ref={axisX} className={'axis x'} transform={`translate(${left}, ${h - bottom})`} /> <g ref={axisX} className={'axis x'} transform={`translate(${offset.left}, ${h - offset.bottom})`} />
<g ref={axisY} className={'axis y'} transform={`translate(${left}, ${top})`} /> <g ref={axisY} className={'axis y'} transform={`translate(${offset.left}, ${offset.top})`} />
<g transform={`translate(${left}, ${top})`}> <g transform={`translate(${offset.left}, ${offset.top})`}>
<g ref={chartBars} stroke={color} /> <g ref={chartBars} stroke={bar.color} strokeWidth={bar.width} />
<path ref={targetPath} stroke={targetColor} fill={'none'} /> <path ref={targetPath} stroke={target.color} strokeWidth={target.width} fill={'none'} />
</g> </g>
</svg> </svg>
<D3Tooltip
targets={bars}
onTargetHover={(e) => d3.select(e.target).attr('stroke', bar.hover)}
onTargetOut={(e) => d3.select(e.target).attr('stroke', bar.color)}
content={(v) => (
<Grid>
<GridItem row={1} col={1}>Дата:</GridItem>
<GridItem row={1} col={2}>{formatDate(v.date)}</GridItem>
<GridItem row={2} col={1}>Ключевой параметр:</GridItem>
<GridItem row={2} col={2}>{(v.value || 0).toFixed(2)}</GridItem>
<GridItem row={3} col={1}>Целевой параметр:</GridItem>
<GridItem row={3} col={2}>{(v.target || 0).toFixed(2)}</GridItem>
</Grid>
)}
/>
</div> </div>
) )
}) })

30
src/styles/d3.less Normal file
View File

@ -0,0 +1,30 @@
.asb-d3-tooltip {
@color: white;
@bg-color: rgba(0, 0, 0, .75);
color: @color;
position: absolute;
padding: 0;
border: none;
border-radius: 2px;
background-color: @bg-color;
transition: opacity .1s ease-out;
@arrow-size: 8px;
&::after {
content: ' ';
position: absolute;
width: 0;
height: 0;
border: @arrow-size solid transparent;
}
&.bottom::after {
border-top-color: @bg-color;
top: 100%;
left: 50%;
margin-left: -@arrow-size;
}
}