forked from ddrilling/asb_cloud_front
Добавлен компонент для отображения групп вертикальных графиков
This commit is contained in:
parent
8bb98f6c4c
commit
9aef0979bd
465
src/components/d3/D3MonitoringCharts.tsx
Normal file
465
src/components/d3/D3MonitoringCharts.tsx
Normal file
@ -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<number, number, never> | d3.ScaleLinear<number, number, never>
|
||||
|
||||
type ExtendedChartDataset<DataType> = ChartDataset<DataType> & {
|
||||
yDomain: MinMax
|
||||
hideXAxis?: boolean
|
||||
}
|
||||
|
||||
type ExtendedChartRegistry<DataType> = ExtendedChartDataset<DataType> & {
|
||||
(): d3.Selection<SVGGElement, DataType, any, any>
|
||||
scale: d3.ScaleLinear<number, number>
|
||||
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 = 20
|
||||
|
||||
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])
|
||||
|
||||
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 yDomain = {
|
||||
min: 0,
|
||||
max: 10,
|
||||
...dataset.yDomain,
|
||||
}
|
||||
|
||||
const scale = d3.scaleLinear()
|
||||
.domain([yDomain.min, yDomain.max])
|
||||
|
||||
// Пересоздаём график
|
||||
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,
|
||||
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<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) {
|
||||
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(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<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>(chart.scale, yAxis, chart, chartData, true)
|
||||
|
||||
chart.afterDraw?.(chart)
|
||||
})
|
||||
})
|
||||
}, [data, groups, groupScales, height, offset, sizes])
|
||||
|
||||
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
|
@ -1,4 +1,6 @@
|
||||
export * from './D3Chart'
|
||||
export type { D3ChartProps } from './D3Chart'
|
||||
|
||||
export * from './D3MonitoringCharts'
|
||||
|
||||
export * from './types'
|
||||
|
Loading…
Reference in New Issue
Block a user