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 { 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
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