forked from ddrilling/asb_cloud_front
* Типы графиков перенесены в отдельный файл
* Закончена работа с тултипами (кроме nearest)
This commit is contained in:
parent
5109d73013
commit
571d8de440
@ -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 { useElementSize } from 'usehooks-ts'
|
||||||
import { Empty } from 'antd'
|
|
||||||
import { Property } from 'csstype'
|
import { Property } from 'csstype'
|
||||||
|
import { Empty } from 'antd'
|
||||||
import * as d3 from 'd3'
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
import LoaderPortal from '@components/LoaderPortal'
|
import LoaderPortal from '@components/LoaderPortal'
|
||||||
@ -15,103 +15,11 @@ import {
|
|||||||
D3Cursor,
|
D3Cursor,
|
||||||
D3CursorSettings,
|
D3CursorSettings,
|
||||||
D3Tooltip,
|
D3Tooltip,
|
||||||
D3TooltipSettings
|
D3TooltipSettings,
|
||||||
} from './plugins'
|
} from './plugins'
|
||||||
|
|
||||||
import '@styles/d3.less'
|
import '@styles/d3.less'
|
||||||
|
import { ChartAxis, ChartDataset, ChartDomain, ChartOffset, ChartRegistry, ChartTicks, DefaultDataType } from './types'
|
||||||
type DefaultDataType = Record<string, any>
|
|
||||||
|
|
||||||
export type ChartAxis<DataType> = {
|
|
||||||
type: 'linear' | 'time',
|
|
||||||
accessor: keyof DataType | ((d: DataType) => any)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BaseChartDataset<DataType> = {
|
|
||||||
key: string | number
|
|
||||||
label?: ReactNode
|
|
||||||
yAxis: ChartAxis<DataType>
|
|
||||||
color?: Property.Color
|
|
||||||
opacity?: number
|
|
||||||
width?: Property.StrokeWidth
|
|
||||||
tooltip?: D3TooltipSettings<DataType>
|
|
||||||
animDurationMs?: number
|
|
||||||
afterDraw?: (d: any) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LineChartDataset<DataType> = {
|
|
||||||
type: 'line'
|
|
||||||
point?: {
|
|
||||||
radius?: number
|
|
||||||
color?: Property.Color
|
|
||||||
}
|
|
||||||
nullValues?: 'skip' | 'gap' | 'none'
|
|
||||||
optimization?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AreaChartDataset<DataType> = {
|
|
||||||
type: 'area'
|
|
||||||
fillColor?: Property.Color
|
|
||||||
point?: {
|
|
||||||
radius?: number
|
|
||||||
color?: Property.Color
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NeedleChartDataset<DataType> = {
|
|
||||||
type: 'needle'
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ChartDataset<DataType> = BaseChartDataset<DataType> & (
|
|
||||||
AreaChartDataset<DataType> |
|
|
||||||
LineChartDataset<DataType> |
|
|
||||||
NeedleChartDataset<DataType>
|
|
||||||
)
|
|
||||||
|
|
||||||
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<DataType = DefaultDataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
|
||||||
xAxis: ChartAxis<DataType>
|
|
||||||
datasets: ChartDataset<DataType>[]
|
|
||||||
data?: DataType[]
|
|
||||||
domain?: Partial<ChartDomain>
|
|
||||||
width?: number | string
|
|
||||||
height?: number | string
|
|
||||||
loading?: boolean
|
|
||||||
offset?: Partial<ChartOffset>
|
|
||||||
mode: 'horizontal' | 'vertical'
|
|
||||||
animDurationMs?: number
|
|
||||||
backgroundColor?: Property.Color
|
|
||||||
ticks?: ChartTicks
|
|
||||||
plugins?: {
|
|
||||||
menu?: BasePluginSettings & D3ContextMenuSettings
|
|
||||||
tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
|
|
||||||
cursor?: BasePluginSettings & D3CursorSettings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Selection = d3.Selection<any, any, null, undefined>
|
|
||||||
|
|
||||||
type ChartRegistry<DataType = DefaultDataType> = ChartDataset<DataType> & {
|
|
||||||
(): Selection
|
|
||||||
y: (value: any) => number
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOffsets: ChartOffset = {
|
const defaultOffsets: ChartOffset = {
|
||||||
top: 10,
|
top: 10,
|
||||||
@ -125,10 +33,6 @@ const defaultXAxisConfig: ChartAxis<DefaultDataType> = {
|
|||||||
accessor: (d: any) => new Date(d.date)
|
accessor: (d: any) => new Date(d.date)
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultCursorPlugin: BasePluginSettings & D3CursorSettings = {
|
|
||||||
enabled: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const getGroupClass = (key: string | number) => `chart-id-${key}`
|
const getGroupClass = (key: string | number) => `chart-id-${key}`
|
||||||
|
|
||||||
const getByAccessor = <T extends Record<string, any>>(accessor: string | ((d: T) => any)) => {
|
const getByAccessor = <T extends Record<string, any>>(accessor: string | ((d: T) => any)) => {
|
||||||
@ -143,7 +47,26 @@ const createAxis = <DataType,>(config: ChartAxis<DataType>) => {
|
|||||||
return d3.scaleLinear()
|
return d3.scaleLinear()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const D3Chart = memo<D3ChartProps>(({
|
export type D3ChartProps<DataType = DefaultDataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||||
|
xAxis: ChartAxis<DataType>
|
||||||
|
datasets: ChartDataset<DataType>[]
|
||||||
|
data?: DataType[]
|
||||||
|
domain?: Partial<ChartDomain>
|
||||||
|
width?: number | string
|
||||||
|
height?: number | string
|
||||||
|
loading?: boolean
|
||||||
|
offset?: Partial<ChartOffset>
|
||||||
|
animDurationMs?: number
|
||||||
|
backgroundColor?: Property.Color
|
||||||
|
ticks?: ChartTicks
|
||||||
|
plugins?: {
|
||||||
|
menu?: BasePluginSettings & D3ContextMenuSettings
|
||||||
|
tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
|
||||||
|
cursor?: BasePluginSettings & D3CursorSettings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||||
className = '',
|
className = '',
|
||||||
xAxis: _xAxisConfig,
|
xAxis: _xAxisConfig,
|
||||||
datasets,
|
datasets,
|
||||||
@ -153,7 +76,6 @@ export const D3Chart = memo<D3ChartProps>(({
|
|||||||
height: givenHeight = '100%',
|
height: givenHeight = '100%',
|
||||||
loading,
|
loading,
|
||||||
offset: _offset,
|
offset: _offset,
|
||||||
mode = 'horizontal',
|
|
||||||
animDurationMs = 200,
|
animDurationMs = 200,
|
||||||
backgroundColor = 'transparent',
|
backgroundColor = 'transparent',
|
||||||
ticks,
|
ticks,
|
||||||
@ -174,7 +96,7 @@ export const D3Chart = memo<D3ChartProps>(({
|
|||||||
|
|
||||||
const getX = useMemo(() => getByAccessor(xAxisConfig.accessor), [xAxisConfig.accessor])
|
const getX = useMemo(() => getByAccessor(xAxisConfig.accessor), [xAxisConfig.accessor])
|
||||||
|
|
||||||
const [charts, setCharts] = useState<ChartRegistry[]>([])
|
const [charts, setCharts] = useState<ChartRegistry<DefaultDataType>[]>([])
|
||||||
|
|
||||||
const [rootRef, { width, height }] = useElementSize()
|
const [rootRef, { width, height }] = useElementSize()
|
||||||
|
|
||||||
@ -272,7 +194,9 @@ export const D3Chart = memo<D3ChartProps>(({
|
|||||||
() => chartArea().select('.' + getGroupClass(dataset.key)),
|
() => chartArea().select('.' + getGroupClass(dataset.key)),
|
||||||
{
|
{
|
||||||
...dataset,
|
...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<D3ChartProps>(({
|
|||||||
|
|
||||||
charts.forEach((chart) => {
|
charts.forEach((chart) => {
|
||||||
chart()
|
chart()
|
||||||
.attr('stroke', String(chart.color))
|
.attr('color', chart.color ?? null)
|
||||||
|
.attr('stroke', 'currentColor')
|
||||||
.attr('stroke-width', chart.width ?? 1)
|
.attr('stroke-width', chart.width ?? 1)
|
||||||
.attr('opacity', chart.opacity ?? 1)
|
.attr('opacity', chart.opacity ?? 1)
|
||||||
.attr('fill', 'none')
|
.attr('fill', 'none')
|
||||||
@ -305,7 +230,7 @@ export const D3Chart = memo<D3ChartProps>(({
|
|||||||
case 'needle':
|
case 'needle':
|
||||||
elms = chart()
|
elms = chart()
|
||||||
.selectAll('line')
|
.selectAll('line')
|
||||||
.data(data)
|
.data(d)
|
||||||
|
|
||||||
elms.exit().remove()
|
elms.exit().remove()
|
||||||
elms.enter().append('line')
|
elms.enter().append('line')
|
||||||
@ -314,15 +239,15 @@ export const D3Chart = memo<D3ChartProps>(({
|
|||||||
.selectAll('line')
|
.selectAll('line')
|
||||||
.transition()
|
.transition()
|
||||||
.duration(chart.animDurationMs ?? animDurationMs)
|
.duration(chart.animDurationMs ?? animDurationMs)
|
||||||
.attr('x1', (d: any) => xAxis(getX(d)))
|
.attr('x1', (d: any) => xAxis(chart.x(d)))
|
||||||
.attr('x2', (d: any) => xAxis(getX(d)))
|
.attr('x2', (d: any) => xAxis(chart.x(d)))
|
||||||
.attr('y1', height - offset.bottom - offset.top)
|
.attr('y1', height - offset.bottom - offset.top)
|
||||||
.attr('y2', (d: any) => yAxis(chart.y(d)))
|
.attr('y2', (d: any) => yAxis(chart.y(d)))
|
||||||
|
|
||||||
break
|
break
|
||||||
case 'line': {
|
case 'line': {
|
||||||
let line = d3.line()
|
let line = d3.line()
|
||||||
.x(d => xAxis(getX(d)))
|
.x(d => xAxis(chart.x(d)))
|
||||||
.y(d => yAxis(chart.y(d)))
|
.y(d => yAxis(chart.y(d)))
|
||||||
|
|
||||||
switch (chart.nullValues || 'skip') {
|
switch (chart.nullValues || 'skip') {
|
||||||
@ -341,14 +266,36 @@ export const D3Chart = memo<D3ChartProps>(({
|
|||||||
d = optimize(d)
|
d = optimize(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (chart().selectAll('path').empty())
|
if (chart().selectAll('path').empty())
|
||||||
chart().append('path')
|
chart().append('path')
|
||||||
|
|
||||||
elms = chart().selectAll('path')
|
chart().selectAll('path')
|
||||||
.transition()
|
.transition()
|
||||||
.duration(chart.animDurationMs ?? animDurationMs)
|
.duration(chart.animDurationMs ?? animDurationMs)
|
||||||
.attr('d', line(d as any))
|
.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
|
break
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
@ -1,2 +1,4 @@
|
|||||||
export * from './D3Chart'
|
export * from './D3Chart'
|
||||||
export type { D3ChartProps, ChartAxis, ChartDataset, ChartDomain, ChartOffset } from './D3Chart'
|
export type { D3ChartProps } from './D3Chart'
|
||||||
|
|
||||||
|
export * from './types'
|
||||||
|
@ -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 * as d3 from 'd3'
|
||||||
|
|
||||||
import { isDev } from '@utils'
|
import { formatDate, isDev } from '@utils'
|
||||||
|
|
||||||
import { D3MouseState, useD3MouseZone } from '../D3MouseZone'
|
import { D3MouseState, useD3MouseZone } from '../D3MouseZone'
|
||||||
|
import { ChartRegistry, DefaultDataType } from '../types'
|
||||||
import { wrapPlugin } from './base'
|
import { wrapPlugin } from './base'
|
||||||
|
|
||||||
import '@styles/d3.less'
|
import '@styles/d3.less'
|
||||||
|
|
||||||
type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none'
|
type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none'
|
||||||
|
type D3TooltipTouchType = 'x' | 'y' | 'all'
|
||||||
|
|
||||||
|
export type D3RenderData<DataType> = {
|
||||||
|
chart: ChartRegistry<DataType>
|
||||||
|
data: DataType[]
|
||||||
|
selection: d3.Selection<any, DataType, any, any>
|
||||||
|
}[]
|
||||||
|
|
||||||
|
export type D3RenderFunction<DataType> = (data: D3RenderData<DataType>, mouseState: D3MouseState) => ReactNode
|
||||||
|
|
||||||
export type BaseTooltip<DataType> = {
|
export type BaseTooltip<DataType> = {
|
||||||
render?: (data: DataType, target: d3.Selection<any, any, null, undefined>, mouseState: D3MouseState) => ReactNode
|
render?: D3RenderFunction<DataType>
|
||||||
width?: number | string
|
width?: number | string
|
||||||
height?: number | string
|
height?: number | string
|
||||||
style?: CSSProperties
|
style?: CSSProperties
|
||||||
position?: D3TooltipPosition
|
position?: D3TooltipPosition
|
||||||
className?: string
|
className?: string
|
||||||
|
touchType?: D3TooltipTouchType
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AccurateTooltip = { type: 'accurate' }
|
export type AccurateTooltip = { type: 'accurate' }
|
||||||
@ -27,49 +39,129 @@ export type NearestTooltip = {
|
|||||||
|
|
||||||
export type D3TooltipSettings<DataType> = BaseTooltip<DataType> & (AccurateTooltip | NearestTooltip)
|
export type D3TooltipSettings<DataType> = BaseTooltip<DataType> & (AccurateTooltip | NearestTooltip)
|
||||||
|
|
||||||
export type D3TooltipProps<DataType = Record<string, any>> = Partial<D3TooltipSettings<DataType>> & {
|
const defaultRender: D3RenderFunction<Record<string, any>> = (data, mouseState) => (
|
||||||
charts: any[],
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultRender = <DataType,>(data: DataType, target: any, mouseState: D3MouseState) => (
|
|
||||||
<>
|
<>
|
||||||
X: {mouseState.x} Y: {mouseState.y}
|
{data.length > 0 ? data.map(({ chart, data }) => {
|
||||||
<br/>
|
let Icon
|
||||||
Data: {JSON.stringify(data)}
|
switch (chart.type) {
|
||||||
|
case 'needle': Icon = BarChartOutlined; break
|
||||||
|
case 'line': Icon = LineChartOutlined; break
|
||||||
|
// case 'area': Icon = AreaChartOutlined; break
|
||||||
|
// case 'dot': Icon = DotChartOutLined; break
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'tooltip-group'} key={chart.key}>
|
||||||
|
<div className={'group-label'}>
|
||||||
|
<Icon style={{ color: chart.color }} />
|
||||||
|
<span>{chart.label}:</span>
|
||||||
|
</div>
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<span key={`${i}`}>
|
||||||
|
{formatDate(chart.x(d))} {chart.xAxis.unit} :: {chart.y(d).toFixed(2)} {chart.yAxis.unit}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}) : (
|
||||||
|
<span>Данных нет</span>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export type D3TooltipProps<DataType = DefaultDataType> = Partial<D3TooltipSettings<DataType>> & {
|
||||||
|
charts: ChartRegistry<DataType>[],
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = <DataType,>(
|
||||||
|
chart: ChartRegistry<DataType>,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
limit: number = 0,
|
||||||
|
touchType: D3TooltipTouchType = 'all'
|
||||||
|
): d3.Selection<any, DataType, any, any> => {
|
||||||
|
let nodes: d3.Selection<any, any, any, any>
|
||||||
|
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<D3TooltipProps>(function D3Tooltip({
|
export const D3Tooltip = wrapPlugin<D3TooltipProps>(function D3Tooltip({
|
||||||
type = 'accurate',
|
type = 'accurate',
|
||||||
width = 200,
|
width = 200,
|
||||||
height = 100,
|
height = 120,
|
||||||
render = defaultRender,
|
render = defaultRender,
|
||||||
charts,
|
charts,
|
||||||
position: _position = 'bottom',
|
position: _position = 'bottom',
|
||||||
className,
|
className,
|
||||||
style: _style = {},
|
style: _style = {},
|
||||||
|
touchType = 'all',
|
||||||
...other
|
...other
|
||||||
}) {
|
}) {
|
||||||
const { mouseState, zoneRect, subscribe } = useD3MouseZone()
|
const { mouseState, zoneRect, subscribe } = useD3MouseZone()
|
||||||
const [tooltipBody, setTooltipBody] = useState<any>()
|
const [tooltipBody, setTooltipBody] = useState<any>()
|
||||||
const [style, setStyle] = useState<CSSProperties>(_style)
|
const [style, setStyle] = useState<CSSProperties>(_style)
|
||||||
const [position, setPosition] = useState<D3TooltipPosition>(_position ?? 'bottom')
|
const [position, setPosition] = useState<D3TooltipPosition>(_position ?? 'bottom')
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
const [fixed, setFixed] = useState(false)
|
const [fixed, setFixed] = useState(false)
|
||||||
|
|
||||||
const tooltipRef = useRef<HTMLDivElement>(null)
|
const tooltipRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const onMiddleClick = useCallback((e: Event) => {
|
||||||
|
if ((e as MouseEvent).button === 1 && visible)
|
||||||
|
setFixed((prev) => !prev)
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = subscribe('auxclick', (e) => {
|
const unsubscribe = subscribe('auxclick', onMiddleClick)
|
||||||
if ((e as MouseEvent).button === 1)
|
|
||||||
setFixed((prev) => !prev)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (unsubscribe)
|
if (unsubscribe)
|
||||||
if (!unsubscribe() && isDev())
|
if (!unsubscribe() && isDev())
|
||||||
console.warn('Не удалось отвязать эвент')
|
console.warn('Не удалось отвязать эвент')
|
||||||
}
|
}
|
||||||
}, [])
|
}, [onMiddleClick])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tooltipRef.current || !zoneRect || fixed) return
|
if (!tooltipRef.current || !zoneRect || fixed) return
|
||||||
@ -80,13 +172,12 @@ export const D3Tooltip = wrapPlugin<D3TooltipProps>(function D3Tooltip({
|
|||||||
...prev,
|
...prev,
|
||||||
left: -rect.width,
|
left: -rect.width,
|
||||||
top: -rect.height,
|
top: -rect.height,
|
||||||
opacity: 0,
|
|
||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const offsetX = -rect.width / 2 // По центру
|
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 left = Math.max(0, Math.min(zoneRect.width - rect.width, mouseState.x + offsetX))
|
||||||
const top = mouseState.y - offsetY - rect.height
|
const top = mouseState.y - offsetY - rect.height
|
||||||
@ -95,14 +186,31 @@ export const D3Tooltip = wrapPlugin<D3TooltipProps>(function D3Tooltip({
|
|||||||
...prev,
|
...prev,
|
||||||
left,
|
left,
|
||||||
top,
|
top,
|
||||||
opacity: 1
|
|
||||||
}))
|
}))
|
||||||
}, [tooltipRef.current, mouseState, zoneRect, fixed])
|
}, [tooltipRef.current, mouseState, zoneRect, fixed])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fixed) return
|
if (fixed) return
|
||||||
setTooltipBody(render({}, d3.select('.nothing'), mouseState))
|
if (!mouseState.visible)
|
||||||
}, [mouseState, charts, fixed])
|
return setVisible(false)
|
||||||
|
|
||||||
|
const data: D3RenderData<DefaultDataType> = []
|
||||||
|
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 (
|
return (
|
||||||
<foreignObject
|
<foreignObject
|
||||||
@ -110,8 +218,9 @@ export const D3Tooltip = wrapPlugin<D3TooltipProps>(function D3Tooltip({
|
|||||||
y={style.top}
|
y={style.top}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
opacity={style.opacity}
|
opacity={visible ? 1 : 0}
|
||||||
pointerEvents={fixed ? 'all' : 'none'}
|
pointerEvents={fixed ? 'all' : 'none'}
|
||||||
|
style={{ transition: 'opacity .1s ease-out' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
{...other}
|
{...other}
|
||||||
|
73
src/components/d3/types.ts
Normal file
73
src/components/d3/types.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { Property } from 'csstype'
|
||||||
|
|
||||||
|
import {
|
||||||
|
D3TooltipSettings
|
||||||
|
} from './plugins'
|
||||||
|
|
||||||
|
export type DefaultDataType = Record<string, any>
|
||||||
|
type Selection = d3.Selection<any, any, null, undefined>
|
||||||
|
|
||||||
|
export type ChartAxis<DataType> = {
|
||||||
|
type: 'linear' | 'time',
|
||||||
|
accessor: keyof DataType | ((d: DataType) => any)
|
||||||
|
unit?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BaseChartDataset<DataType> = {
|
||||||
|
key: string | number
|
||||||
|
label?: ReactNode
|
||||||
|
yAxis: ChartAxis<DataType>
|
||||||
|
xAxis: ChartAxis<DataType>
|
||||||
|
color?: Property.Color
|
||||||
|
opacity?: number
|
||||||
|
width?: Property.StrokeWidth
|
||||||
|
tooltip?: D3TooltipSettings<DataType>
|
||||||
|
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<DataType> = BaseChartDataset<DataType> & (
|
||||||
|
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<DataType = DefaultDataType> = ChartDataset<DataType> & {
|
||||||
|
(): Selection
|
||||||
|
y: (value: any) => number
|
||||||
|
x: (value: any) => number
|
||||||
|
}
|
@ -7,18 +7,8 @@ import '@styles/detected_operations.less'
|
|||||||
// Палитра: https://colorhunt.co/palette/f9f2ed3ab0ffffb562f87474
|
// Палитра: https://colorhunt.co/palette/f9f2ed3ab0ffffb562f87474
|
||||||
|
|
||||||
const chartDatasets = [{
|
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',
|
key: 'normBars',
|
||||||
|
label: 'Нормативные значения',
|
||||||
type: 'needle',
|
type: 'needle',
|
||||||
width: 2,
|
width: 2,
|
||||||
color: '#FFB562',
|
color: '#FFB562',
|
||||||
@ -29,6 +19,7 @@ const chartDatasets = [{
|
|||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
key: 'bars',
|
key: 'bars',
|
||||||
|
label: 'Действительные значения',
|
||||||
type: 'needle',
|
type: 'needle',
|
||||||
width: 2,
|
width: 2,
|
||||||
color: '#3AB0FF',
|
color: '#3AB0FF',
|
||||||
@ -39,6 +30,7 @@ const chartDatasets = [{
|
|||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
key: 'target',
|
key: 'target',
|
||||||
|
label: 'Целевые значения',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
color: '#F87474',
|
color: '#F87474',
|
||||||
yAxis: {
|
yAxis: {
|
||||||
@ -50,7 +42,7 @@ const chartDatasets = [{
|
|||||||
|
|
||||||
const xAxis = {
|
const xAxis = {
|
||||||
type: 'time',
|
type: 'time',
|
||||||
accessor: (row) => new Date(row.dateStart)
|
accessor: (row) => new Date(row.dateStart),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OperationsChart = memo(({ data, yDomain, height }) => {
|
export const OperationsChart = memo(({ data, yDomain, height }) => {
|
||||||
@ -77,12 +69,14 @@ export const OperationsChart = memo(({ data, yDomain, height }) => {
|
|||||||
plugins={{
|
plugins={{
|
||||||
tooltip: {
|
tooltip: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
type: 'nearest',
|
||||||
|
limit: 10,
|
||||||
},
|
},
|
||||||
cursor: {
|
cursor: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
enabled: true,
|
enabled: false,
|
||||||
onUpdate: onChartUpdate,
|
onUpdate: onChartUpdate,
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -8,6 +8,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100% - @arrow-size;
|
height: 100% - @arrow-size;
|
||||||
|
|
||||||
|
font-size: 13px;
|
||||||
color: @color;
|
color: @color;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
@ -31,6 +32,24 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
margin-left: -@arrow-size;
|
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 {
|
& .chart-empty {
|
||||||
|
Loading…
Reference in New Issue
Block a user