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

* Закончена работа с тултипами (кроме 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 { 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<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
}
import { ChartAxis, ChartDataset, ChartDomain, ChartOffset, ChartRegistry, ChartTicks, DefaultDataType } from './types'
const defaultOffsets: ChartOffset = {
top: 10,
@ -125,10 +33,6 @@ const defaultXAxisConfig: ChartAxis<DefaultDataType> = {
accessor: (d: any) => new Date(d.date)
}
const defaultCursorPlugin: BasePluginSettings & D3CursorSettings = {
enabled: true,
}
const getGroupClass = (key: string | number) => `chart-id-${key}`
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()
}
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 = '',
xAxis: _xAxisConfig,
datasets,
@ -153,7 +76,6 @@ export const D3Chart = memo<D3ChartProps>(({
height: givenHeight = '100%',
loading,
offset: _offset,
mode = 'horizontal',
animDurationMs = 200,
backgroundColor = 'transparent',
ticks,
@ -174,7 +96,7 @@ export const D3Chart = memo<D3ChartProps>(({
const getX = useMemo(() => getByAccessor(xAxisConfig.accessor), [xAxisConfig.accessor])
const [charts, setCharts] = useState<ChartRegistry[]>([])
const [charts, setCharts] = useState<ChartRegistry<DefaultDataType>[]>([])
const [rootRef, { width, height }] = useElementSize()
@ -272,7 +194,9 @@ export const D3Chart = memo<D3ChartProps>(({
() => 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<D3ChartProps>(({
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<D3ChartProps>(({
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<D3ChartProps>(({
.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<D3ChartProps>(({
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:

View File

@ -1,2 +1,4 @@
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 { 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<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> = {
render?: (data: DataType, target: d3.Selection<any, any, null, undefined>, mouseState: D3MouseState) => ReactNode
render?: D3RenderFunction<DataType>
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<DataType> = BaseTooltip<DataType> & (AccurateTooltip | NearestTooltip)
export type D3TooltipProps<DataType = Record<string, any>> = Partial<D3TooltipSettings<DataType>> & {
charts: any[],
const defaultRender: D3RenderFunction<Record<string, any>> = (data, mouseState) => (
<>
{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
}
const defaultRender = <DataType,>(data: DataType, target: any, mouseState: D3MouseState) => (
<>
X: {mouseState.x} Y: {mouseState.y}
<br/>
Data: {JSON.stringify(data)}
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({
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<any>()
const [style, setStyle] = useState<CSSProperties>(_style)
const [position, setPosition] = useState<D3TooltipPosition>(_position ?? 'bottom')
const [visible, setVisible] = useState(false)
const [fixed, setFixed] = useState(false)
const tooltipRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const unsubscribe = subscribe('auxclick', (e) => {
if ((e as MouseEvent).button === 1)
const onMiddleClick = useCallback((e: Event) => {
if ((e as MouseEvent).button === 1 && visible)
setFixed((prev) => !prev)
})
}, [visible])
useEffect(() => {
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<D3TooltipProps>(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<D3TooltipProps>(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<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 (
<foreignObject
@ -110,8 +218,9 @@ export const D3Tooltip = wrapPlugin<D3TooltipProps>(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' }}
>
<div
{...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
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,
}
}}

View File

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