forked from ddrilling/asb_cloud_front
396 lines
14 KiB
TypeScript
396 lines
14 KiB
TypeScript
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 { isDev, usePartialProps } from '@utils'
|
||
|
||
import D3MouseZone from './D3MouseZone'
|
||
import { getChartClass } from './functions'
|
||
import {
|
||
renderArea,
|
||
renderLine,
|
||
renderPoint,
|
||
renderNeedle
|
||
} from './renders'
|
||
import {
|
||
BasePluginSettings,
|
||
D3ContextMenu,
|
||
D3ContextMenuSettings,
|
||
D3Cursor,
|
||
D3CursorSettings,
|
||
D3Legend,
|
||
D3LegendSettings,
|
||
D3Tooltip,
|
||
D3TooltipSettings,
|
||
} from './plugins'
|
||
import type {
|
||
BaseDataType,
|
||
ChartAxis,
|
||
ChartDataset,
|
||
ChartDomain,
|
||
ChartOffset,
|
||
ChartRegistry,
|
||
ChartTicks
|
||
} from './types'
|
||
|
||
import '@styles/d3.less'
|
||
|
||
const defaultOffsets: ChartOffset = {
|
||
top: 10,
|
||
bottom: 30,
|
||
left: 50,
|
||
right: 10,
|
||
}
|
||
|
||
export const getByAccessor = <DataType extends Record<any, any>, R>(accessor: keyof DataType | ((d: DataType) => R)): ((d: DataType) => R) => {
|
||
if (typeof accessor === 'function')
|
||
return accessor
|
||
return (d) => d[accessor]
|
||
}
|
||
|
||
const createAxis = <DataType extends BaseDataType>(config: ChartAxis<DataType>) => {
|
||
if (config.type === 'time')
|
||
return d3.scaleTime()
|
||
return d3.scaleLinear()
|
||
}
|
||
|
||
export type D3ChartProps<DataType extends BaseDataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||
/** Параметры общей горизонтальной оси */
|
||
xAxis: ChartAxis<DataType>
|
||
/** Параметры графиков */
|
||
datasets: ChartDataset<DataType>[]
|
||
/** Массив отображаемых данных или объект с парами ключ графика-данные */
|
||
data?: DataType[] | Record<string, DataType[]>
|
||
/** Диапозон отображаемых значений */
|
||
domain?: Partial<ChartDomain>
|
||
/** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */
|
||
width?: string | number
|
||
/** Высота графика числом пикселей или CSS-значением (px/%/em/rem) */
|
||
height?: string | number
|
||
/** Задать статус "заргужается" графику */
|
||
loading?: boolean
|
||
/** Отступы графика от края SVG */
|
||
offset?: Partial<ChartOffset>
|
||
/** Длительность анимации в миллисекундах */
|
||
animDurationMs?: number
|
||
/** Цвет фона в формате CSS-значения */
|
||
backgroundColor?: Property.Color
|
||
/** Настройки рисок и цен деления графика */
|
||
ticks?: ChartTicks<DataType>
|
||
/** Штриховка графика */
|
||
dash?: string | number | number[]
|
||
/** Параметры плагинов */
|
||
plugins?: {
|
||
/** Параметры контекстного меню */
|
||
menu?: BasePluginSettings & D3ContextMenuSettings
|
||
/** Параметры всплывающей подсказки */
|
||
tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
|
||
/** Параметры курсора */
|
||
cursor?: BasePluginSettings & D3CursorSettings
|
||
/** Параметры блока легенды */
|
||
legend?: BasePluginSettings & D3LegendSettings
|
||
}
|
||
}
|
||
|
||
const getDefaultXAxisConfig = <DataType extends BaseDataType>(): ChartAxis<DataType> => ({
|
||
type: 'time',
|
||
accessor: (d: any) => new Date(d.date)
|
||
})
|
||
|
||
const _D3Chart = <DataType extends Record<string, unknown>>({
|
||
className = '',
|
||
xAxis: _xAxisConfig,
|
||
datasets,
|
||
data,
|
||
domain,
|
||
width: givenWidth = '100%',
|
||
height: givenHeight = '100%',
|
||
loading,
|
||
offset: _offset,
|
||
animDurationMs = 200,
|
||
backgroundColor = 'transparent',
|
||
ticks,
|
||
plugins,
|
||
dash,
|
||
...other
|
||
}: D3ChartProps<DataType>) => {
|
||
const xAxisConfig = usePartialProps<ChartAxis<DataType>>(_xAxisConfig, getDefaultXAxisConfig)
|
||
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 [charts, setCharts] = useState<ChartRegistry<DataType>[]>([])
|
||
|
||
const [rootRef, { width, height }] = useElementSize()
|
||
|
||
const xAxis = useMemo(() => {
|
||
if (!data) return
|
||
|
||
const getX = getByAccessor(xAxisConfig.accessor)
|
||
|
||
const xAxis = createAxis(xAxisConfig)
|
||
xAxis.range([0, width - offset.left - offset.right])
|
||
|
||
if (domain?.x?.min && domain?.x?.max) {
|
||
xAxis.domain([domain.x.min, domain.x.max])
|
||
return xAxis
|
||
}
|
||
|
||
if (Array.isArray(data)) {
|
||
const [minX, maxX] = d3.extent(data, getX)
|
||
xAxis.domain([domain?.x?.min ?? minX, domain?.x?.max ?? maxX])
|
||
} else {
|
||
let [minX, maxX] = [Infinity, -Infinity]
|
||
for (const key in data) {
|
||
const [min, max] = d3.extent(data[key], getX)
|
||
if (min < minX) minX = min
|
||
if (max > maxX) maxX = max
|
||
}
|
||
xAxis.domain([domain?.x?.min ?? minX, domain?.x?.max ?? maxX])
|
||
}
|
||
|
||
return xAxis
|
||
}, [xAxisConfig, 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
|
||
if (Array.isArray(data)) {
|
||
charts.forEach(({ y }) => {
|
||
const [min, max] = d3.extent(data, y)
|
||
if (min && min < minY) minY = min
|
||
if (max && max > maxY) maxY = max
|
||
})
|
||
} else {
|
||
for (const key in data) {
|
||
const chart = charts.find((chart) => chart.key === key)
|
||
if (!chart) continue
|
||
const [min, max] = d3.extent(data[key], chart.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])
|
||
|
||
const nTicks = {
|
||
color: 'lightgray',
|
||
...ticks,
|
||
x: {
|
||
visible: false,
|
||
format: (d: any) => String(d),
|
||
count: 10,
|
||
...ticks?.x,
|
||
}
|
||
}
|
||
|
||
useEffect(() => { // Рисуем ось X
|
||
if (!xAxis) return
|
||
xAxisArea().transition()
|
||
.duration(animDurationMs)
|
||
.call(d3.axisBottom(xAxis)
|
||
.tickSize(nTicks.x.visible ? -height + offset.bottom : 0)
|
||
.tickFormat((d, i) => nTicks.x.format(d, i))
|
||
.ticks(nTicks.x.count) as any // TODO: Исправить тип
|
||
)
|
||
|
||
xAxisArea().selectAll('.tick line').attr('stroke', nTicks.x.color || nTicks.color)
|
||
}, [xAxisArea, xAxis, animDurationMs, height, offset, ticks])
|
||
|
||
useEffect(() => { // Рисуем ось Y
|
||
if (!yAxis) return
|
||
|
||
const nTicks = {
|
||
color: 'lightgray',
|
||
...ticks,
|
||
y: {
|
||
visible: false,
|
||
format: (d: any) => String(d),
|
||
count: 10,
|
||
...ticks?.y,
|
||
}
|
||
}
|
||
|
||
yAxisArea().transition()
|
||
.duration(animDurationMs)
|
||
.call(d3.axisLeft(yAxis)
|
||
.tickSize(nTicks.y.visible ? -width + offset.left + offset.right : 0)
|
||
.tickFormat((d, i) => nTicks.y.format(d, i))
|
||
.ticks(nTicks.y.count) as any // TODO: Исправить тип
|
||
)
|
||
|
||
yAxisArea().selectAll('.tick line').attr('stroke', nTicks.y.color || nTicks.color)
|
||
}, [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<DataType>[] = []
|
||
|
||
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<DataType> = Object.assign(
|
||
() => chartArea().select('.' + getChartClass(dataset.key)) as d3.Selection<SVGGElement, DataType, any, unknown>,
|
||
{
|
||
width: 1,
|
||
opacity: 1,
|
||
label: dataset.key,
|
||
color: 'gray',
|
||
animDurationMs,
|
||
...dataset,
|
||
xAxis: dataset.xAxis ?? xAxisConfig,
|
||
y: getByAccessor(dataset.yAxis.accessor),
|
||
x: getByAccessor(dataset.xAxis?.accessor ?? xAxisConfig.accessor),
|
||
}
|
||
)
|
||
|
||
if (newChart.type === 'line')
|
||
newChart.optimization = false
|
||
|
||
if (!newChart().node())
|
||
chartArea()
|
||
.append('g')
|
||
.attr('class', getChartClass(newChart.key))
|
||
|
||
charts[chartIdx] = newChart
|
||
})
|
||
|
||
return charts
|
||
})
|
||
}, [xAxisConfig, chartArea, datasets, animDurationMs])
|
||
|
||
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 ?? null)
|
||
.attr('opacity', chart.opacity ?? null)
|
||
.attr('fill', 'none')
|
||
|
||
let chartData = Array.isArray(data) ? data : data[String(chart.key).split(':')[0]]
|
||
if (!chartData) return
|
||
|
||
switch (chart.type) {
|
||
case 'needle':
|
||
chartData = renderNeedle<DataType>(xAxis, yAxis, chart, chartData, height, offset)
|
||
break
|
||
case 'line':
|
||
chartData = renderLine<DataType>(xAxis, yAxis, chart, chartData)
|
||
break
|
||
case 'point':
|
||
chartData = renderPoint<DataType>(xAxis, yAxis, chart, chartData)
|
||
break
|
||
case 'area':
|
||
chartData = renderArea<DataType>(xAxis, yAxis, chart, chartData)
|
||
break
|
||
default:
|
||
break
|
||
}
|
||
|
||
if (chart.point)
|
||
renderPoint<DataType>(xAxis, yAxis, chart, chartData, true)
|
||
|
||
if (dash) chart().attr('stroke-dasharray', dash)
|
||
|
||
chart.afterDraw?.(chart)
|
||
})
|
||
}, [charts, data, xAxis, yAxis, height, offset])
|
||
|
||
useEffect(() => {
|
||
redrawCharts()
|
||
}, [redrawCharts])
|
||
|
||
return (
|
||
<LoaderPortal
|
||
show={loading}
|
||
style={{
|
||
width: givenWidth,
|
||
height: givenHeight,
|
||
}}
|
||
>
|
||
<div
|
||
{...other}
|
||
ref={rootRef}
|
||
className={`asb-d3-chart ${className}`}
|
||
>
|
||
{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={Math.max(width - offset.left - offset.right, 0)}
|
||
height={Math.max(height - offset.top - offset.bottom, 0)}
|
||
fill={backgroundColor}
|
||
/>
|
||
</g>
|
||
<D3MouseZone width={width} height={height} offset={offset}>
|
||
<D3Cursor {...plugins?.cursor} />
|
||
<D3Legend<DataType> charts={charts} {...plugins?.legend} />
|
||
<D3Tooltip<DataType> charts={charts} {...plugins?.tooltip} />
|
||
</D3MouseZone>
|
||
</svg>
|
||
</D3ContextMenu>
|
||
) : (
|
||
<div className={'chart-empty'}>
|
||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</LoaderPortal>
|
||
)
|
||
}
|
||
|
||
export const D3Chart = memo(_D3Chart) as typeof _D3Chart
|
||
|
||
export default D3Chart
|