diff --git a/src/components/d3/D3MonitoringCharts.tsx b/src/components/d3/D3MonitoringCharts.tsx new file mode 100644 index 0000000..ceacb05 --- /dev/null +++ b/src/components/d3/D3MonitoringCharts.tsx @@ -0,0 +1,465 @@ +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' + +type AxisScale = d3.ScaleTime | d3.ScaleLinear + +type ExtendedChartDataset = ChartDataset & { + yDomain: MinMax + hideXAxis?: boolean +} + +type ExtendedChartRegistry = ExtendedChartDataset & { + (): d3.Selection + scale: d3.ScaleLinear + 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 = 20 + +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]) + + 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 yDomain = { + min: 0, + max: 10, + ...dataset.yDomain, + } + + const scale = d3.scaleLinear() + .domain([yDomain.min, yDomain.max]) + + // Пересоздаём график + 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, + yDomain, + scale, + 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) { + 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(chart.scale.range([0, sizes.groupWidth])) + + if (j === chartsData.length - 1) { + axis = axis + .ticks(5) + .tickSize(-sizes.chartsHeight) + .tickFormat((d, i) => i === 0 || i === 5 ? String(d) : '') + .tickValues(getTicks(chart.yDomain, 5)) + } else { + axis = axis.ticks(1) + .tickValues(getTicks(chart.yDomain, 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]) + + 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) => { + 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 = chart.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(chart.scale, yAxis, chart, chartData, true) + + chart.afterDraw?.(chart) + }) + }) + }, [data, groups, groupScales, height, offset, sizes]) + + 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 diff --git a/src/components/d3/index.ts b/src/components/d3/index.ts index 4811ec3..8fd3688 100644 --- a/src/components/d3/index.ts +++ b/src/components/d3/index.ts @@ -1,4 +1,6 @@ export * from './D3Chart' export type { D3ChartProps } from './D3Chart' +export * from './D3MonitoringCharts' + export * from './types'