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' import { formatDate, makePointsOptimizator, usePartialProps } from '@utils' import D3MouseZone from './D3MouseZone' import { BasePluginSettings, D3ContextMenu, D3ContextMenuSettings, D3Cursor, D3CursorSettings, D3Tooltip, D3TooltipSettings } from './plugins' import '@styles/d3.less' type DefaultDataType = Record export type ChartAxis = { type: 'linear' | 'time', accessor: keyof DataType | ((d: DataType) => any) } export type BaseChartDataset = { key: string | number label?: ReactNode yAxis: ChartAxis color?: Property.Color opacity?: number width?: Property.StrokeWidth tooltip?: D3TooltipSettings animDurationMs?: number afterDraw?: (d: any) => void } export type LineChartDataset = { type: 'line' point?: { radius?: number color?: Property.Color } nullValues?: 'skip' | 'gap' | 'none' optimization?: boolean } export type AreaChartDataset = { type: 'area' fillColor?: Property.Color point?: { radius?: number color?: Property.Color } } export type NeedleChartDataset = { type: 'needle' } export type ChartDataset = BaseChartDataset & ( AreaChartDataset | 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 D3ChartProps = React.DetailedHTMLProps, HTMLDivElement> & { xAxis: ChartAxis datasets: ChartDataset[] data?: DataType[] domain?: Partial width?: number | string height?: number | string loading?: boolean offset?: Partial mode: 'horizontal' | 'vertical' animDurationMs?: number backgroundColor?: Property.Color ticks?: ChartTicks plugins?: { menu?: BasePluginSettings & D3ContextMenuSettings tooltip?: BasePluginSettings & D3TooltipSettings cursor?: BasePluginSettings & D3CursorSettings } } type Selection = d3.Selection type ChartRegistry = ChartDataset & { (): Selection y: (value: any) => number } const defaultOffsets: ChartOffset = { top: 10, bottom: 30, left: 50, right: 10, } const defaultXAxisConfig: ChartAxis = { 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 = >(accessor: string | ((d: T) => any)) => { if (typeof accessor === 'function') return accessor return (d: T) => d[accessor] } const createAxis = (config: ChartAxis) => { if (config.type === 'time') return d3.scaleTime() return d3.scaleLinear() } export const D3Chart = memo(({ 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(null) const [xAxisRef, setXAxisRef] = useState(null) const [yAxisRef, setYAxisRef] = useState(null) const [chartAreaRef, setChartAreaRef] = useState(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([]) 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(() => { 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 switch (chart.type) { case 'needle': { const bars = chart() .selectAll('line') .data(data) bars.exit().remove() bars.enter().append('line') const newBars = chart() .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))) chart.afterDraw?.(newBars) 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') const lineElm = chart().selectAll('path') .transition() .duration(chart.animDurationMs ?? animDurationMs) .attr('d', line(d as any)) chart.afterDraw?.(lineElm) break } default: break } }) }, [charts, data, xAxis, yAxis, height]) useEffect(() => { redrawCharts() }, [redrawCharts]) return (
{data ? ( {(plugins?.tooltip?.enabled ?? true) && ( )} {(plugins?.cursor?.enabled ?? true) && ( )} ) : (
)}
) }) export default D3Chart