forked from ddrilling/asb_cloud_front
К графику определённых операций добавлена всплывающая подсказка для столбиков
This commit is contained in:
parent
48723286c7
commit
f876e9a51e
95
src/components/d3/D3Tooltip.tsx
Normal file
95
src/components/d3/D3Tooltip.tsx
Normal 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>
|
||||
)
|
||||
})
|
3
src/components/d3/index.ts
Normal file
3
src/components/d3/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './D3Tooltip'
|
||||
|
||||
export type { D3TooltipProps } from './D3Tooltip'
|
@ -1,14 +1,40 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
import { D3Tooltip } from '@components/d3'
|
||||
import { Grid, GridItem } from '@components/Grid'
|
||||
import { makePointsOptimizator } from '@utils/functions/chart'
|
||||
import { formatDate } from '@utils'
|
||||
|
||||
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)
|
||||
|
||||
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 [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 axisY = useRef(null)
|
||||
@ -20,24 +46,24 @@ export const OperationsChart = memo(({ data, yDomain, width, height, bottom = 30
|
||||
|
||||
const d = useMemo(() => data.map((row) => ({
|
||||
date: new Date(row.dateStart),
|
||||
value: row.durationMinutes,
|
||||
value: row.value,
|
||||
target: row.operationValue?.targetValue,
|
||||
})), [data]) // Нормализуем данные для графика
|
||||
|
||||
const x = useMemo(() => d3
|
||||
.scaleTime()
|
||||
.range([0, w - left - right])
|
||||
.range([0, w - offset.left - offset.right])
|
||||
.domain([
|
||||
d3.min(d, d => d.date),
|
||||
d3.max(d, d => d.date)
|
||||
])
|
||||
, [w, d, left, right]) // Создаём ось X
|
||||
, [w, d, offset]) // Создаём ось X
|
||||
|
||||
const y = useMemo(() => d3
|
||||
.scaleLinear()
|
||||
.range([h - bottom - top, 0])
|
||||
.range([h - offset.bottom - offset.top, 0])
|
||||
.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()
|
||||
.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))
|
||||
, [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(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)
|
||||
|
||||
bars.exit().remove() // Удаляем лишние линии
|
||||
bars.enter().append('line') // Добавляем новые, если нужно
|
||||
|
||||
bars.enter()
|
||||
.append('line') // Создаём новые линии, если не хватает
|
||||
.merge(bars) // Объединяем с существующими
|
||||
const newBars = d3.select(chartBars.current)
|
||||
.selectAll('line')
|
||||
.attr('x1', 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))
|
||||
|
||||
}, [d, x, y, color, h, bottom, top])
|
||||
setBars(newBars)
|
||||
}, [d, x, y, h, offset])
|
||||
|
||||
return (
|
||||
<div className={'page-left'} ref={setRef}>
|
||||
<svg width={width ?? '100%'} height={height ?? '100%'}>
|
||||
<g ref={axisX} className={'axis x'} transform={`translate(${left}, ${h - bottom})`} />
|
||||
<g ref={axisY} className={'axis y'} transform={`translate(${left}, ${top})`} />
|
||||
<g transform={`translate(${left}, ${top})`}>
|
||||
<g ref={chartBars} stroke={color} />
|
||||
<path ref={targetPath} stroke={targetColor} fill={'none'} />
|
||||
<g ref={axisX} className={'axis x'} transform={`translate(${offset.left}, ${h - offset.bottom})`} />
|
||||
<g ref={axisY} className={'axis y'} transform={`translate(${offset.left}, ${offset.top})`} />
|
||||
<g transform={`translate(${offset.left}, ${offset.top})`}>
|
||||
<g ref={chartBars} stroke={bar.color} strokeWidth={bar.width} />
|
||||
<path ref={targetPath} stroke={target.color} strokeWidth={target.width} fill={'none'} />
|
||||
</g>
|
||||
</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>
|
||||
)
|
||||
})
|
||||
|
30
src/styles/d3.less
Normal file
30
src/styles/d3.less
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user