asb_cloud_front/src/components/d3/D3MonitoringCharts.tsx

498 lines
19 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,
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<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 }
}
type AxisScale = d3.ScaleTime<number, number, never> | d3.ScaleLinear<number, number, never>
type ExtendedChartDataset<DataType> = ChartDataset<DataType> & {
xDomain: MinMax
hideXAxis?: boolean
}
type ExtendedChartRegistry<DataType> = ExtendedChartDataset<DataType> & {
(): d3.Selection<SVGGElement, DataType, any, any>
y: (value: any) => number
x: (value: any) => number
}
export type ChartGroup<DataType> = {
(): 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,
})
const findChartsByKey = <DataType,>(groups: ChartGroup<DataType>[], key: string) => {
const out: ChartRegistry<DataType>[] = []
groups.forEach((group) => {
const res = group.charts.find((chart) => chart.key === key)
if (res) out.push(res)
})
return out
}
export type D3MonitoringChartsProps<DataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
datasetGroups: ExtendedChartDataset<DataType>[][]
width?: string | number
height?: string | number
animDurationMs?: number
loading?: boolean
data?: DataType[]
offset?: Partial<ChartOffset>
backgroundColor?: Property.Color
yAxis?: ChartAxis<DataType>
plugins?: {
cursor?: BasePluginSettings & D3HorizontalCursorSettings<DataType>
menu?: BasePluginSettings & D3ContextMenuSettings
tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
}
yTicks?: ChartTick<DataType>
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 = <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,
className = '',
...other
}: D3MonitoringChartsProps<DataType>) => {
const [groups, setGroups] = useState<ChartGroup<DataType>[]>([])
const [groupScales, setGroupScales] = useState<Record<string, AxisScale>[]>([])
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 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 - 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<DataType> => Object.assign(
() => chartArea().select('.' + getGroupClass(i)) as d3.Selection<SVGGElement, any, any, any>,
{
key: i,
charts: [],
}
), [chartArea, axesArea])
const chartDomains: Record<string, {
scale: d3.ScaleLinear<number, number>,
domain: Required<MinMax>,
}>[] = useMemo(() => {
return groups.map((group) => {
const out = group.charts.map((chart) => {
const mm = { ...chart.xDomain }
let domain: Required<MinMax> = { min: 0, max: 100 }
if (mm.min && mm.max) {
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,
}]
})
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<DataType>[] = []
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<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, 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<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.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<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, 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<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
default:
break
}
if (chart.point)
renderPoint<DataType>(xAxis, yAxis, chart, chartData, true)
chart.afterDraw?.(chart)
})
})
}, [data, groups, groupScales, height, offset, sizes, chartDomains])
return (
<LoaderPortal
show={loading}
style={{
width: givenWidth,
height: givenHeight,
}}
>
<div
{...other}
ref={rootRef}
className={`asb-d3-chart ${className}`}
>
{data ? (
<D3ContextMenu {...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 + space) * i - space / 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>
)}
</div>
</LoaderPortal>
)
}
export const D3MonitoringCharts = memo(_D3MonitoringCharts)
export default D3MonitoringCharts