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 = { type: 'time', accessor: (d: any) => new Date(d.date) } 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 type D3ChartProps = React.DetailedHTMLProps, HTMLDivElement> & { xAxis: ChartAxis datasets: ChartDataset[] data?: DataType[] domain?: Partial width?: number | string height?: number | string loading?: boolean offset?: Partial animDurationMs?: number backgroundColor?: Property.Color ticks?: ChartTicks plugins?: { menu?: BasePluginSettings & D3ContextMenuSettings tooltip?: BasePluginSettings & D3TooltipSettings cursor?: BasePluginSettings & D3CursorSettings } } export const D3Chart = memo>(({ 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(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(() => { 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 (
{data ? ( {(plugins?.cursor?.enabled ?? true) && ( )} {(plugins?.tooltip?.enabled ?? true) && ( )} ) : (
)}
) }) export default D3Chart