asb_cloud_front/src/components/d3/D3MonitoringCharts.tsx

583 lines
24 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 } from '@utils'
import {
ChartAxis,
ChartDataset,
ChartOffset,
ChartRegistry,
ChartTick,
MinMax
} from './types'
import {
BasePluginSettings,
D3ContextMenu,
D3ContextMenuSettings,
D3HorizontalCursor,
D3HorizontalCursorSettings
} from './plugins'
import D3MouseZone from './D3MouseZone'
import { getByAccessor, getChartClass, getGroupClass, getTicks } from './functions'
import { renderArea, renderLine, renderNeedle, renderPoint, renderRectArea } from './renders'
import D3MonitoringGroupsEditor from './D3MonitoringGroupsEditor'
import { UserSettingsService } from '@asb/services/api'
import { invokeWebApiWrapperAsync } from '../factory'
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<MinMax> => {
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 }
}
export type ExtendedChartDataset<DataType> = ChartDataset<DataType> & {
/** Диапазон отображаемых значений по горизонтальной оси */
xDomain: MinMax
/** Скрыть отображение шкалы графика */
hideLabel?: 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: 10,
}
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>> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
/** Двумерный массив датасетов (группа-график) */
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
}
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,
className = '',
...other
}: D3MonitoringChartsProps<DataType>) => {
const [datasets, setDatasets] = useState<ExtendedChartDataset<DataType>[][]>([])
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>(true)
useEffect(() => {
invokeWebApiWrapperAsync(
async () => {
let sets = chartName ? await UserSettingsService.get(chartName) : null
if (typeof sets === 'string')
sets = JSON.parse(sets)
if (Array.isArray(sets)) {
setDatasets(sets)
} else if (Array.isArray(datasetGroups)) {
setDatasets(datasetGroups)
if (chartName) {
invokeWebApiWrapperAsync(
async () => await UserSettingsService.insert(chartName, datasetGroups),
undefined,
'Не удалось сохранить настройки графиков'
)
}
}
},
undefined,
'Не удалось загрузить настройки графиков'
)
}, [datasetGroups, chartName])
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 }, 100)
}
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?.bindDomainFrom
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, axesArea])
const onGroupsChange = useCallback((sets: ExtendedChartDataset<DataType>[][]) => {
if (chartName) {
invokeWebApiWrapperAsync(
async () => {
await UserSettingsService.update(chartName, sets)
},
undefined,
'Не удалось сохранить параметры графиков'
)
}
setDatasets(sets)
setSettingsVisible(false)
}, [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<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 = 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<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])
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>
<D3MouseZone width={width} height={height} offset={{ ...offset, top: sizes.chartsTop }}>
<D3HorizontalCursor
{...plugins?.cursor}
yAxis={yAxis}
groups={groups}
sizes={sizes}
data={data}
/>
</D3MouseZone>
</svg>
</D3ContextMenu>
) : (
<div className={'chart-empty'}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
)}
<D3MonitoringGroupsEditor
name={chartName}
groups={datasets}
visible={settingsVisible}
onChange={onGroupsChange}
onCancel={() => setSettingsVisible(false)}
/>
</div>
</LoaderPortal>
)
}
export const D3MonitoringCharts = memo(_D3MonitoringCharts)
export default D3MonitoringCharts