diff --git a/src/components/d3/D3Chart.tsx b/src/components/d3/D3Chart.tsx index 0e9e48e..4f4dbc8 100644 --- a/src/components/d3/D3Chart.tsx +++ b/src/components/d3/D3Chart.tsx @@ -1,7 +1,7 @@ -import { memo, ReactNode, useCallback, useEffect, useMemo, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useElementSize } from 'usehooks-ts' -import { Empty } from 'antd' import { Property } from 'csstype' +import { Empty } from 'antd' import * as d3 from 'd3' import LoaderPortal from '@components/LoaderPortal' @@ -15,103 +15,11 @@ import { D3Cursor, D3CursorSettings, D3Tooltip, - D3TooltipSettings + D3TooltipSettings, } from './plugins' import '@styles/d3.less' - -type DefaultDataType = Record - -export type ChartAxis = { - type: 'linear' | 'time', - accessor: keyof DataType | ((d: DataType) => any) -} - -export type BaseChartDataset = { - key: string | number - label?: ReactNode - yAxis: ChartAxis - color?: Property.Color - opacity?: number - width?: Property.StrokeWidth - tooltip?: D3TooltipSettings - animDurationMs?: number - afterDraw?: (d: any) => void -} - -export type LineChartDataset = { - type: 'line' - point?: { - radius?: number - color?: Property.Color - } - nullValues?: 'skip' | 'gap' | 'none' - optimization?: boolean -} - -export type AreaChartDataset = { - type: 'area' - fillColor?: Property.Color - point?: { - radius?: number - color?: Property.Color - } -} - -export type NeedleChartDataset = { - type: 'needle' -} - -export type ChartDataset = BaseChartDataset & ( - AreaChartDataset | - LineChartDataset | - NeedleChartDataset -) - -export type ChartDomain = { - x: { min?: number, max?: number } - y: { min?: number, max?: number } -} - -export type ChartOffset = { - top: number - bottom: number - left: number - right: number -} - -export type ChartTicks = { - color?: Property.Color - x?: { visible?: boolean, count?: number } - y?: { visible?: boolean, count?: number } -} - -export type D3ChartProps = React.DetailedHTMLProps, HTMLDivElement> & { - xAxis: ChartAxis - datasets: ChartDataset[] - data?: DataType[] - domain?: Partial - width?: number | string - height?: number | string - loading?: boolean - offset?: Partial - mode: 'horizontal' | 'vertical' - animDurationMs?: number - backgroundColor?: Property.Color - ticks?: ChartTicks - plugins?: { - menu?: BasePluginSettings & D3ContextMenuSettings - tooltip?: BasePluginSettings & D3TooltipSettings - cursor?: BasePluginSettings & D3CursorSettings - } -} - -type Selection = d3.Selection - -type ChartRegistry = ChartDataset & { - (): Selection - y: (value: any) => number -} +import { ChartAxis, ChartDataset, ChartDomain, ChartOffset, ChartRegistry, ChartTicks, DefaultDataType } from './types' const defaultOffsets: ChartOffset = { top: 10, @@ -125,10 +33,6 @@ const defaultXAxisConfig: ChartAxis = { accessor: (d: any) => new Date(d.date) } -const defaultCursorPlugin: BasePluginSettings & D3CursorSettings = { - enabled: true, -} - const getGroupClass = (key: string | number) => `chart-id-${key}` const getByAccessor = >(accessor: string | ((d: T) => any)) => { @@ -143,7 +47,26 @@ const createAxis = (config: ChartAxis) => { return d3.scaleLinear() } -export const D3Chart = memo(({ +export type D3ChartProps = React.DetailedHTMLProps, HTMLDivElement> & { + xAxis: ChartAxis + datasets: ChartDataset[] + data?: DataType[] + domain?: Partial + width?: number | string + height?: number | string + loading?: boolean + offset?: Partial + animDurationMs?: number + backgroundColor?: Property.Color + ticks?: ChartTicks + plugins?: { + menu?: BasePluginSettings & D3ContextMenuSettings + tooltip?: BasePluginSettings & D3TooltipSettings + cursor?: BasePluginSettings & D3CursorSettings + } +} + +export const D3Chart = memo>(({ className = '', xAxis: _xAxisConfig, datasets, @@ -153,7 +76,6 @@ export const D3Chart = memo(({ height: givenHeight = '100%', loading, offset: _offset, - mode = 'horizontal', animDurationMs = 200, backgroundColor = 'transparent', ticks, @@ -174,7 +96,7 @@ export const D3Chart = memo(({ const getX = useMemo(() => getByAccessor(xAxisConfig.accessor), [xAxisConfig.accessor]) - const [charts, setCharts] = useState([]) + const [charts, setCharts] = useState[]>([]) const [rootRef, { width, height }] = useElementSize() @@ -272,7 +194,9 @@ export const D3Chart = memo(({ () => chartArea().select('.' + getGroupClass(dataset.key)), { ...dataset, - y: getByAccessor(dataset.yAxis.accessor) + xAxis: dataset.xAxis ?? xAxisConfig, + y: getByAccessor(dataset.yAxis.accessor), + x: getByAccessor(dataset.xAxis?.accessor ?? xAxisConfig.accessor), } ) @@ -293,7 +217,8 @@ export const D3Chart = memo(({ charts.forEach((chart) => { chart() - .attr('stroke', String(chart.color)) + .attr('color', chart.color ?? null) + .attr('stroke', 'currentColor') .attr('stroke-width', chart.width ?? 1) .attr('opacity', chart.opacity ?? 1) .attr('fill', 'none') @@ -305,7 +230,7 @@ export const D3Chart = memo(({ case 'needle': elms = chart() .selectAll('line') - .data(data) + .data(d) elms.exit().remove() elms.enter().append('line') @@ -314,15 +239,15 @@ export const D3Chart = memo(({ .selectAll('line') .transition() .duration(chart.animDurationMs ?? animDurationMs) - .attr('x1', (d: any) => xAxis(getX(d))) - .attr('x2', (d: any) => xAxis(getX(d))) + .attr('x1', (d: any) => xAxis(chart.x(d))) + .attr('x2', (d: any) => xAxis(chart.x(d))) .attr('y1', height - offset.bottom - offset.top) .attr('y2', (d: any) => yAxis(chart.y(d))) break case 'line': { let line = d3.line() - .x(d => xAxis(getX(d))) + .x(d => xAxis(chart.x(d))) .y(d => yAxis(chart.y(d))) switch (chart.nullValues || 'skip') { @@ -341,14 +266,36 @@ export const D3Chart = memo(({ d = optimize(d) } + if (chart().selectAll('path').empty()) chart().append('path') - elms = chart().selectAll('path') + chart().selectAll('path') .transition() .duration(chart.animDurationMs ?? animDurationMs) .attr('d', line(d as any)) + const radius = chart.point?.radius ?? 3 + elms = chart() + .selectAll('circle') + .data(d) + + elms.exit().remove() + elms.enter().append('circle') + + elms = chart() + .selectAll('circle') + .transition() + .duration(chart.animDurationMs ?? animDurationMs) + .attr('cx', (d: any) => xAxis(chart.x(d))) + .attr('cy', (d: any) => yAxis(chart.y(d))) + .attr('r', radius) + .attr('stroke-width', chart.point?.strokeWidth ?? null) + .attr('stroke', chart.point?.strokeColor ?? null) + .attr('fill', chart.point?.fillColor ?? null) + + elms = chart().selectAll() + break } default: diff --git a/src/components/d3/index.ts b/src/components/d3/index.ts index c71522a..4811ec3 100644 --- a/src/components/d3/index.ts +++ b/src/components/d3/index.ts @@ -1,2 +1,4 @@ export * from './D3Chart' -export type { D3ChartProps, ChartAxis, ChartDataset, ChartDomain, ChartOffset } from './D3Chart' +export type { D3ChartProps } from './D3Chart' + +export * from './types' diff --git a/src/components/d3/plugins/D3Tooltip.tsx b/src/components/d3/plugins/D3Tooltip.tsx index 82a186d..4f8e4bf 100644 --- a/src/components/d3/plugins/D3Tooltip.tsx +++ b/src/components/d3/plugins/D3Tooltip.tsx @@ -1,22 +1,34 @@ -import { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react' +import { CSSProperties, ReactNode, useCallback, useEffect, useRef, useState } from 'react' +import { BarChartOutlined, LineChartOutlined } from '@ant-design/icons' import * as d3 from 'd3' -import { isDev } from '@utils' +import { formatDate, isDev } from '@utils' import { D3MouseState, useD3MouseZone } from '../D3MouseZone' +import { ChartRegistry, DefaultDataType } from '../types' import { wrapPlugin } from './base' import '@styles/d3.less' type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none' +type D3TooltipTouchType = 'x' | 'y' | 'all' + +export type D3RenderData = { + chart: ChartRegistry + data: DataType[] + selection: d3.Selection +}[] + +export type D3RenderFunction = (data: D3RenderData, mouseState: D3MouseState) => ReactNode export type BaseTooltip = { - render?: (data: DataType, target: d3.Selection, mouseState: D3MouseState) => ReactNode + render?: D3RenderFunction width?: number | string height?: number | string style?: CSSProperties position?: D3TooltipPosition className?: string + touchType?: D3TooltipTouchType } export type AccurateTooltip = { type: 'accurate' } @@ -27,49 +39,129 @@ export type NearestTooltip = { export type D3TooltipSettings = BaseTooltip & (AccurateTooltip | NearestTooltip) -export type D3TooltipProps> = Partial> & { - charts: any[], -} - -const defaultRender = (data: DataType, target: any, mouseState: D3MouseState) => ( +const defaultRender: D3RenderFunction> = (data, mouseState) => ( <> - X: {mouseState.x} Y: {mouseState.y} -
- Data: {JSON.stringify(data)} + {data.length > 0 ? data.map(({ chart, data }) => { + let Icon + switch (chart.type) { + case 'needle': Icon = BarChartOutlined; break + case 'line': Icon = LineChartOutlined; break + // case 'area': Icon = AreaChartOutlined; break + // case 'dot': Icon = DotChartOutLined; break + } + + return ( +
+
+ + {chart.label}: +
+ {data.map((d, i) => ( + + {formatDate(chart.x(d))} {chart.xAxis.unit} :: {chart.y(d).toFixed(2)} {chart.yAxis.unit} + + ))} +
+ ) + }) : ( + Данных нет + )} ) +export type D3TooltipProps = Partial> & { + charts: ChartRegistry[], +} + +const getDistance = (x1: number, y1: number, x2: number, y2: number) => + Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)) + +const makeIsCircleTouched = (x: number, y: number, limit: number) => function (this: d3.BaseType, d: any, i: number) { + const elm = d3.select(this) + const cx = +elm.attr('cx') + const cy = +elm.attr('cy') + const r = +elm.attr('r') + + if (Number.isNaN(cx + cy + r)) return false + const distance = getDistance(x, y, cx, cy) + return (distance - r) <= (limit || 0) +} + +const makeIsLineTouched = (x: number, y: number, limit: number) => function (this: d3.BaseType, d: any, i: number) { + const elm = d3.select(this) + const dx = +elm.attr('x1') + const y1 = +elm.attr('y1') + const y2 = +elm.attr('y2') + + if (Number.isNaN(dx + y1 + y2)) return false + + const ymin = Math.min(y1, y2) + const ymax = Math.max(y1, y2) + + const pd = getDistance(x, y, dx, ymin) // Расстояние до верхней точки + const distance = (ymin <= y && y <= ymax) ? Math.abs(x - dx) : pd + + return distance <= (limit || 0) +} + +const getTouchedElements = ( + chart: ChartRegistry, + x: number, + y: number, + limit: number = 0, + touchType: D3TooltipTouchType = 'all' +): d3.Selection => { + let nodes: d3.Selection + switch (chart.type) { + case 'line': + nodes = chart().selectAll('circle') + if (touchType === 'all') + nodes = nodes.filter(makeIsCircleTouched(x, y, limit)) + break + case 'needle': + nodes = chart().selectAll('line') + if (touchType === 'all') + nodes = nodes.filter(makeIsLineTouched(x, y, limit)) + break + } + return nodes +} + export const D3Tooltip = wrapPlugin(function D3Tooltip({ type = 'accurate', width = 200, - height = 100, + height = 120, render = defaultRender, charts, position: _position = 'bottom', className, style: _style = {}, + touchType = 'all', ...other }) { const { mouseState, zoneRect, subscribe } = useD3MouseZone() const [tooltipBody, setTooltipBody] = useState() const [style, setStyle] = useState(_style) const [position, setPosition] = useState(_position ?? 'bottom') + const [visible, setVisible] = useState(false) const [fixed, setFixed] = useState(false) const tooltipRef = useRef(null) + const onMiddleClick = useCallback((e: Event) => { + if ((e as MouseEvent).button === 1 && visible) + setFixed((prev) => !prev) + }, [visible]) + useEffect(() => { - const unsubscribe = subscribe('auxclick', (e) => { - if ((e as MouseEvent).button === 1) - setFixed((prev) => !prev) - }) + const unsubscribe = subscribe('auxclick', onMiddleClick) return () => { if (unsubscribe) if (!unsubscribe() && isDev()) console.warn('Не удалось отвязать эвент') } - }, []) + }, [onMiddleClick]) useEffect(() => { if (!tooltipRef.current || !zoneRect || fixed) return @@ -80,13 +172,12 @@ export const D3Tooltip = wrapPlugin(function D3Tooltip({ ...prev, left: -rect.width, top: -rect.height, - opacity: 0, })) return } const offsetX = -rect.width / 2 // По центру - const offsetY = 30 // Чуть выше курсора + const offsetY = 15 // Чуть выше курсора const left = Math.max(0, Math.min(zoneRect.width - rect.width, mouseState.x + offsetX)) const top = mouseState.y - offsetY - rect.height @@ -95,14 +186,31 @@ export const D3Tooltip = wrapPlugin(function D3Tooltip({ ...prev, left, top, - opacity: 1 })) }, [tooltipRef.current, mouseState, zoneRect, fixed]) useEffect(() => { if (fixed) return - setTooltipBody(render({}, d3.select('.nothing'), mouseState)) - }, [mouseState, charts, fixed]) + if (!mouseState.visible) + return setVisible(false) + + const data: D3RenderData = [] + charts.forEach((chart) => { + const touched = getTouchedElements(chart, mouseState.x, mouseState.y, 2, touchType) + + if (touched.empty()) return + + data.push({ + chart, + data: touched.data(), + selection: touched, + }) + }) + + setVisible(data.length > 0) + if (data.length > 0) + setTooltipBody(render(data, mouseState)) + }, [charts, touchType, mouseState, fixed]) return ( (function D3Tooltip({ y={style.top} width={width} height={height} - opacity={style.opacity} + opacity={visible ? 1 : 0} pointerEvents={fixed ? 'all' : 'none'} + style={{ transition: 'opacity .1s ease-out' }} >
+type Selection = d3.Selection + +export type ChartAxis = { + type: 'linear' | 'time', + accessor: keyof DataType | ((d: DataType) => any) + unit?: ReactNode +} + +export type BaseChartDataset = { + key: string | number + label?: ReactNode + yAxis: ChartAxis + xAxis: ChartAxis + color?: Property.Color + opacity?: number + width?: Property.StrokeWidth + tooltip?: D3TooltipSettings + animDurationMs?: number + afterDraw?: (d: any) => void +} + +export type LineChartDataset = { + type: 'line' + point?: { + radius?: number + strokeColor?: Property.Stroke + strokeWidth?: Property.StrokeWidth + fillColor?: Property.Fill + } + nullValues?: 'skip' | 'gap' | 'none' + optimization?: boolean +} + +export type NeedleChartDataset = { + type: 'needle' +} + +export type ChartDataset = BaseChartDataset & ( + LineChartDataset | + NeedleChartDataset +) + +export type ChartDomain = { + x: { min?: number, max?: number } + y: { min?: number, max?: number } +} + +export type ChartOffset = { + top: number + bottom: number + left: number + right: number +} + +export type ChartTicks = { + color?: Property.Color + x?: { visible?: boolean, count?: number } + y?: { visible?: boolean, count?: number } +} + +export type ChartRegistry = ChartDataset & { + (): Selection + y: (value: any) => number + x: (value: any) => number +} diff --git a/src/pages/Telemetry/Operations/OperationsChart.jsx b/src/pages/Telemetry/Operations/OperationsChart.jsx index 250dbc4..55e9535 100644 --- a/src/pages/Telemetry/Operations/OperationsChart.jsx +++ b/src/pages/Telemetry/Operations/OperationsChart.jsx @@ -7,18 +7,8 @@ import '@styles/detected_operations.less' // Палитра: https://colorhunt.co/palette/f9f2ed3ab0ffffb562f87474 const chartDatasets = [{ - key: 'normLine', - type: 'area', - width: 2, - color: '#FFB562', - opacity: 0.3, - yAxis: { - type: 'linear', - accessor: (row) => row.operationValue?.standardValue, - }, - fillColor: '#FFB562' -}, { key: 'normBars', + label: 'Нормативные значения', type: 'needle', width: 2, color: '#FFB562', @@ -29,6 +19,7 @@ const chartDatasets = [{ }, }, { key: 'bars', + label: 'Действительные значения', type: 'needle', width: 2, color: '#3AB0FF', @@ -39,6 +30,7 @@ const chartDatasets = [{ }, }, { key: 'target', + label: 'Целевые значения', type: 'line', color: '#F87474', yAxis: { @@ -50,7 +42,7 @@ const chartDatasets = [{ const xAxis = { type: 'time', - accessor: (row) => new Date(row.dateStart) + accessor: (row) => new Date(row.dateStart), } export const OperationsChart = memo(({ data, yDomain, height }) => { @@ -77,12 +69,14 @@ export const OperationsChart = memo(({ data, yDomain, height }) => { plugins={{ tooltip: { enabled: true, + type: 'nearest', + limit: 10, }, cursor: { enabled: true, }, menu: { - enabled: true, + enabled: false, onUpdate: onChartUpdate, } }} diff --git a/src/styles/d3.less b/src/styles/d3.less index 8f339f7..504109a 100644 --- a/src/styles/d3.less +++ b/src/styles/d3.less @@ -8,6 +8,7 @@ width: 100%; height: 100% - @arrow-size; + font-size: 13px; color: @color; position: absolute; padding: 5px; @@ -31,6 +32,24 @@ left: 50%; margin-left: -@arrow-size; } + + & .tooltip-group { + display: flex; + flex-direction: column; + justify-content: stretch; + align-items: flex-start; + + & .group-label { + width: 200%; + overflow: hidden; + + & span { + font-weight: 600; + margin-left: 5px; + white-space: nowrap; + } + } + } } & .chart-empty {