2022-06-25 16:03:08 +05:00
|
|
|
import { memo, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
|
import { useElementSize } from 'usehooks-ts'
|
|
|
|
import { Empty } from 'antd'
|
|
|
|
import { Property } from 'csstype'
|
|
|
|
import * as d3 from 'd3'
|
|
|
|
|
|
|
|
import LoaderPortal from '@components/LoaderPortal'
|
2022-06-26 17:16:12 +05:00
|
|
|
import { formatDate, isDev, makePointsOptimizator, usePartialProps } from '@utils'
|
2022-06-25 16:03:08 +05:00
|
|
|
|
|
|
|
import D3MouseZone from './D3MouseZone'
|
|
|
|
import {
|
|
|
|
BasePluginSettings,
|
|
|
|
D3ContextMenu,
|
|
|
|
D3ContextMenuSettings,
|
|
|
|
D3Cursor,
|
|
|
|
D3CursorSettings,
|
|
|
|
D3Tooltip,
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
const defaultOffsets: ChartOffset = {
|
|
|
|
top: 10,
|
|
|
|
bottom: 30,
|
|
|
|
left: 50,
|
|
|
|
right: 10,
|
|
|
|
}
|
|
|
|
|
|
|
|
const defaultXAxisConfig: ChartAxis<DefaultDataType> = {
|
|
|
|
type: 'time',
|
|
|
|
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)) => {
|
|
|
|
if (typeof accessor === 'function')
|
|
|
|
return accessor
|
|
|
|
return (d: T) => d[accessor]
|
|
|
|
}
|
|
|
|
|
|
|
|
const createAxis = <DataType,>(config: ChartAxis<DataType>) => {
|
|
|
|
if (config.type === 'time')
|
|
|
|
return d3.scaleTime()
|
|
|
|
return d3.scaleLinear()
|
|
|
|
}
|
|
|
|
|
|
|
|
export const D3Chart = memo<D3ChartProps>(({
|
|
|
|
className = '',
|
|
|
|
xAxis: _xAxisConfig,
|
|
|
|
datasets,
|
|
|
|
data,
|
|
|
|
domain,
|
|
|
|
width: givenWidth = '100%',
|
|
|
|
height: givenHeight = '100%',
|
|
|
|
loading,
|
|
|
|
offset: _offset,
|
|
|
|
mode = 'horizontal',
|
|
|
|
animDurationMs = 200,
|
|
|
|
backgroundColor = 'transparent',
|
|
|
|
ticks,
|
|
|
|
plugins,
|
|
|
|
...other
|
|
|
|
}) => {
|
|
|
|
const xAxisConfig = usePartialProps(_xAxisConfig, defaultXAxisConfig)
|
|
|
|
const offset = usePartialProps(_offset, defaultOffsets)
|
|
|
|
|
|
|
|
const [svgRef, setSvgRef] = useState<SVGSVGElement | null>(null)
|
|
|
|
const [xAxisRef, setXAxisRef] = useState<SVGGElement | null>(null)
|
|
|
|
const [yAxisRef, setYAxisRef] = useState<SVGGElement | null>(null)
|
|
|
|
const [chartAreaRef, setChartAreaRef] = useState<SVGGElement | null>(null)
|
|
|
|
|
|
|
|
const xAxisArea = useCallback(() => d3.select(xAxisRef), [xAxisRef])
|
|
|
|
const yAxisArea = useCallback(() => d3.select(yAxisRef), [yAxisRef])
|
|
|
|
const chartArea = useCallback(() => d3.select(chartAreaRef), [chartAreaRef])
|
|
|
|
|
|
|
|
const getX = useMemo(() => getByAccessor(xAxisConfig.accessor), [xAxisConfig.accessor])
|
|
|
|
|
|
|
|
const [charts, setCharts] = useState<ChartRegistry[]>([])
|
|
|
|
|
|
|
|
const [rootRef, { width, height }] = useElementSize()
|
|
|
|
|
|
|
|
const xAxis = useMemo(() => {
|
|
|
|
if (!data) return
|
|
|
|
|
|
|
|
const xAxis = createAxis(xAxisConfig)
|
|
|
|
|
|
|
|
const [minX, maxX] = d3.extent(data, getX)
|
|
|
|
xAxis.domain([domain?.x?.min ?? minX, domain?.x?.max ?? maxX])
|
|
|
|
xAxis.range([0, width - offset.left - offset.right])
|
|
|
|
|
|
|
|
return xAxis
|
|
|
|
}, [xAxisConfig, getX, data, domain, width, offset])
|
|
|
|
|
|
|
|
const yAxis = useMemo(() => {
|
|
|
|
if (!data) return
|
|
|
|
|
|
|
|
const yAxis = d3.scaleLinear()
|
|
|
|
|
|
|
|
if (domain?.y) {
|
|
|
|
const { min, max } = domain.y
|
|
|
|
if (min && max && Number.isFinite(min + max)) {
|
|
|
|
yAxis.domain([min, max])
|
|
|
|
return yAxis
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let minY = Infinity
|
|
|
|
let maxY = -Infinity
|
|
|
|
charts.forEach(({ y }) => {
|
|
|
|
const [min, max] = d3.extent(data, y)
|
|
|
|
if (min && min < minY) minY = min
|
|
|
|
if (max && max > maxY) maxY = max
|
|
|
|
})
|
|
|
|
|
|
|
|
yAxis.domain([
|
|
|
|
domain?.y?.min ?? minY,
|
|
|
|
domain?.y?.max ?? maxY,
|
|
|
|
])
|
|
|
|
|
|
|
|
yAxis.range([height - offset.top - offset.bottom, 0])
|
|
|
|
|
|
|
|
return yAxis
|
|
|
|
}, [charts, data, domain, height, offset])
|
|
|
|
|
|
|
|
useEffect(() => { // Рисуем ось X
|
|
|
|
if (!xAxis) return
|
|
|
|
xAxisArea().transition()
|
|
|
|
.duration(animDurationMs)
|
|
|
|
.call(d3.axisBottom(xAxis)
|
|
|
|
.tickSize((ticks?.x?.visible ?? false) ? -height + offset.bottom : 0)
|
|
|
|
.tickFormat((d) => formatDate(d, undefined, 'YYYY-MM-DD') || 'NaN')
|
|
|
|
.ticks(ticks?.x?.count ?? 10) as any // TODO: Исправить тип
|
|
|
|
)
|
|
|
|
|
|
|
|
xAxisArea().selectAll('.tick line').attr('stroke', ticks?.color || 'lightgray')
|
|
|
|
}, [xAxisArea, xAxis, animDurationMs, height, offset, ticks])
|
|
|
|
|
|
|
|
useEffect(() => { // Рисуем ось Y
|
|
|
|
if (!yAxis) return
|
|
|
|
yAxisArea().transition()
|
|
|
|
.duration(animDurationMs)
|
|
|
|
.call(d3.axisLeft(yAxis)
|
|
|
|
.tickSize((ticks?.y?.visible ?? false) ? -width + offset.left + offset.right : 0)
|
|
|
|
.ticks(ticks?.y?.count ?? 10) as any // TODO: Исправить тип
|
|
|
|
)
|
|
|
|
|
|
|
|
yAxisArea().selectAll('.tick line').attr('stroke', ticks?.color || 'lightgray')
|
|
|
|
}, [yAxisArea, yAxis, animDurationMs, width, offset, ticks])
|
|
|
|
|
|
|
|
useEffect(() => {
|
2022-06-26 17:16:12 +05:00
|
|
|
if (isDev())
|
|
|
|
for (let i = 0; i < datasets.length - 1; i++)
|
|
|
|
for (let j = i + 1; j < datasets.length; j++)
|
|
|
|
if (datasets[i].key === datasets[j].key)
|
|
|
|
console.warn(`Ключ датасета "${datasets[i].key}" неуникален (индексы ${i} и ${j})!`)
|
|
|
|
|
2022-06-25 16:03:08 +05:00
|
|
|
setCharts((oldCharts) => {
|
|
|
|
const charts: ChartRegistry[] = []
|
|
|
|
|
|
|
|
for (const chart of oldCharts) { // Удаляем ненужные графики
|
|
|
|
if (datasets.find(({ key }) => key === chart.key))
|
|
|
|
charts.push(chart)
|
|
|
|
else
|
|
|
|
chart().remove()
|
|
|
|
}
|
|
|
|
|
|
|
|
datasets.forEach((dataset) => { // Добавляем новые
|
|
|
|
let chartIdx = charts.findIndex(({ key }) => key === dataset.key)
|
|
|
|
if (chartIdx < 0)
|
|
|
|
chartIdx = charts.length
|
|
|
|
|
|
|
|
const newChart: ChartRegistry = Object.assign(
|
|
|
|
() => chartArea().select('.' + getGroupClass(dataset.key)),
|
|
|
|
{
|
|
|
|
...dataset,
|
|
|
|
y: getByAccessor(dataset.yAxis.accessor)
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
if (!newChart().node())
|
|
|
|
chartArea()
|
|
|
|
.append('g')
|
|
|
|
.attr('class', getGroupClass(newChart.key))
|
|
|
|
|
|
|
|
charts[chartIdx] = newChart
|
|
|
|
})
|
|
|
|
|
|
|
|
return charts
|
|
|
|
})
|
|
|
|
}, [chartArea, datasets])
|
|
|
|
|
|
|
|
const redrawCharts = useCallback(() => {
|
|
|
|
if (!data || !xAxis || !yAxis) return
|
|
|
|
|
|
|
|
charts.forEach((chart) => {
|
|
|
|
chart()
|
|
|
|
.attr('stroke', String(chart.color))
|
|
|
|
.attr('stroke-width', chart.width ?? 1)
|
|
|
|
.attr('opacity', chart.opacity ?? 1)
|
|
|
|
.attr('fill', 'none')
|
|
|
|
|
|
|
|
let d = data
|
2022-06-26 17:16:12 +05:00
|
|
|
let elms
|
2022-06-25 16:03:08 +05:00
|
|
|
|
|
|
|
switch (chart.type) {
|
2022-06-26 17:16:12 +05:00
|
|
|
case 'needle':
|
|
|
|
elms = chart()
|
2022-06-25 16:03:08 +05:00
|
|
|
.selectAll('line')
|
|
|
|
.data(data)
|
|
|
|
|
2022-06-26 17:16:12 +05:00
|
|
|
elms.exit().remove()
|
|
|
|
elms.enter().append('line')
|
2022-06-25 16:03:08 +05:00
|
|
|
|
2022-06-26 17:16:12 +05:00
|
|
|
elms = chart()
|
2022-06-25 16:03:08 +05:00
|
|
|
.selectAll('line')
|
|
|
|
.transition()
|
|
|
|
.duration(chart.animDurationMs ?? animDurationMs)
|
|
|
|
.attr('x1', (d: any) => xAxis(getX(d)))
|
|
|
|
.attr('x2', (d: any) => xAxis(getX(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)))
|
|
|
|
.y(d => yAxis(chart.y(d)))
|
|
|
|
|
|
|
|
switch (chart.nullValues || 'skip') {
|
|
|
|
case 'gap':
|
|
|
|
line = line.defined(d => (chart.y(d) ?? null) !== null && !Number.isNaN(chart.y(d)))
|
|
|
|
break
|
|
|
|
case 'skip':
|
|
|
|
d = d.filter(chart.y)
|
|
|
|
break
|
|
|
|
default:
|
|
|
|
break
|
|
|
|
}
|
|
|
|
|
|
|
|
if (chart.optimization ?? true) {
|
|
|
|
const optimize = makePointsOptimizator((a, b) => chart.y(a) === chart.y(b))
|
|
|
|
d = optimize(d)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (chart().selectAll('path').empty())
|
|
|
|
chart().append('path')
|
|
|
|
|
2022-06-26 17:16:12 +05:00
|
|
|
elms = chart().selectAll('path')
|
2022-06-25 16:03:08 +05:00
|
|
|
.transition()
|
|
|
|
.duration(chart.animDurationMs ?? animDurationMs)
|
|
|
|
.attr('d', line(d as any))
|
|
|
|
|
|
|
|
break
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
break
|
|
|
|
}
|
2022-06-26 17:16:12 +05:00
|
|
|
|
|
|
|
chart.afterDraw?.(elms)
|
2022-06-25 16:03:08 +05:00
|
|
|
})
|
|
|
|
}, [charts, data, xAxis, yAxis, height])
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
redrawCharts()
|
|
|
|
}, [redrawCharts])
|
|
|
|
|
|
|
|
return (
|
|
|
|
<LoaderPortal show={loading}>
|
|
|
|
<div
|
|
|
|
{...other}
|
|
|
|
ref={rootRef}
|
|
|
|
className={`asb-d3-chart ${className}`}
|
|
|
|
style={{
|
|
|
|
width: givenWidth,
|
|
|
|
height: givenHeight,
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{data ? (
|
|
|
|
<D3ContextMenu {...plugins?.menu} svg={svgRef}>
|
|
|
|
<svg ref={setSvgRef} width={'100%'} height={'100%'}>
|
|
|
|
<g ref={setXAxisRef} className={'axis x'} transform={`translate(${offset.left}, ${height - offset.bottom})`} />
|
|
|
|
<g ref={setYAxisRef} className={'axis y'} transform={`translate(${offset.left}, ${offset.top})`} />
|
|
|
|
<g ref={setChartAreaRef} className={'chart-area'} transform={`translate(${offset.left}, ${offset.top})`}>
|
|
|
|
<rect width={width - offset.left - offset.right} height={height - offset.top - offset.bottom} fill={backgroundColor} />
|
|
|
|
</g>
|
|
|
|
<D3MouseZone width={width} height={height} offset={offset}>
|
|
|
|
{(plugins?.cursor?.enabled ?? true) && ( <D3Cursor {...plugins?.cursor} /> )}
|
2022-06-26 17:16:12 +05:00
|
|
|
{(plugins?.tooltip?.enabled ?? true) && ( <D3Tooltip {...plugins?.tooltip} charts={charts} /> )}
|
2022-06-25 16:03:08 +05:00
|
|
|
</D3MouseZone>
|
|
|
|
</svg>
|
|
|
|
</D3ContextMenu>
|
|
|
|
) : (
|
|
|
|
<div className={'chart-empty'}>
|
|
|
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</LoaderPortal>
|
|
|
|
)
|
|
|
|
})
|
|
|
|
|
|
|
|
export default D3Chart
|