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 { ChartAxis, ChartDataset, ChartOffset, ChartRegistry, ChartTick, MinMax } from './types' import { BasePluginSettings, D3ContextMenu, D3ContextMenuSettings, D3HorizontalCursor, D3HorizontalCursorSettings, D3TooltipSettings } from './plugins' import D3MouseZone from './D3MouseZone' import { getByAccessor, getChartClass, getGroupClass, getTicks } from './functions' import { renderArea, renderLine, renderNeedle, renderPoint } from './renders' const roundTo = (v: number, to: number = 50) => { if (to == 0) return v if (v < 0) return Math.round(v / to) * to return Math.ceil(v / to) * to } const calculateDomain = (mm: MinMax, round: number = 100): Required => { let min = roundTo(mm.min ?? 0, round) let max = roundTo(mm.max ?? round, round) if (min - max < round) { const mid = (min + max) / 2 min = mid - round max = mid + round } return { min, max } } type AxisScale = d3.ScaleTime | d3.ScaleLinear type ExtendedChartDataset = ChartDataset & { xDomain: MinMax hideXAxis?: boolean } type ExtendedChartRegistry = ExtendedChartDataset & { (): d3.Selection y: (value: any) => number x: (value: any) => number } export type ChartGroup = { (): d3.Selection key: number charts: ExtendedChartRegistry[] } const defaultOffsets: ChartOffset = { top: 10, bottom: 10, left: 100, right: 10, } const getDefaultYAxisConfig = (): ChartAxis => ({ type: 'time', accessor: (d: any) => new Date(d.date) }) const getDefaultYTicks = (): Required> => ({ visible: false, format: (d: d3.NumberValue, idx: number, data?: DataType) => String(d), color: 'lightgray', count: 10, }) const findChartsByKey = (groups: ChartGroup[], key: string) => { const out: ChartRegistry[] = [] groups.forEach((group) => { const res = group.charts.find((chart) => chart.key === key) if (res) out.push(res) }) return out } export type D3MonitoringChartsProps = React.DetailedHTMLProps, HTMLDivElement> & { datasetGroups: ExtendedChartDataset[][] width?: string | number height?: string | number animDurationMs?: number loading?: boolean data?: DataType[] offset?: Partial backgroundColor?: Property.Color yAxis?: ChartAxis plugins?: { cursor?: BasePluginSettings & D3HorizontalCursorSettings menu?: BasePluginSettings & D3ContextMenuSettings tooltip?: BasePluginSettings & D3TooltipSettings } yTicks?: ChartTick yDomain?: { min?: number max?: number } onWheel: (e: WheelEvent) => void } export type ChartSizes = ChartOffset & { inlineWidth: number inlineHeight: number groupWidth: number axesHeight: number chartsTop: number chartsHeight: number groupLeft: (i: number) => number axisTop: (i: number, count: number) => number } const axisHeight = 20 const space = 30 const _D3MonitoringCharts = >({ width: givenWidth = '100%', height: givenHeight = '100%', animDurationMs = 0, loading = false, datasetGroups, data, plugins, offset: _offset, yAxis: _yAxisConfig, backgroundColor = 'transparent', yDomain, yTicks: _yTicks, className = '', ...other }: D3MonitoringChartsProps) => { const [groups, setGroups] = useState[]>([]) const [groupScales, setGroupScales] = useState[]>([]) const [svgRef, setSvgRef] = useState(null) const [yAxisRef, setYAxisRef] = useState(null) const [chartAreaRef, setChartAreaRef] = useState(null) const [axesAreaRef, setAxesAreaRef] = useState(null) const offset = usePartialProps(_offset, defaultOffsets) const yTicks = usePartialProps>>(_yTicks, getDefaultYTicks) const yAxisConfig = usePartialProps>(_yAxisConfig, getDefaultYAxisConfig) const [rootRef, { width, height }] = useElementSize() const yAxisArea = useCallback(() => d3.select(yAxisRef), [yAxisRef]) const axesArea = useCallback(() => d3.select(axesAreaRef), [axesAreaRef]) const chartArea = useCallback(() => d3.select(chartAreaRef), [chartAreaRef]) const sizes: ChartSizes = useMemo(() => { const inlineWidth = Math.max(width - offset.left - offset.right, 0) const inlineHeight = Math.max(height - offset.top - offset.bottom, 0) const groupsCount = groups.length const groupWidth = groupsCount ? (inlineWidth - space * (groupsCount - 1)) / groupsCount : 0 let maxChartCount = Math.max(...groups.map((group) => group.charts.length)) if (!Number.isFinite(maxChartCount)) maxChartCount = 0 const axesHeight = (axisHeight * maxChartCount) return ({ ...offset, inlineWidth, inlineHeight, groupWidth, axesHeight, chartsTop: offset.top + axesHeight, chartsHeight: inlineHeight - axesHeight, groupLeft: (i: number) => (groupWidth + space) * i, axisTop: (i: number, count: number) => axisHeight * (maxChartCount - count + i + 1) }) }, [groups, height, offset]) const yAxis = useMemo(() => { if (!data) return const yAxis = d3.scaleTime() .domain([yDomain?.min ?? 0, yDomain?.max ?? 0]) .range([0, sizes.chartsHeight]) return yAxis }, [groups, data, yDomain, sizes.chartsHeight]) const createAxesGroup = useCallback((i: number): ChartGroup => Object.assign( () => chartArea().select('.' + getGroupClass(i)) as d3.Selection, { key: i, charts: [], } ), [chartArea, axesArea]) const chartDomains: Record, domain: Required, }>[] = useMemo(() => { return groups.map((group) => { const out = group.charts.map((chart) => { const mm = { ...chart.xDomain } let domain: Required = { min: 0, max: 100 } if (mm.min && mm.max) { domain = mm as Required } else if (data) { const [min, max] = d3.extent(data, chart.x) domain = calculateDomain({ min, max, ...mm }, 100) } return [chart.key, { scale: d3.scaleLinear().domain([domain.min, domain.max]), domain, }] }) return Object.fromEntries(out) }) }, [groups, data]) console.log(chartDomains) useEffect(() => { if (isDev()) { datasetGroups.forEach((sets, i) => { sets.forEach((set, j) => { for (let k = j + 1; k < sets.length; k++) { if (set.key === sets[k].key) console.warn(`Ключ датасета "${set.key}" неуникален (группа ${i}, индексы ${j} и ${k})!`) } }) }) } setGroups((oldGroups) => { const groups: ChartGroup[] = [] if (datasetGroups.length < oldGroups.length) { // Удаляем неактуальные группы oldGroups.slice(datasetGroups.length).forEach((group) => group().remove()) groups.push(...oldGroups.slice(0, datasetGroups.length)) } else { groups.push(...oldGroups) } datasetGroups.forEach((datasets, i) => { let group: ChartGroup = createAxesGroup(i) if (group().empty()) chartArea().append('g') .attr('class', `chart-group ${getGroupClass(i)}`) datasets.forEach((dataset) => { // Обновляем и добавляем новые чарты let chartIdx = group.charts.findIndex(({ key }) => key === dataset.key) if (chartIdx < 0) { chartIdx = group.charts.length } else { // Если типы графиков не сходятся удалить старые элементы if (group.charts[chartIdx].type !== dataset.type) group.charts[chartIdx]().selectAll('*').remove() } // Пересоздаём график const newChart: ExtendedChartRegistry = Object.assign( () => group().select('.' + getChartClass(dataset.key)) as d3.Selection, { width: 1, opacity: 1, label: dataset.key, color: 'gray', animDurationMs, ...dataset, yAxis: dataset.yAxis ?? yAxisConfig, y: getByAccessor(dataset.yAxis.accessor ?? yAxisConfig.accessor), x: getByAccessor(dataset.xAxis?.accessor), } ) if (newChart.type === 'line') newChart.optimization = false // Если у графика нет группы создаём её if (newChart().empty()) group().append('g') .attr('class', `chart ${getChartClass(newChart.key)}`) group.charts[chartIdx] = newChart }) groups[i] = group }) return groups }) }, [yAxisConfig, chartArea, datasetGroups, animDurationMs, createAxesGroup]) useEffect(() => { const axesGroups = d3.select(axesAreaRef) .selectAll('.charts-group') .data(groups) axesGroups.exit().remove() axesGroups.enter() .append('g') .attr('class', 'charts-group') const actualAxesGroups = d3.select(axesAreaRef) .selectAll>('.charts-group') .attr('class', (g) => `charts-group ${getGroupClass(g.key)}`) .attr('transform', (g) => `translate(${sizes.groupLeft(g.key)}, 0)`) actualAxesGroups.each(function(group, i) { const groupAxes = d3.select(this) const chartsData = group.charts.filter((chart) => !chart.hideXAxis) const charts = groupAxes.selectChildren().data(chartsData) charts.exit().remove() charts.enter().append('g') .attr('class', (d) => `chart ${getChartClass(d.key)}`) .attr('transform', (_, j) => `translate(0, ${sizes.axisTop(j, chartsData.length)})`) const actualCharts = groupAxes.selectChildren>() .style('color', (d) => d.color ?? null) actualCharts.each(function (chart, j) { let axis = d3.axisTop(chartDomains[i][chart.key].scale.range([0, sizes.groupWidth])) const domain = chartDomains[i][chart.key].domain if (j === chartsData.length - 1) { axis = axis .ticks(5) .tickSize(-sizes.chartsHeight) .tickFormat((d, i) => i === 0 || i === 5 ? String(d) : '') .tickValues(getTicks(domain, 5)) } else { axis = axis.ticks(1) .tickValues(getTicks(domain, 1)) } d3.select(this).call(axis as any) }) if (actualCharts.selectChild('text').empty()) actualCharts.append('text') actualCharts.selectChild('text') .attr('fill', 'currentColor') .style('text-anchor', 'middle') .style('dominant-baseline', 'middle') .attr('x', sizes.groupWidth / 2) .attr('y', -axisHeight / 2) .text((d) => String(d.label) ?? d.key) actualCharts.each(function (_, j) { d3.select(this) .selectAll('.tick line') .attr('stroke', j === chartsData.length - 1 ? 'gray' : 'currentColor') }) }) }, [groups, groupScales, sizes, space, chartDomains]) useEffect(() => { // Рисуем ось Y if (!yAxis) return const getX = getByAccessor(yAxisConfig.accessor) yAxisArea().transition() .duration(animDurationMs) .call(d3.axisLeft(yAxis) .tickFormat((d, i) => { let rowData if (data) rowData = data.find((row) => getX(row) === d) return yTicks.format(d, i, rowData) }) .tickSize(yTicks.visible ? -width + offset.left + offset.right : 0) .ticks(yTicks.count) as any // TODO: Исправить тип ) yAxisArea().selectAll('.tick line').attr('stroke', yTicks.color) }, [yAxisArea, yAxis, animDurationMs, width, offset, yTicks]) useEffect(() => { if (!data || !yAxis) return groups.forEach((group, i) => { group() .attr('transform', `translate(${sizes.groupLeft(group.key)}, 0)`) .attr('clip-path', `url(#chart-clip)`) group.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 = data if (!chartData) return const xAxis = chartDomains[i][chart.key].scale.range([0, sizes.groupWidth]) 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) chart.afterDraw?.(chart) }) }) }, [data, groups, groupScales, height, offset, sizes, chartDomains]) return (
{data ? ( {/* Сдвиг во все стороны на 1 px чтобы линии на краях было видно */} {d3.range(1, groups.length).map((i) => { const x = offset.left + (sizes.groupWidth + space) * i - space / 2 return })} ) : (
)}
) } export const D3MonitoringCharts = memo(_D3MonitoringCharts) export default D3MonitoringCharts