asb_cloud_front/src/components/d3/D3Chart.tsx
2022-10-03 20:17:58 +05:00

396 lines
14 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 D3MouseZone from './D3MouseZone'
import { getChartClass } from './functions'
import {
renderArea,
renderLine,
renderPoint,
renderNeedle
} from './renders'
import {
BasePluginSettings,
D3ContextMenu,
D3ContextMenuSettings,
D3Cursor,
D3CursorSettings,
D3Legend,
D3LegendSettings,
D3Tooltip,
D3TooltipSettings,
} from './plugins'
import type {
BaseDataType,
ChartAxis,
ChartDataset,
ChartDomain,
ChartOffset,
ChartRegistry,
ChartTicks
} from './types'
import '@styles/d3.less'
const defaultOffsets: ChartOffset = {
top: 10,
bottom: 30,
left: 50,
right: 10,
}
export const getByAccessor = <DataType extends Record<any, any>, R>(accessor: keyof DataType | ((d: DataType) => R)): ((d: DataType) => R) => {
if (typeof accessor === 'function')
return accessor
return (d) => d[accessor]
}
const createAxis = <DataType extends BaseDataType>(config: ChartAxis<DataType>) => {
if (config.type === 'time')
return d3.scaleTime()
return d3.scaleLinear()
}
export type D3ChartProps<DataType extends BaseDataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
/** Параметры общей горизонтальной оси */
xAxis: ChartAxis<DataType>
/** Параметры графиков */
datasets: ChartDataset<DataType>[]
/** Массив отображаемых данных или объект с парами ключ графика-данные */
data?: DataType[] | Record<string, DataType[]>
/** Диапозон отображаемых значений */
domain?: Partial<ChartDomain>
/** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */
width?: string | number
/** Высота графика числом пикселей или CSS-значением (px/%/em/rem) */
height?: string | number
/** Задать статус "заргужается" графику */
loading?: boolean
/** Отступы графика от края SVG */
offset?: Partial<ChartOffset>
/** Длительность анимации в миллисекундах */
animDurationMs?: number
/** Цвет фона в формате CSS-значения */
backgroundColor?: Property.Color
/** Настройки рисок и цен деления графика */
ticks?: ChartTicks<DataType>
/** Штриховка графика */
dash?: string | number | number[]
/** Параметры плагинов */
plugins?: {
/** Параметры контекстного меню */
menu?: BasePluginSettings & D3ContextMenuSettings
/** Параметры всплывающей подсказки */
tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
/** Параметры курсора */
cursor?: BasePluginSettings & D3CursorSettings
/** Параметры блока легенды */
legend?: BasePluginSettings & D3LegendSettings
}
}
const getDefaultXAxisConfig = <DataType extends BaseDataType>(): ChartAxis<DataType> => ({
type: 'time',
accessor: (d: any) => new Date(d.date)
})
const _D3Chart = <DataType extends Record<string, unknown>>({
className = '',
xAxis: _xAxisConfig,
datasets,
data,
domain,
width: givenWidth = '100%',
height: givenHeight = '100%',
loading,
offset: _offset,
animDurationMs = 200,
backgroundColor = 'transparent',
ticks,
plugins,
dash,
...other
}: D3ChartProps<DataType>) => {
const xAxisConfig = usePartialProps<ChartAxis<DataType>>(_xAxisConfig, getDefaultXAxisConfig)
const offset = usePartialProps(_offset, defaultOffsets)
const [svgRef, setSvgRef] = useState<SVGSVGElement | null>(null)
const [xAxisRef, setXAxisRef] = useState<SVGGElement | null>(null)
const [yAxisRef, setYAxisRef] = useState<SVGGElement | null>(null)
const [chartAreaRef, setChartAreaRef] = useState<SVGGElement | null>(null)
const xAxisArea = useCallback(() => d3.select(xAxisRef), [xAxisRef])
const yAxisArea = useCallback(() => d3.select(yAxisRef), [yAxisRef])
const chartArea = useCallback(() => d3.select(chartAreaRef), [chartAreaRef])
const [charts, setCharts] = useState<ChartRegistry<DataType>[]>([])
const [rootRef, { width, height }] = useElementSize()
const xAxis = useMemo(() => {
if (!data) return
const getX = getByAccessor(xAxisConfig.accessor)
const xAxis = createAxis(xAxisConfig)
xAxis.range([0, width - offset.left - offset.right])
if (domain?.x?.min && domain?.x?.max) {
xAxis.domain([domain.x.min, domain.x.max])
return xAxis
}
if (Array.isArray(data)) {
const [minX, maxX] = d3.extent(data, getX)
xAxis.domain([domain?.x?.min ?? minX, domain?.x?.max ?? maxX])
} else {
let [minX, maxX] = [Infinity, -Infinity]
for (const key in data) {
const [min, max] = d3.extent(data[key], getX)
if (min < minX) minX = min
if (max > maxX) maxX = max
}
xAxis.domain([domain?.x?.min ?? minX, domain?.x?.max ?? maxX])
}
return xAxis
}, [xAxisConfig, data, domain, width, offset])
const yAxis = useMemo(() => {
if (!data) return
const yAxis = d3.scaleLinear()
if (domain?.y) {
const { min, max } = domain.y
if (min && max && Number.isFinite(min + max)) {
yAxis.domain([min, max])
return yAxis
}
}
let minY = Infinity
let maxY = -Infinity
if (Array.isArray(data)) {
charts.forEach(({ y }) => {
const [min, max] = d3.extent(data, y)
if (min && min < minY) minY = min
if (max && max > maxY) maxY = max
})
} else {
for (const key in data) {
const chart = charts.find((chart) => chart.key === key)
if (!chart) continue
const [min, max] = d3.extent(data[key], chart.y)
if (min && min < minY) minY = min
if (max && max > maxY) maxY = max
}
}
yAxis.domain([
domain?.y?.min ?? minY,
domain?.y?.max ?? maxY,
])
yAxis.range([height - offset.top - offset.bottom, 0])
return yAxis
}, [charts, data, domain, height, offset])
const nTicks = {
color: 'lightgray',
...ticks,
x: {
visible: false,
format: (d: any) => String(d),
count: 10,
...ticks?.x,
}
}
useEffect(() => { // Рисуем ось X
if (!xAxis) return
xAxisArea().transition()
.duration(animDurationMs)
.call(d3.axisBottom(xAxis)
.tickSize(nTicks.x.visible ? -height + offset.bottom : 0)
.tickFormat((d, i) => nTicks.x.format(d, i))
.ticks(nTicks.x.count) as any // TODO: Исправить тип
)
xAxisArea().selectAll('.tick line').attr('stroke', nTicks.x.color || nTicks.color)
}, [xAxisArea, xAxis, animDurationMs, height, offset, ticks])
useEffect(() => { // Рисуем ось Y
if (!yAxis) return
const nTicks = {
color: 'lightgray',
...ticks,
y: {
visible: false,
format: (d: any) => String(d),
count: 10,
...ticks?.y,
}
}
yAxisArea().transition()
.duration(animDurationMs)
.call(d3.axisLeft(yAxis)
.tickSize(nTicks.y.visible ? -width + offset.left + offset.right : 0)
.tickFormat((d, i) => nTicks.y.format(d, i))
.ticks(nTicks.y.count) as any // TODO: Исправить тип
)
yAxisArea().selectAll('.tick line').attr('stroke', nTicks.y.color || nTicks.color)
}, [yAxisArea, yAxis, animDurationMs, width, offset, ticks])
useEffect(() => {
if (isDev())
for (let i = 0; i < datasets.length - 1; i++)
for (let j = i + 1; j < datasets.length; j++)
if (datasets[i].key === datasets[j].key)
console.warn(`Ключ датасета "${datasets[i].key}" неуникален (индексы ${i} и ${j})!`)
setCharts((oldCharts) => {
const charts: ChartRegistry<DataType>[] = []
for (const chart of oldCharts) { // Удаляем ненужные графики
if (datasets.find(({ key }) => key === chart.key))
charts.push(chart)
else
chart().remove()
}
datasets.forEach((dataset) => { // Добавляем новые
let chartIdx = charts.findIndex(({ key }) => key === dataset.key)
if (chartIdx < 0)
chartIdx = charts.length
const newChart: ChartRegistry<DataType> = Object.assign(
() => chartArea().select('.' + getChartClass(dataset.key)) as d3.Selection<SVGGElement, DataType, any, unknown>,
{
width: 1,
opacity: 1,
label: dataset.key,
color: 'gray',
animDurationMs,
...dataset,
xAxis: dataset.xAxis ?? xAxisConfig,
y: getByAccessor(dataset.yAxis.accessor),
x: getByAccessor(dataset.xAxis?.accessor ?? xAxisConfig.accessor),
}
)
if (newChart.type === 'line')
newChart.optimization = false
if (!newChart().node())
chartArea()
.append('g')
.attr('class', getChartClass(newChart.key))
charts[chartIdx] = newChart
})
return charts
})
}, [xAxisConfig, chartArea, datasets, animDurationMs])
const redrawCharts = useCallback(() => {
if (!data || !xAxis || !yAxis) return
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 = Array.isArray(data) ? data : data[String(chart.key).split(':')[0]]
if (!chartData) return
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)
if (dash) chart().attr('stroke-dasharray', dash)
chart.afterDraw?.(chart)
})
}, [charts, data, xAxis, yAxis, height, offset])
useEffect(() => {
redrawCharts()
}, [redrawCharts])
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%'}>
<g ref={setXAxisRef} className={'axis x'} transform={`translate(${offset.left}, ${height - offset.bottom})`} />
<g ref={setYAxisRef} className={'axis y'} transform={`translate(${offset.left}, ${offset.top})`} />
<g ref={setChartAreaRef} className={'chart-area'} transform={`translate(${offset.left}, ${offset.top})`}>
<rect
width={Math.max(width - offset.left - offset.right, 0)}
height={Math.max(height - offset.top - offset.bottom, 0)}
fill={backgroundColor}
/>
</g>
<D3MouseZone width={width} height={height} offset={offset}>
<D3Cursor {...plugins?.cursor} />
<D3Legend<DataType> charts={charts} {...plugins?.legend} />
<D3Tooltip<DataType> charts={charts} {...plugins?.tooltip} />
</D3MouseZone>
</svg>
</D3ContextMenu>
) : (
<div className={'chart-empty'}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
)}
</div>
</LoaderPortal>
)
}
export const D3Chart = memo(_D3Chart) as typeof _D3Chart
export default D3Chart