From f876e9a51edc6f7692f3151512f51390f97d9699 Mon Sep 17 00:00:00 2001 From: goodmice Date: Fri, 17 Jun 2022 19:22:07 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9A=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=83=20=D0=BE=D0=BF=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D1=91=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B2=D1=81=D0=BF=D0=BB=D1=8B=D0=B2=D0=B0=D1=8E=D1=89=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D1=81=D1=82=D0=BE=D0=BB=D0=B1=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/d3/D3Tooltip.tsx | 95 +++++++++++++++++++ src/components/d3/index.ts | 3 + .../Telemetry/Operations/OperationsChart.jsx | 76 +++++++++++---- src/styles/d3.less | 30 ++++++ 4 files changed, 187 insertions(+), 17 deletions(-) create mode 100644 src/components/d3/D3Tooltip.tsx create mode 100644 src/components/d3/index.ts create mode 100644 src/styles/d3.less diff --git a/src/components/d3/D3Tooltip.tsx b/src/components/d3/D3Tooltip.tsx new file mode 100644 index 0000000..1366aeb --- /dev/null +++ b/src/components/d3/D3Tooltip.tsx @@ -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 & { + targets: Selection + onTargetHover?: (e: MouseEvent) => void + onTargetOut?: (e: MouseEvent) => void + width?: number + height?: number + content: (data: unknown, target: Selection) => ReactNode +} + +export const D3Tooltip = memo(({ targets, width, height, content, onTargetHover, onTargetOut, className, style, ...other }) => { + const [target, setTarget] = useState>() + const [position, setMousePosition] = useState<{ x: number, y: number }>({ x: 0, y: 0 }) + const [visible, setVisible] = useState(false) + + const tooltipRef = useRef(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 ( +
+ {data} +
+ ) +}) diff --git a/src/components/d3/index.ts b/src/components/d3/index.ts new file mode 100644 index 0000000..1154fa7 --- /dev/null +++ b/src/components/d3/index.ts @@ -0,0 +1,3 @@ +export * from './D3Tooltip' + +export type { D3TooltipProps } from './D3Tooltip' diff --git a/src/pages/Telemetry/Operations/OperationsChart.jsx b/src/pages/Telemetry/Operations/OperationsChart.jsx index 396aac6..634ac8e 100644 --- a/src/pages/Telemetry/Operations/OperationsChart.jsx +++ b/src/pages/Telemetry/Operations/OperationsChart.jsx @@ -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 (
- - - - - + + + + + + d3.select(e.target).attr('stroke', bar.hover)} + onTargetOut={(e) => d3.select(e.target).attr('stroke', bar.color)} + content={(v) => ( + + Дата: + {formatDate(v.date)} + Ключевой параметр: + {(v.value || 0).toFixed(2)} + Целевой параметр: + {(v.target || 0).toFixed(2)} + + )} + />
) }) diff --git a/src/styles/d3.less b/src/styles/d3.less new file mode 100644 index 0000000..9610535 --- /dev/null +++ b/src/styles/d3.less @@ -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; + } +} \ No newline at end of file