asb_cloud_front/src/components/d3/D3Chart.tsx

349 lines
12 KiB
TypeScript
Raw Normal View History

import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useElementSize } from 'usehooks-ts'
import { Property } from 'csstype'
import { Empty } from 'antd'
import * as d3 from 'd3'
import LoaderPortal from '@components/LoaderPortal'
import { formatDate, isDev, makePointsOptimizator, usePartialProps } from '@utils'
import D3MouseZone from './D3MouseZone'
import {
BasePluginSettings,
D3ContextMenu,
D3ContextMenuSettings,
D3Cursor,
D3CursorSettings,
D3Tooltip,
D3TooltipSettings,
} from './plugins'
import '@styles/d3.less'
import { ChartAxis, ChartDataset, ChartDomain, ChartOffset, ChartRegistry, ChartTicks, DefaultDataType } from './types'
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 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 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,
data,
domain,
width: givenWidth = '100%',
height: givenHeight = '100%',
loading,
offset: _offset,
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<DefaultDataType>[]>([])
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(() => {
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})!`)
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,
xAxis: dataset.xAxis ?? xAxisConfig,
y: getByAccessor(dataset.yAxis.accessor),
x: getByAccessor(dataset.xAxis?.accessor ?? xAxisConfig.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('color', chart.color ?? null)
.attr('stroke', 'currentColor')
.attr('stroke-width', chart.width ?? 1)
.attr('opacity', chart.opacity ?? 1)
.attr('fill', 'none')
let d = data
let elms
switch (chart.type) {
case 'needle':
elms = chart()
.selectAll('line')
.data(d)
elms.exit().remove()
elms.enter().append('line')
elms = chart()
.selectAll('line')
.transition()
.duration(chart.animDurationMs ?? animDurationMs)
.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(chart.x(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')
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:
break
}
chart.afterDraw?.(elms)
})
}, [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} /> )}
{(plugins?.tooltip?.enabled ?? true) && ( <D3Tooltip {...plugins?.tooltip} charts={charts} /> )}
</D3MouseZone>
</svg>
</D3ContextMenu>
) : (
<div className={'chart-empty'}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
)}
</div>
</LoaderPortal>
)
})
export default D3Chart