asb_cloud_front/src/components/d3/monitoring/D3MonitoringCharts.tsx

598 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, useUserSettings } from '@utils'
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 D3MonitoringCurrentValues from './D3MonitoringCurrentValues'
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<MinMax> => {
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<DataType> = ChartDataset<DataType> & {
/** Диапазон отображаемых значений по горизонтальной оси */
xDomain: MinMax
/** Скрыть отображение шкалы графика */
hideLabel?: boolean
/** Показать последнее значение сверху графика */
showCurrentValue?: boolean
}
export type ExtendedChartRegistry<DataType> = ChartRegistry<DataType> & ExtendedChartDataset<DataType>
export type ChartGroup<DataType> = {
/** Получить D3 выборку, содержащую корневой G-элемент группы */
(): d3.Selection<SVGGElement, any, any, any>
/** Уникальный ключ группы (индекс) */
key: number
/** Массив содержащихся в группе графиков */
charts: ExtendedChartRegistry<DataType>[]
}
const defaultOffsets: ChartOffset = {
top: 10,
bottom: 10,
left: 100,
right: 20,
}
const defaultRegulators: TelemetryRegulators = {
1: { color: '#59B359', label: 'Скорость блока' },
2: { color: '#FF0000', label: 'Давление' },
3: { color: '#0000CC', label: 'Осевая нагрузка' },
4: { color: '#990099', label: 'Момент на роторе' },
5: { color: '#007070', label: 'Расход' },
}
const getDefaultYAxisConfig = <DataType,>(): ChartAxis<DataType> => ({
type: 'time',
accessor: (d: any) => new Date(d.date)
})
const getDefaultYTicks = <DataType,>(): Required<ChartTick<DataType>> => ({
visible: false,
format: (d: d3.NumberValue, idx: number, data?: DataType) => String(d),
color: 'lightgray',
count: 10,
})
/**
* @template DataType тип данных отображаемых записей
*/
export type D3MonitoringChartsProps<DataType extends Record<string, any>> = Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'> & {
/** Двумерный массив датасетов (группа-график) */
datasetGroups: ExtendedChartDataset<DataType>[][]
/** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */
width?: string | number
/** Высота графика числом пикселей или CSS-значением (px/%/em/rem) */
height?: string | number
/** Длительность анимации в миллисекундах */
animDurationMs?: number
/** Задать статус "заргужается" графику */
loading?: boolean
/** Массив отображаемых данных */
data?: DataType[]
/** Отступы графика от края SVG */
offset?: Partial<ChartOffset>
/** Цвет фона в формате CSS-значения */
backgroundColor?: Property.Color
/** Параметры общей вертикальной оси */
yAxis?: ChartAxis<DataType>
/** Параметры плагинов */
plugins?: {
/** Параметры горизонтального курсора */
cursor?: BasePluginSettings & D3HorizontalCursorSettings<DataType>
/** Параметры контекстного меню */
menu?: BasePluginSettings & D3ContextMenuSettings
}
/** Настройки рисок и цен деления вертикальной шкалы */
yTicks?: ChartTick<DataType>
/** Штриховка графика */
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<number, number>,
/** Диапазон отображаемых на графике занчений */
domain: Required<MinMax>,
}
const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
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<DataType>) => {
const [datasets, setDatasets, resetDatasets] = useUserSettings(chartName, datasetGroups)
const [regulators, setRegulators, resetRegulators] = useUserSettings(`${chartName}_regulators`, defaultRegulators)
const [groups, setGroups] = useState<ChartGroup<DataType>[]>([])
const [svgRef, setSvgRef] = useState<SVGSVGElement | null>(null)
const [yAxisRef, setYAxisRef] = useState<SVGGElement | null>(null)
const [chartAreaRef, setChartAreaRef] = useState<SVGGElement | null>(null)
const [axesAreaRef, setAxesAreaRef] = useState<SVGGElement | null>(null)
const [settingsVisible, setSettingsVisible] = useState<boolean>(false)
const offset = usePartialProps(_offset, defaultOffsets)
const yTicks = usePartialProps<Required<ChartTick<DataType>>>(_yTicks, getDefaultYTicks)
const yAxisConfig = usePartialProps<ChartAxis<DataType>>(_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<MinMax> = { min: 0, max: 100 }
if (!Number.isNaN((mm.min ?? NaN) + (mm.max ?? NaN))) {
domain = mm as Required<MinMax>
} 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<DataType> => Object.assign(
() => chartArea().select('.' + getGroupClass(i)) as d3.Selection<SVGGElement, any, any, any>,
{
key: i,
charts: [],
}
), [chartArea])
const onGroupsChange = useCallback((settings: ExtendedChartDataset<DataType>[][], regulators: TelemetryRegulators) => {
setSettingsVisible(false)
setRegulators(regulators)
setDatasets(settings)
}, [setDatasets, setRegulators])
const onGroupsReset = useCallback(() => {
setSettingsVisible(false)
resetRegulators()
resetDatasets()
}, [resetDatasets, resetRegulators])
useEffect(() => methods?.({ setSettingsVisible }), [methods]) /// Возвращаем в делегат доступные методы
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<DataType>[] = []
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<DataType> = 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<DataType> = Object.assign(
() => group().select('.' + getChartClass(dataset.key)) as d3.Selection<SVGGElement, DataType, any, unknown>,
{
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<SVGGElement | null, ChartGroup<DataType>>('.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<SVGGElement | null, ExtendedChartRegistry<DataType>>()
.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<DataType>(xAxis, yAxis, chart, chartData, height, offset)
break
case 'line':
chartData = renderLine<DataType>(xAxis, yAxis, chart, chartData)
break
case 'point':
chartData = renderPoint<DataType>(xAxis, yAxis, chart, chartData)
break
case 'area':
chartData = renderArea<DataType>(xAxis, yAxis, chart, chartData)
break
case 'rect_area':
renderRectArea<DataType>(xAxis, yAxis, chart)
break
default:
break
}
if (chart.point)
renderPoint<DataType>(xAxis, yAxis, chart, chartData, true)
if (dash) chart().attr('stroke-dasharray', dash)
chart.afterDraw?.(chart)
})
})
}, [data, groups, height, offset, sizes, chartDomains])
return (
<LoaderPortal
show={loading}
style={{
width: givenWidth,
height: givenHeight,
}}
>
<div
{...other}
ref={rootRef}
className={`asb-d3-chart ${className}`}
>
{data ? (
<D3ContextMenu
onSettingsOpen={() => setSettingsVisible(true)}
{...plugins?.menu}
svg={svgRef}
>
<svg ref={setSvgRef} width={'100%'} height={'100%'}>
<defs>
<clipPath id={`chart-clip`}>
{/* Сдвиг во все стороны на 1 px чтобы линии на краях было видно */}
<rect x={-1} y={-1} width={sizes.groupWidth + 2} height={sizes.chartsHeight + 2} />
</clipPath>
</defs>
<g ref={setYAxisRef} className={'axis y'} transform={`translate(${offset.left}, ${sizes.chartsTop})`} />
<g ref={setAxesAreaRef} className={'chart-axes'} transform={`translate(${offset.left}, ${offset.top})`}>
<rect width={sizes.inlineWidth} height={sizes.axesHeight} fill={backgroundColor} />
</g>
<g ref={setChartAreaRef} className={'chart-area'} transform={`translate(${offset.left}, ${sizes.chartsTop})`}>
<rect width={sizes.inlineWidth} height={sizes.chartsHeight} fill={backgroundColor} />
</g>
<g stroke={'black'}>
{d3.range(1, groups.length).map((i) => {
const x = offset.left + (sizes.groupWidth + spaceBetweenGroups) * i - spaceBetweenGroups / 2
return <line key={`${i}`} x1={x} x2={x} y1={sizes.chartsTop} y2={offset.top + sizes.inlineHeight} />
})}
</g>
<D3MonitoringLimitChart<DataType>
regulators={regulators}
data={data}
yAxis={yAxis}
width={20}
height={sizes.chartsHeight}
left={sizes.inlineWidth + sizes.left}
top={sizes.chartsTop}
zoneWidth={sizes.inlineWidth}
/>
<D3MonitoringCurrentValues<DataType>
groups={groups}
data={data}
left={offset.left}
sizes={sizes}
/>
<D3MouseZone width={width} height={height} offset={{ ...offset, top: sizes.chartsTop }}>
<D3HorizontalCursor
{...plugins?.cursor}
yAxis={yAxis}
groups={groups}
sizes={sizes}
spaceBetweenGroups={spaceBetweenGroups}
data={data}
height={height}
/>
</D3MouseZone>
</svg>
</D3ContextMenu>
) : (
<div className={'chart-empty'}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
)}
<D3MonitoringEditor
groups={datasets}
regulators={regulators}
visible={settingsVisible}
onChange={onGroupsChange}
onCancel={() => setSettingsVisible(false)}
onReset={onGroupsReset}
/>
</div>
</LoaderPortal>
)
}
export const D3MonitoringCharts = memo(_D3MonitoringCharts) as typeof _D3MonitoringCharts
export default D3MonitoringCharts