* Типы графиков перенесены в отдельный файл

* Закончена работа с тултипами (кроме nearest)
This commit is contained in:
Александр Сироткин 2022-06-27 05:53:16 +05:00
parent 5109d73013
commit 571d8de440
6 changed files with 291 additions and 147 deletions

View File

@ -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:

View File

@ -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'

View File

@ -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}

View 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
}

View File

@ -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,
} }
}} }}

View File

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