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 { invokeWebApiWrapperAsync } from '@components/factory' import { isDev, usePartialProps } from '@utils' import { UserSettingsService } from '@api' import { ChartAxis, ChartDataset, ChartOffset, ChartRegistry, ChartTick, MinMax } from '@components/d3/types' import { BasePluginSettings, D3ContextMenu, D3ContextMenuSettings, } from '@components/d3/plugins' import D3MouseZone from '@components/d3/D3MouseZone' import { getByAccessor, getChartClass, getGroupClass, getTicks } from '@components/d3/functions' import { renderArea, renderLine, renderNeedle, renderPoint, renderRectArea } from '@components/d3/renders' import D3MonitoringEditor from './D3MonitoringEditor' import { D3HorizontalCursor, D3HorizontalCursorSettings } from './D3HorizontalCursor' import D3MonitoringLimitChart, { TelemetryRegulators } from './D3MonitoringLimitChart' const roundTo = (v: number, to: number = 50) => { if (v === 0) return v return (v > 0 ? Math.ceil : Math.round)(v / to) * to } const calculateDomain = (mm: MinMax): Required => { let round = Math.abs((mm.max ?? 0) - (mm.min ?? 0)) if (round < 10) round = 10 else if (round < 100) round = roundTo(round, 10) else if (round < 1000) round = roundTo(round, 100) else if (round < 10000) round = roundTo(round, 1000) else round = 0 let min = roundTo(mm.min ?? 0, round) let max = roundTo(mm.max ?? round, round) if (round && Math.abs(min - max) < round) { const mid = (min + max) / 2 min = mid - round max = mid + round } return { min, max } } export type ExtendedChartDataset = ChartDataset & { /** Диапазон отображаемых значений по горизонтальной оси */ xDomain: MinMax /** Скрыть отображение шкалы графика */ hideLabel?: boolean } export type ExtendedChartRegistry = ChartRegistry & ExtendedChartDataset export type ChartGroup = { /** Получить D3 выборку, содержащую корневой G-элемент группы */ (): d3.Selection /** Уникальный ключ группы (индекс) */ key: number /** Массив содержащихся в группе графиков */ charts: ExtendedChartRegistry[] } const defaultOffsets: ChartOffset = { top: 10, bottom: 10, left: 100, right: 20, } const defaultRegulators: TelemetryRegulators = { 1: { color: '#007070', label: 'Расход' }, 2: { color: '#59B359', label: 'Скорость блока' }, 3: { color: '#FF0000', label: 'Давление' }, 4: { color: '#0000CC', label: 'Осевая нагрузка' }, 5: { color: '#00B3B3', label: 'Вес на крюке' }, 6: { color: '#990099', label: 'Момент на роторе' }, } 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, }) /** * @template DataType тип данных отображаемых записей */ export type D3MonitoringChartsProps> = Omit, HTMLDivElement>, 'ref'> & { /** Двумерный массив датасетов (группа-график) */ datasetGroups: ExtendedChartDataset[][] /** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */ width?: string | number /** Высота графика числом пикселей или CSS-значением (px/%/em/rem) */ height?: string | number /** Длительность анимации в миллисекундах */ animDurationMs?: number /** Задать статус "заргужается" графику */ loading?: boolean /** Массив отображаемых данных */ data?: DataType[] /** Отступы графика от края SVG */ offset?: Partial /** Цвет фона в формате CSS-значения */ backgroundColor?: Property.Color /** Параметры общей вертикальной оси */ yAxis?: ChartAxis /** Параметры плагинов */ plugins?: { /** Параметры горизонтального курсора */ cursor?: BasePluginSettings & D3HorizontalCursorSettings /** Параметры контекстного меню */ menu?: BasePluginSettings & D3ContextMenuSettings } /** Настройки рисок и цен деления вертикальной шкалы */ yTicks?: ChartTick /** Штриховка графика */ dash?: string | number | number[] /** Диапозон отображаемых значений по вертикальной оси (сверху вниз) */ yDomain?: MinMax /** Событие, вызываемое при прокрутке колёсика мышки над графиком */ onWheel: (e: WheelEvent) => void /** Высота шкал графиков в пикселях (20 по умолчанию) */ axisHeight?: number /** Отступ между группами графиков в пикселях (30 по умолчанию) */ spaceBetweenGroups?: number /** Название графика для сохранения в базе */ chartName?: string methods?: (value: { setSettingsVisible: (visible: boolean) => void }) => void } export type ChartSizes = ChartOffset & { /** Ширина зоны графика */ inlineWidth: number /** Высота зоны графика */ inlineHeight: number /** Ширина группы на графике */ groupWidth: number /** Высота блока осей */ axesHeight: number /** Отступ сверху до активной зоны графиков */ chartsTop: number /** Высота активной зоны графиков */ chartsHeight: number /** Отступ слева для `i`-ой группы */ groupLeft: (i: number) => number /** Отступ сверху для `i`-ой оси в группе размером `count` */ axisTop: (i: number, count: number) => number } type ChartDomain = { /** Шкала графика */ scale: d3.ScaleLinear, /** Диапазон отображаемых на графике занчений */ domain: Required, } const _D3MonitoringCharts = >({ width: givenWidth = '100%', height: givenHeight = '100%', animDurationMs = 0, loading = false, datasetGroups, data, plugins, offset: _offset, yAxis: _yAxisConfig, backgroundColor = 'transparent', yDomain, yTicks: _yTicks, axisHeight = 20, spaceBetweenGroups = 30, dash, chartName, methods, className = '', ...other }: D3MonitoringChartsProps) => { const [datasets, setDatasets] = useState[][]>([]) const [groups, setGroups] = useState[]>([]) const [svgRef, setSvgRef] = useState(null) const [yAxisRef, setYAxisRef] = useState(null) const [chartAreaRef, setChartAreaRef] = useState(null) const [axesAreaRef, setAxesAreaRef] = useState(null) const [settingsVisible, setSettingsVisible] = useState(false) const [regulators, setRegulators] = useState(defaultRegulators) 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 - spaceBetweenGroups * (groupsCount - 1)) / groupsCount : 0 const maxChartCount = Math.max(0, ...groups.map((g) => g.charts.filter((c) => !c.hideLabel).length)) const axesHeight = (axisHeight * maxChartCount) return ({ ...offset, inlineWidth, inlineHeight, groupWidth, axesHeight, chartsTop: offset.top + axesHeight, chartsHeight: inlineHeight - axesHeight, groupLeft: (i: number) => (groupWidth + spaceBetweenGroups) * i, axisTop: (i: number, count: number) => axisHeight * (maxChartCount - count + i + 1) }) }, [groups, width, height, offset, axisHeight, spaceBetweenGroups]) 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 chartDomains = useMemo(() => groups.map((group) => { const out: [string | number, ChartDomain][] = group.charts.map((chart) => { const mm = { ...chart.xDomain } let domain: Required = { min: 0, max: 100 } if (!Number.isNaN((mm.min ?? NaN) + (mm.max ?? NaN))) { domain = mm as Required } else if (data) { const [min, max] = d3.extent(data, chart.x) domain = calculateDomain({ min, max, ...mm }) } return [chart.key, { scale: d3.scaleLinear().domain([domain.min, domain.max]), domain, }] }) out.forEach(([key], i) => { const chart = group.charts.find((chart) => chart.key === key) const bind = chart?.linkedTo if (!bind) return const bindDomain = out.find(([key]) => key === bind) if (bindDomain) out[i][1] = bindDomain[1] }) return Object.fromEntries(out) }), [groups, data]) const createAxesGroup = useCallback((i: number): ChartGroup => Object.assign( () => chartArea().select('.' + getGroupClass(i)) as d3.Selection, { key: i, charts: [], } ), [chartArea]) const onGroupsChange = useCallback((settings: ExtendedChartDataset[][], regulators: TelemetryRegulators) => { if (chartName) { invokeWebApiWrapperAsync( async () => { await UserSettingsService.update(chartName, { settings, regulators, }) }, undefined, 'Не удалось сохранить параметры графиков' ) } setDatasets(settings) setRegulators(regulators) setSettingsVisible(false) }, [chartName]) const onGroupsReset = useCallback(() => { setSettingsVisible(false) setDatasets(datasetGroups) setRegulators(defaultRegulators) if (chartName) { invokeWebApiWrapperAsync( async () => await UserSettingsService.delete(chartName), undefined, 'Не удалось удалить настройки графиков' ) } }, [datasetGroups, chartName]) useEffect(() => methods?.({ setSettingsVisible }), [methods]) useEffect(() => { invokeWebApiWrapperAsync( async () => { let sets = chartName ? await UserSettingsService.get(chartName) : null if (typeof sets === 'string') sets = JSON.parse(sets) const { settings, regulators } = sets if (regulators) setRegulators(regulators) if (Array.isArray(settings)) { setDatasets(settings) } else if (Array.isArray(datasetGroups)) { setDatasets(datasetGroups) if (chartName) { invokeWebApiWrapperAsync( async () => await UserSettingsService.insert(chartName, { settings: datasetGroups, regulators: defaultRegulators, }), undefined, 'Не удалось сохранить настройки графиков' ) } } }, undefined, 'Не удалось загрузить настройки графиков' ) }, [datasetGroups, chartName]) useEffect(() => { if (isDev()) { datasets.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 (datasets.length < oldGroups.length) { // Удаляем неактуальные группы oldGroups.slice(datasets.length).forEach((group) => group().remove()) groups.push(...oldGroups.slice(0, datasets.length)) } else { groups.push(...oldGroups) } datasets.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, datasets, animDurationMs, createAxesGroup]) useEffect(() => { const axesGroups = axesArea() .selectAll('.charts-group') .data(groups) axesGroups.exit().remove() axesGroups.enter() .append('g') .attr('class', 'charts-group') const actualAxesGroups = axesArea() .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.hideLabel) 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, sizes, spaceBetweenGroups, chartDomains, axesArea]) 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 case 'rect_area': renderRectArea(xAxis, yAxis, chart) break default: break } if (chart.point) renderPoint(xAxis, yAxis, chart, chartData, true) if (dash) chart().attr('stroke-dasharray', dash) chart.afterDraw?.(chart) }) }) }, [data, groups, height, offset, sizes, chartDomains]) return (
{data ? ( setSettingsVisible(true)} {...plugins?.menu} svg={svgRef} > {/* Сдвиг во все стороны на 1 px чтобы линии на краях было видно */} {d3.range(1, groups.length).map((i) => { const x = offset.left + (sizes.groupWidth + spaceBetweenGroups) * i - spaceBetweenGroups / 2 return })} ) : (
)} setSettingsVisible(false)} onReset={onGroupsReset} />
) } export const D3MonitoringCharts = memo(_D3MonitoringCharts) export default D3MonitoringCharts