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 { 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 = , R>(accessor: keyof DataType | ((d: DataType) => R)): ((d: DataType) => R) => { if (typeof accessor === 'function') return accessor return (d) => d[accessor] } const createAxis = (config: ChartAxis) => { if (config.type === 'time') return d3.scaleTime() return d3.scaleLinear() } export type D3ChartProps = React.DetailedHTMLProps, HTMLDivElement> & { /** Параметры общей горизонтальной оси */ xAxis: ChartAxis /** Параметры графиков */ datasets: ChartDataset[] /** Массив отображаемых данных или объект с парами ключ графика-данные */ data?: DataType[] | Record /** Диапозон отображаемых значений */ domain?: Partial /** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */ width?: string | number /** Высота графика числом пикселей или CSS-значением (px/%/em/rem) */ height?: string | number /** Задать статус "заргужается" графику */ loading?: boolean /** Отступы графика от края SVG */ offset?: Partial /** Длительность анимации в миллисекундах */ animDurationMs?: number /** Цвет фона в формате CSS-значения */ backgroundColor?: Property.Color /** Настройки рисок и цен деления графика */ ticks?: ChartTicks /** Штриховка графика */ dash?: string | number | number[] /** Параметры плагинов */ plugins?: { /** Параметры контекстного меню */ menu?: BasePluginSettings & D3ContextMenuSettings /** Параметры всплывающей подсказки */ tooltip?: BasePluginSettings & D3TooltipSettings /** Параметры курсора */ cursor?: BasePluginSettings & D3CursorSettings /** Параметры блока легенды */ legend?: BasePluginSettings & D3LegendSettings } } const getDefaultXAxisConfig = (): ChartAxis => ({ type: 'time', accessor: (d: any) => new Date(d.date) }) const _D3Chart = >({ className = '', xAxis: _xAxisConfig, datasets, data, domain, width: givenWidth = '100%', height: givenHeight = '100%', loading, offset: _offset, animDurationMs = 200, backgroundColor = 'transparent', ticks, plugins, dash, ...other }: D3ChartProps) => { const xAxisConfig = usePartialProps>(_xAxisConfig, getDefaultXAxisConfig) 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 [charts, setCharts] = useState[]>([]) 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[] = [] 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('.' + getChartClass(dataset.key)) as d3.Selection, { 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(xAxis, yAxis, chart, chartData, height, offset) break case 'line': chartData = renderLine(xAxis, yAxis, chart, chartData) break case 'point': chartData = renderPoint(xAxis, yAxis, chart, chartData) break case 'area': chartData = renderArea(xAxis, yAxis, chart, chartData) break default: break } if (chart.point) renderPoint(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 (
{data ? ( charts={charts} {...plugins?.legend} /> charts={charts} {...plugins?.tooltip} /> ) : (
)}
) } export const D3Chart = memo(_D3Chart) as typeof _D3Chart export default D3Chart