forked from ddrilling/asb_cloud_front
* Добавлен компонент графика d3
* Добавлены плагины для графиков * Empty перемещён из опрелённых операций в компонент графика * добавлена зависимость usehooks-ts
This commit is contained in:
parent
8db3476c0e
commit
1a05704375
27
package-lock.json
generated
27
package-lock.json
generated
@ -28,6 +28,7 @@
|
|||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"rxjs": "^7.5.5",
|
"rxjs": "^7.5.5",
|
||||||
"typescript": "^4.7.2",
|
"typescript": "^4.7.2",
|
||||||
|
"usehooks-ts": "^2.6.0",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -11065,9 +11066,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "5.4.0",
|
"version": "5.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-5.5.1.tgz",
|
||||||
"integrity": "sha512-A1SRXysDg7J+mVP46jF+9cKANw0kptqSFZ8tGyL+HBiv0K1spjxPX8Z4EGu+Eu6pjClJUBdnUPlxrOafR668/g==",
|
"integrity": "sha512-MEvryPLf18HvlCbLSzCW0U00IMftKGI5udnjrQbC5D4P0Hodwffhv+iGfWuJwg16Y/TK11ZFK8i+BPVW2z/eAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.18"
|
"node": ">=12.18"
|
||||||
@ -11171,6 +11172,14 @@
|
|||||||
"requires-port": "^1.0.0"
|
"requires-port": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/usehooks-ts": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-Kj/4oc2nOxRDGTDb2v1ZulF7+tpeXFuqI6cUesM0Vic7TPPDlFORxKh4ivsYg+NTvX/YbM+lhqqkfFTiIt23eg==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@ -19998,9 +20007,9 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"undici": {
|
"undici": {
|
||||||
"version": "5.4.0",
|
"version": "5.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-5.5.1.tgz",
|
||||||
"integrity": "sha512-A1SRXysDg7J+mVP46jF+9cKANw0kptqSFZ8tGyL+HBiv0K1spjxPX8Z4EGu+Eu6pjClJUBdnUPlxrOafR668/g==",
|
"integrity": "sha512-MEvryPLf18HvlCbLSzCW0U00IMftKGI5udnjrQbC5D4P0Hodwffhv+iGfWuJwg16Y/TK11ZFK8i+BPVW2z/eAw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"unicode-canonical-property-names-ecmascript": {
|
"unicode-canonical-property-names-ecmascript": {
|
||||||
@ -20067,6 +20076,12 @@
|
|||||||
"requires-port": "^1.0.0"
|
"requires-port": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"usehooks-ts": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-Kj/4oc2nOxRDGTDb2v1ZulF7+tpeXFuqI6cUesM0Vic7TPPDlFORxKh4ivsYg+NTvX/YbM+lhqqkfFTiIt23eg==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"util-deprecate": {
|
"util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
@ -23,6 +23,7 @@
|
|||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"rxjs": "^7.5.5",
|
"rxjs": "^7.5.5",
|
||||||
"typescript": "^4.7.2",
|
"typescript": "^4.7.2",
|
||||||
|
"usehooks-ts": "^2.6.0",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
395
src/components/d3/D3Chart.tsx
Normal file
395
src/components/d3/D3Chart.tsx
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
import { memo, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useElementSize } from 'usehooks-ts'
|
||||||
|
import { Empty } from 'antd'
|
||||||
|
import { Property } from 'csstype'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
import LoaderPortal from '@components/LoaderPortal'
|
||||||
|
import { formatDate, makePointsOptimizator, usePartialProps } from '@utils'
|
||||||
|
|
||||||
|
import D3MouseZone from './D3MouseZone'
|
||||||
|
import {
|
||||||
|
BasePluginSettings,
|
||||||
|
D3ContextMenu,
|
||||||
|
D3ContextMenuSettings,
|
||||||
|
D3Cursor,
|
||||||
|
D3CursorSettings,
|
||||||
|
D3Tooltip,
|
||||||
|
D3TooltipSettings
|
||||||
|
} from './plugins'
|
||||||
|
|
||||||
|
import '@styles/d3.less'
|
||||||
|
|
||||||
|
type DefaultDataType = Record<string, any>
|
||||||
|
|
||||||
|
export type ChartAxis<DataType> = {
|
||||||
|
type: 'linear' | 'time',
|
||||||
|
accessor: keyof DataType | ((d: DataType) => any)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BaseChartDataset<DataType> = {
|
||||||
|
key: string | number
|
||||||
|
label?: ReactNode
|
||||||
|
yAxis: ChartAxis<DataType>
|
||||||
|
color?: Property.Color
|
||||||
|
opacity?: number
|
||||||
|
width?: Property.StrokeWidth
|
||||||
|
tooltip?: D3TooltipSettings<DataType>
|
||||||
|
animDurationMs?: number
|
||||||
|
afterDraw?: (d: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LineChartDataset<DataType> = {
|
||||||
|
type: 'line'
|
||||||
|
point?: {
|
||||||
|
radius?: number
|
||||||
|
color?: Property.Color
|
||||||
|
}
|
||||||
|
nullValues?: 'skip' | 'gap' | 'none'
|
||||||
|
optimization?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AreaChartDataset<DataType> = {
|
||||||
|
type: 'area'
|
||||||
|
fillColor?: Property.Color
|
||||||
|
point?: {
|
||||||
|
radius?: number
|
||||||
|
color?: Property.Color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NeedleChartDataset<DataType> = {
|
||||||
|
type: 'needle'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChartDataset<DataType> = BaseChartDataset<DataType> & (
|
||||||
|
AreaChartDataset<DataType> |
|
||||||
|
LineChartDataset<DataType> |
|
||||||
|
NeedleChartDataset<DataType>
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ChartDomain = {
|
||||||
|
x: { min?: number, max?: number }
|
||||||
|
y: { min?: number, max?: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChartOffset = {
|
||||||
|
top: number
|
||||||
|
bottom: number
|
||||||
|
left: number
|
||||||
|
right: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChartTicks = {
|
||||||
|
color?: Property.Color
|
||||||
|
x?: { visible?: boolean, count?: number }
|
||||||
|
y?: { visible?: boolean, count?: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type D3ChartProps<DataType = DefaultDataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||||
|
xAxis: ChartAxis<DataType>
|
||||||
|
datasets: ChartDataset<DataType>[]
|
||||||
|
data?: DataType[]
|
||||||
|
domain?: Partial<ChartDomain>
|
||||||
|
width?: number | string
|
||||||
|
height?: number | string
|
||||||
|
loading?: boolean
|
||||||
|
offset?: Partial<ChartOffset>
|
||||||
|
mode: 'horizontal' | 'vertical'
|
||||||
|
animDurationMs?: number
|
||||||
|
backgroundColor?: Property.Color
|
||||||
|
ticks?: ChartTicks
|
||||||
|
plugins?: {
|
||||||
|
menu?: BasePluginSettings & D3ContextMenuSettings
|
||||||
|
tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
|
||||||
|
cursor?: BasePluginSettings & D3CursorSettings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Selection = d3.Selection<any, any, null, undefined>
|
||||||
|
|
||||||
|
type ChartRegistry<DataType = DefaultDataType> = ChartDataset<DataType> & {
|
||||||
|
(): Selection
|
||||||
|
y: (value: any) => number
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOffsets: ChartOffset = {
|
||||||
|
top: 10,
|
||||||
|
bottom: 30,
|
||||||
|
left: 50,
|
||||||
|
right: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultXAxisConfig: ChartAxis<DefaultDataType> = {
|
||||||
|
type: 'time',
|
||||||
|
accessor: (d: any) => new Date(d.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultCursorPlugin: BasePluginSettings & D3CursorSettings = {
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGroupClass = (key: string | number) => `chart-id-${key}`
|
||||||
|
|
||||||
|
const getByAccessor = <T extends Record<string, any>>(accessor: string | ((d: T) => any)) => {
|
||||||
|
if (typeof accessor === 'function')
|
||||||
|
return accessor
|
||||||
|
return (d: T) => d[accessor]
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAxis = <DataType,>(config: ChartAxis<DataType>) => {
|
||||||
|
if (config.type === 'time')
|
||||||
|
return d3.scaleTime()
|
||||||
|
return d3.scaleLinear()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const D3Chart = memo<D3ChartProps>(({
|
||||||
|
className = '',
|
||||||
|
xAxis: _xAxisConfig,
|
||||||
|
datasets,
|
||||||
|
data,
|
||||||
|
domain,
|
||||||
|
width: givenWidth = '100%',
|
||||||
|
height: givenHeight = '100%',
|
||||||
|
loading,
|
||||||
|
offset: _offset,
|
||||||
|
mode = 'horizontal',
|
||||||
|
animDurationMs = 200,
|
||||||
|
backgroundColor = 'transparent',
|
||||||
|
ticks,
|
||||||
|
plugins,
|
||||||
|
...other
|
||||||
|
}) => {
|
||||||
|
const xAxisConfig = usePartialProps(_xAxisConfig, defaultXAxisConfig)
|
||||||
|
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 getX = useMemo(() => getByAccessor(xAxisConfig.accessor), [xAxisConfig.accessor])
|
||||||
|
|
||||||
|
const [charts, setCharts] = useState<ChartRegistry[]>([])
|
||||||
|
|
||||||
|
const [rootRef, { width, height }] = useElementSize()
|
||||||
|
|
||||||
|
const xAxis = useMemo(() => {
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
const xAxis = createAxis(xAxisConfig)
|
||||||
|
|
||||||
|
const [minX, maxX] = d3.extent(data, getX)
|
||||||
|
xAxis.domain([domain?.x?.min ?? minX, domain?.x?.max ?? maxX])
|
||||||
|
xAxis.range([0, width - offset.left - offset.right])
|
||||||
|
|
||||||
|
return xAxis
|
||||||
|
}, [xAxisConfig, getX, 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
|
||||||
|
charts.forEach(({ y }) => {
|
||||||
|
const [min, max] = d3.extent(data, 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])
|
||||||
|
|
||||||
|
useEffect(() => { // Рисуем ось X
|
||||||
|
if (!xAxis) return
|
||||||
|
xAxisArea().transition()
|
||||||
|
.duration(animDurationMs)
|
||||||
|
.call(d3.axisBottom(xAxis)
|
||||||
|
.tickSize((ticks?.x?.visible ?? false) ? -height + offset.bottom : 0)
|
||||||
|
.tickFormat((d) => formatDate(d, undefined, 'YYYY-MM-DD') || 'NaN')
|
||||||
|
.ticks(ticks?.x?.count ?? 10) as any // TODO: Исправить тип
|
||||||
|
)
|
||||||
|
|
||||||
|
xAxisArea().selectAll('.tick line').attr('stroke', ticks?.color || 'lightgray')
|
||||||
|
}, [xAxisArea, xAxis, animDurationMs, height, offset, ticks])
|
||||||
|
|
||||||
|
useEffect(() => { // Рисуем ось Y
|
||||||
|
if (!yAxis) return
|
||||||
|
yAxisArea().transition()
|
||||||
|
.duration(animDurationMs)
|
||||||
|
.call(d3.axisLeft(yAxis)
|
||||||
|
.tickSize((ticks?.y?.visible ?? false) ? -width + offset.left + offset.right : 0)
|
||||||
|
.ticks(ticks?.y?.count ?? 10) as any // TODO: Исправить тип
|
||||||
|
)
|
||||||
|
|
||||||
|
yAxisArea().selectAll('.tick line').attr('stroke', ticks?.color || 'lightgray')
|
||||||
|
}, [yAxisArea, yAxis, animDurationMs, width, offset, ticks])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCharts((oldCharts) => {
|
||||||
|
const charts: ChartRegistry[] = []
|
||||||
|
|
||||||
|
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 = Object.assign(
|
||||||
|
() => chartArea().select('.' + getGroupClass(dataset.key)),
|
||||||
|
{
|
||||||
|
...dataset,
|
||||||
|
y: getByAccessor(dataset.yAxis.accessor)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!newChart().node())
|
||||||
|
chartArea()
|
||||||
|
.append('g')
|
||||||
|
.attr('class', getGroupClass(newChart.key))
|
||||||
|
|
||||||
|
charts[chartIdx] = newChart
|
||||||
|
})
|
||||||
|
|
||||||
|
return charts
|
||||||
|
})
|
||||||
|
}, [chartArea, datasets])
|
||||||
|
|
||||||
|
const redrawCharts = useCallback(() => {
|
||||||
|
if (!data || !xAxis || !yAxis) return
|
||||||
|
|
||||||
|
charts.forEach((chart) => {
|
||||||
|
chart()
|
||||||
|
.attr('stroke', String(chart.color))
|
||||||
|
.attr('stroke-width', chart.width ?? 1)
|
||||||
|
.attr('opacity', chart.opacity ?? 1)
|
||||||
|
.attr('fill', 'none')
|
||||||
|
|
||||||
|
let d = data
|
||||||
|
|
||||||
|
switch (chart.type) {
|
||||||
|
case 'needle': {
|
||||||
|
const bars = chart()
|
||||||
|
.selectAll('line')
|
||||||
|
.data(data)
|
||||||
|
|
||||||
|
bars.exit().remove()
|
||||||
|
bars.enter().append('line')
|
||||||
|
|
||||||
|
const newBars = chart()
|
||||||
|
.selectAll('line')
|
||||||
|
.transition()
|
||||||
|
.duration(chart.animDurationMs ?? animDurationMs)
|
||||||
|
.attr('x1', (d: any) => xAxis(getX(d)))
|
||||||
|
.attr('x2', (d: any) => xAxis(getX(d)))
|
||||||
|
.attr('y1', height - offset.bottom - offset.top)
|
||||||
|
.attr('y2', (d: any) => yAxis(chart.y(d)))
|
||||||
|
|
||||||
|
chart.afterDraw?.(newBars)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'line': {
|
||||||
|
let line = d3.line()
|
||||||
|
.x(d => xAxis(getX(d)))
|
||||||
|
.y(d => yAxis(chart.y(d)))
|
||||||
|
|
||||||
|
switch (chart.nullValues || 'skip') {
|
||||||
|
case 'gap':
|
||||||
|
line = line.defined(d => (chart.y(d) ?? null) !== null && !Number.isNaN(chart.y(d)))
|
||||||
|
break
|
||||||
|
case 'skip':
|
||||||
|
d = d.filter(chart.y)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chart.optimization ?? true) {
|
||||||
|
const optimize = makePointsOptimizator((a, b) => chart.y(a) === chart.y(b))
|
||||||
|
d = optimize(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chart().selectAll('path').empty())
|
||||||
|
chart().append('path')
|
||||||
|
|
||||||
|
const lineElm = chart().selectAll('path')
|
||||||
|
.transition()
|
||||||
|
.duration(chart.animDurationMs ?? animDurationMs)
|
||||||
|
.attr('d', line(d as any))
|
||||||
|
|
||||||
|
chart.afterDraw?.(lineElm)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [charts, data, xAxis, yAxis, height])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
redrawCharts()
|
||||||
|
}, [redrawCharts])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoaderPortal show={loading}>
|
||||||
|
<div
|
||||||
|
{...other}
|
||||||
|
ref={rootRef}
|
||||||
|
className={`asb-d3-chart ${className}`}
|
||||||
|
style={{
|
||||||
|
width: givenWidth,
|
||||||
|
height: givenHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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={width - offset.left - offset.right} height={height - offset.top - offset.bottom} fill={backgroundColor} />
|
||||||
|
</g>
|
||||||
|
<D3MouseZone width={width} height={height} offset={offset}>
|
||||||
|
{(plugins?.tooltip?.enabled ?? true) && ( <D3Tooltip {...plugins?.tooltip} data={data} charts={charts} /> )}
|
||||||
|
{(plugins?.cursor?.enabled ?? true) && ( <D3Cursor {...plugins?.cursor} /> )}
|
||||||
|
</D3MouseZone>
|
||||||
|
</svg>
|
||||||
|
</D3ContextMenu>
|
||||||
|
) : (
|
||||||
|
<div className={'chart-empty'}>
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</LoaderPortal>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default D3Chart
|
102
src/components/d3/D3MouseZone.tsx
Normal file
102
src/components/d3/D3MouseZone.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { createContext, memo, ReactNode, useCallback, useContext, useMemo, useRef, useState } from 'react'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
import '@styles/d3.less'
|
||||||
|
|
||||||
|
export type D3MouseState = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type D3MouseZoneContext = {
|
||||||
|
mouseState: D3MouseState,
|
||||||
|
zone: (() => d3.Selection<any, any, null, undefined>) | null
|
||||||
|
zoneRect: DOMRect | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type D3MouseZoneProps = {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
offset: Record<string, number>
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const D3MouseZoneContext = createContext<D3MouseZoneContext>({
|
||||||
|
mouseState: {
|
||||||
|
x: NaN,
|
||||||
|
y: NaN,
|
||||||
|
visible: false
|
||||||
|
},
|
||||||
|
zone: null,
|
||||||
|
zoneRect: null
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useD3MouseZone = () => useContext(D3MouseZoneContext)
|
||||||
|
|
||||||
|
export const D3MouseZone = memo<D3MouseZoneProps>(({ width, height, offset, children }) => {
|
||||||
|
const zoneRef = useRef<SVGGElement>(null)
|
||||||
|
const rectRef = useRef<SVGRectElement>(null)
|
||||||
|
|
||||||
|
const [state, setState] = useState<D3MouseState>({ x: 0, y: 0, visible: false })
|
||||||
|
|
||||||
|
const subscribeEvent = useCallback((name: keyof SVGElementEventMap, handler: EventListenerOrEventListenerObject) => {
|
||||||
|
if (!rectRef.current) return null
|
||||||
|
rectRef.current.addEventListener(name, handler)
|
||||||
|
return () => {
|
||||||
|
if (!rectRef.current) return false
|
||||||
|
rectRef.current.removeEventListener(name, handler)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}, [rectRef.current])
|
||||||
|
|
||||||
|
const onMouse = useCallback((e: any) => {
|
||||||
|
const rect = e.target.getBoundingClientRect()
|
||||||
|
|
||||||
|
setState({
|
||||||
|
x: e.nativeEvent.clientX - rect.left,
|
||||||
|
y: e.nativeEvent.clientY - rect.top,
|
||||||
|
visible: true,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onMouseOut = useCallback((e: any) => {
|
||||||
|
setState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
visible: false,
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const childContext: D3MouseZoneContext = useMemo(() => {
|
||||||
|
const zone = zoneRef.current ? (() => d3.select(zoneRef.current)) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
mouseState: state,
|
||||||
|
zone,
|
||||||
|
zoneRect: zoneRef.current?.getBoundingClientRect() || null,
|
||||||
|
subscribe: subscribeEvent,
|
||||||
|
}
|
||||||
|
}, [zoneRef.current, state, subscribeEvent])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g ref={zoneRef} className={'asb-d3-mouse-zone'} transform={`translate(${offset.left}, ${offset.top})`}>
|
||||||
|
<rect
|
||||||
|
ref={rectRef}
|
||||||
|
pointerEvents={'all'}
|
||||||
|
className={'event-zone'}
|
||||||
|
width={width - offset.left - offset.right}
|
||||||
|
height={height - offset.top - offset.bottom}
|
||||||
|
fill={'none'}
|
||||||
|
stroke={'none'}
|
||||||
|
onMouseMove={onMouse}
|
||||||
|
onMouseOver={onMouse}
|
||||||
|
onMouseOut={onMouseOut}
|
||||||
|
/>
|
||||||
|
<D3MouseZoneContext.Provider value={childContext}>
|
||||||
|
{children}
|
||||||
|
</D3MouseZoneContext.Provider>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default D3MouseZone
|
@ -1,95 +0,0 @@
|
|||||||
import * as d3 from 'd3'
|
|
||||||
import { Selection } from 'd3'
|
|
||||||
import { HTMLAttributes, memo, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
|
|
||||||
import '@styles/d3.less'
|
|
||||||
|
|
||||||
export type D3TooltipProps = HTMLAttributes<HTMLDivElement> & {
|
|
||||||
targets: Selection<any, any, null, undefined>
|
|
||||||
onTargetHover?: (e: MouseEvent) => void
|
|
||||||
onTargetOut?: (e: MouseEvent) => void
|
|
||||||
width?: number
|
|
||||||
height?: number
|
|
||||||
content: (data: unknown, target: Selection<any, any, null, undefined>) => ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const D3Tooltip = memo<D3TooltipProps>(({ targets, width, height, content, onTargetHover, onTargetOut, className, style, ...other }) => {
|
|
||||||
const [target, setTarget] = useState<Selection<any, any, null, undefined>>()
|
|
||||||
const [position, setMousePosition] = useState<{ x: number, y: number }>({ x: 0, y: 0 })
|
|
||||||
const [visible, setVisible] = useState<boolean>(false)
|
|
||||||
|
|
||||||
const tooltipRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const onMouseOver = useCallback((e: MouseEvent) => {
|
|
||||||
onTargetHover?.(e)
|
|
||||||
setTarget(d3.select(e.target as Element))
|
|
||||||
setMousePosition({ x: e.pageX, y: e.pageY })
|
|
||||||
setVisible(true)
|
|
||||||
}, [onTargetHover])
|
|
||||||
|
|
||||||
const onMouseOut = useCallback((e: MouseEvent) => {
|
|
||||||
onTargetOut?.(e)
|
|
||||||
setVisible(false)
|
|
||||||
}, [onTargetOut])
|
|
||||||
|
|
||||||
const onMouseMove = useCallback((e: MouseEvent) => {
|
|
||||||
setMousePosition({ x: e.pageX, y: e.pageY })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.onmousemove = onMouseMove
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('a')
|
|
||||||
if (!targets) return
|
|
||||||
|
|
||||||
targets
|
|
||||||
.on('mouseover', onMouseOver)
|
|
||||||
.on('mouseout', onMouseOut)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
targets
|
|
||||||
.on('mouseover', null)
|
|
||||||
.on('mouseout', null)
|
|
||||||
}
|
|
||||||
}, [targets, onMouseOver, onMouseOut])
|
|
||||||
|
|
||||||
const data = useMemo(() => {
|
|
||||||
if (!target) return null
|
|
||||||
|
|
||||||
const data = target.data()[0]
|
|
||||||
return content(data, target)
|
|
||||||
}, [target])
|
|
||||||
|
|
||||||
const tooltipStyle = useMemo(() => {
|
|
||||||
const rect: DOMRect = tooltipRef.current?.getBoundingClientRect() ?? new DOMRect(0, 0, width, height)
|
|
||||||
|
|
||||||
const offsetX = -rect.width / 2 // По центру
|
|
||||||
const offsetY = 30 // Чуть выше курсора
|
|
||||||
|
|
||||||
const left = Math.max(0, Math.min(document.documentElement.clientWidth - rect.width, position.x + offsetX))
|
|
||||||
|
|
||||||
const bottom = document.documentElement.clientHeight - Math.max(position.y - offsetY, rect.height + offsetY)
|
|
||||||
|
|
||||||
return ({
|
|
||||||
...style,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
left,
|
|
||||||
bottom,
|
|
||||||
opacity: visible ? 1 : 0
|
|
||||||
})
|
|
||||||
}, [tooltipRef, style, width, height, position, visible])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={tooltipRef}
|
|
||||||
className={`asb-d3-tooltip bottom ${className ?? ''}`}
|
|
||||||
style={tooltipStyle}
|
|
||||||
{...other}
|
|
||||||
>
|
|
||||||
{data}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
@ -1,3 +1,2 @@
|
|||||||
export * from './D3Tooltip'
|
export * from './D3Chart'
|
||||||
|
export type { D3ChartProps, ChartAxis, ChartDataset, ChartDomain, ChartOffset } from './D3Chart'
|
||||||
export type { D3TooltipProps } from './D3Tooltip'
|
|
||||||
|
59
src/components/d3/plugins/D3ContextMenu.tsx
Normal file
59
src/components/d3/plugins/D3ContextMenu.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { memo, ReactElement, useMemo } from 'react'
|
||||||
|
import { ItemType } from 'antd/lib/menu/hooks/useItems'
|
||||||
|
import { Dropdown, Menu } from 'antd'
|
||||||
|
|
||||||
|
import { FunctionalValue, svgToDataURL, useFunctionalValue } from '@utils'
|
||||||
|
|
||||||
|
import { BasePluginSettings } from './base'
|
||||||
|
|
||||||
|
export type D3ContextMenuSettings = {
|
||||||
|
overlay?: FunctionalValue<ReactElement | null, [SVGSVGElement | null]>
|
||||||
|
downloadFilename?: string
|
||||||
|
onUpdate?: () => void
|
||||||
|
additionalMenuItems?: ItemType[]
|
||||||
|
trigger?: ('click' | 'hover' | 'contextMenu')[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type D3ContextMenuProps = BasePluginSettings & D3ContextMenuSettings & {
|
||||||
|
children: any
|
||||||
|
svg: SVGSVGElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const D3ContextMenu = memo<D3ContextMenuProps>(({
|
||||||
|
overlay: _overlay = null,
|
||||||
|
downloadFilename = 'chart',
|
||||||
|
additionalMenuItems,
|
||||||
|
onUpdate,
|
||||||
|
trigger = ['contextMenu'],
|
||||||
|
enabled = true,
|
||||||
|
children,
|
||||||
|
svg
|
||||||
|
}) => {
|
||||||
|
const overlay = useFunctionalValue(_overlay)
|
||||||
|
|
||||||
|
const menuItems = useMemo(() => {
|
||||||
|
const menuItems: ItemType[] = [
|
||||||
|
{ key: 'refresh', label: 'Обновить', onClick: onUpdate },
|
||||||
|
{ key: 'download', label: svg && (
|
||||||
|
<a href={svgToDataURL(svg)} download={`${downloadFilename}.svg`}>Сохранить</a>
|
||||||
|
)},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (additionalMenuItems)
|
||||||
|
menuItems.push(...additionalMenuItems)
|
||||||
|
|
||||||
|
return menuItems
|
||||||
|
}, [svg, downloadFilename, onUpdate, additionalMenuItems])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
overlay={overlay(svg) || ( <Menu items={menuItems} /> )}
|
||||||
|
disabled={!enabled}
|
||||||
|
trigger={trigger}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default D3ContextMenu
|
82
src/components/d3/plugins/D3Cursor.tsx
Normal file
82
src/components/d3/plugins/D3Cursor.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { SVGProps, useEffect, useMemo, useRef } from 'react'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
import { useD3MouseZone } from '@components/d3/D3MouseZone'
|
||||||
|
import { usePartialProps } from '@utils'
|
||||||
|
|
||||||
|
import { wrapPlugin } from './base'
|
||||||
|
|
||||||
|
import '@styles/d3.less'
|
||||||
|
|
||||||
|
export type D3CursorSettings = {
|
||||||
|
lineStyle?: SVGProps<SVGGElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultLineStyle: SVGProps<SVGGElement> = {
|
||||||
|
stroke: 'gray',
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeOpacity: 1,
|
||||||
|
className: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSettings: D3CursorSettings = {
|
||||||
|
lineStyle: defaultLineStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type D3CursorProps = D3CursorSettings
|
||||||
|
|
||||||
|
export const D3Cursor = wrapPlugin<D3CursorSettings>(((props) => {
|
||||||
|
const settings = usePartialProps(props, defaultSettings)
|
||||||
|
const lineStyle = usePartialProps(settings.lineStyle, defaultLineStyle)
|
||||||
|
|
||||||
|
const { mouseState, zoneRect } = useD3MouseZone()
|
||||||
|
const zoneRef = useRef(null)
|
||||||
|
|
||||||
|
const zone = useMemo(() => zoneRef.current ? (() => d3.select(zoneRef.current)) : null, [zoneRef.current])
|
||||||
|
|
||||||
|
const getXLine = useMemo(() => zone ? (() => zone().select('.tooltip-x-line')) : null, [zone])
|
||||||
|
const getYLine = useMemo(() => zone ? (() => zone().select('.tooltip-y-line')) : null, [zone])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!zone || !getXLine || !getYLine) return
|
||||||
|
const z = zone()
|
||||||
|
|
||||||
|
if (z.selectAll('line').empty()) {
|
||||||
|
z.append('line').attr('class', 'tooltip-x-line').style('pointer-events', 'none')
|
||||||
|
z.append('line').attr('class', 'tooltip-y-line').style('pointer-events', 'none')
|
||||||
|
}
|
||||||
|
|
||||||
|
getXLine()
|
||||||
|
.attr('y1', 0)
|
||||||
|
.attr('y2', zoneRect?.height ?? 0)
|
||||||
|
|
||||||
|
getYLine()
|
||||||
|
.attr('x1', 0)
|
||||||
|
.attr('x2', zoneRect?.width ?? 0)
|
||||||
|
}, [zone, getXLine, getYLine, zoneRect])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!getXLine || !getYLine || !mouseState) return
|
||||||
|
|
||||||
|
getXLine()
|
||||||
|
.attr('x1', mouseState.x)
|
||||||
|
.attr('x2', mouseState.x)
|
||||||
|
.attr('opacity', mouseState.visible ? '1' : '0')
|
||||||
|
|
||||||
|
getYLine()
|
||||||
|
.attr('y1', mouseState.y)
|
||||||
|
.attr('y2', mouseState.y)
|
||||||
|
.attr('opacity', mouseState.visible ? '1' : '0')
|
||||||
|
}, [mouseState, getXLine, getYLine])
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
{...lineStyle}
|
||||||
|
ref={zoneRef}
|
||||||
|
className={`cursor-zone ${lineStyle.className}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}), true)
|
||||||
|
|
||||||
|
export default D3Cursor
|
38
src/components/d3/plugins/D3Tooltip.tsx
Normal file
38
src/components/d3/plugins/D3Tooltip.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
import { useD3MouseZone } from '../D3MouseZone'
|
||||||
|
import { wrapPlugin } from './base'
|
||||||
|
|
||||||
|
import '@styles/d3.less'
|
||||||
|
|
||||||
|
export type BaseTooltip<DataType> = {
|
||||||
|
render?: (data: DataType) => ReactNode
|
||||||
|
width?: number | string
|
||||||
|
height?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccurateTooltip = { type: 'accurate' }
|
||||||
|
export type NearestTooltip = {
|
||||||
|
type: 'nearest'
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type D3TooltipSettings<DataType> = BaseTooltip<DataType> & (AccurateTooltip | NearestTooltip)
|
||||||
|
|
||||||
|
export type D3TooltipProps<DataType = Record<string, any>> = Partial<D3TooltipSettings<DataType>> & {
|
||||||
|
charts: any[],
|
||||||
|
data: DataType[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const D3Tooltip = wrapPlugin<D3TooltipProps>(({ type = 'accurate', width, height, render, data, charts }) => {
|
||||||
|
const { mouseState, zoneRect } = useD3MouseZone()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<foreignObject>
|
||||||
|
{mouseState.visible && render && render(data)}
|
||||||
|
</foreignObject>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default D3Tooltip
|
15
src/components/d3/plugins/base.tsx
Normal file
15
src/components/d3/plugins/base.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { FC, memo } from 'react'
|
||||||
|
|
||||||
|
export type BasePluginSettings = {
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wrapPlugin = <S,>(Component: FC<S>, defaultEnabled?: boolean) => {
|
||||||
|
const wrappedComponent = memo<S & BasePluginSettings>(({ enabled, ...props }) => {
|
||||||
|
if (!(enabled ?? defaultEnabled)) return <></>
|
||||||
|
|
||||||
|
return <Component {...(props as S)} />
|
||||||
|
})
|
||||||
|
|
||||||
|
return wrappedComponent
|
||||||
|
}
|
4
src/components/d3/plugins/index.ts
Normal file
4
src/components/d3/plugins/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './base'
|
||||||
|
export * from './D3ContextMenu'
|
||||||
|
export * from './D3Cursor'
|
||||||
|
export * from './D3Tooltip'
|
@ -1,125 +1,93 @@
|
|||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useCallback, useMemo, useState } from 'react'
|
||||||
import * as d3 from 'd3'
|
|
||||||
|
|
||||||
import { D3Tooltip } from '@components/d3'
|
import { D3Chart } from '@components/d3'
|
||||||
import { Grid, GridItem } from '@components/Grid'
|
|
||||||
import { makePointsOptimizator } from '@utils/functions/chart'
|
|
||||||
import { formatDate } from '@utils'
|
|
||||||
|
|
||||||
import '@styles/detected_operations.less'
|
import '@styles/detected_operations.less'
|
||||||
|
|
||||||
const defaultBar = {
|
// Палитра: https://colorhunt.co/palette/f9f2ed3ab0ffffb562f87474
|
||||||
width: 2, /// Толщина столбцов графика
|
|
||||||
color: 'royalblue', /// Цвет столбца операций
|
const chartDatasets = [{
|
||||||
hover: 'red', /// Цвет выделеного столбца операций
|
key: 'normLine',
|
||||||
|
type: 'area',
|
||||||
|
width: 2,
|
||||||
|
color: '#FFB562',
|
||||||
|
opacity: 0.3,
|
||||||
|
yAxis: {
|
||||||
|
type: 'linear',
|
||||||
|
accessor: (row) => row.operationValue?.standardValue,
|
||||||
|
},
|
||||||
|
fillColor: '#FFB562'
|
||||||
|
}, {
|
||||||
|
key: 'normBars',
|
||||||
|
type: 'needle',
|
||||||
|
width: 2,
|
||||||
|
color: '#FFB562',
|
||||||
|
opacity: 0.65,
|
||||||
|
yAxis: {
|
||||||
|
type: 'linear',
|
||||||
|
accessor: (row) => row.operationValue?.standardValue,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
key: 'bars',
|
||||||
|
type: 'needle',
|
||||||
|
width: 2,
|
||||||
|
color: '#3AB0FF',
|
||||||
|
opacity: 0.75,
|
||||||
|
yAxis: {
|
||||||
|
type: 'linear',
|
||||||
|
accessor: 'value',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
key: 'target',
|
||||||
|
type: 'line',
|
||||||
|
color: '#F87474',
|
||||||
|
yAxis: {
|
||||||
|
type: 'linear',
|
||||||
|
accessor: (row) => row.operationValue?.targetValue,
|
||||||
|
},
|
||||||
|
nullValues: 'gap',
|
||||||
|
}]
|
||||||
|
|
||||||
|
const xAxis = {
|
||||||
|
type: 'time',
|
||||||
|
accessor: (row) => new Date(row.dateStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTarget = {
|
export const OperationsChart = memo(({ data, yDomain, height }) => {
|
||||||
width: 1, /// Толщина линий целевых параметров
|
const [isChartLoading, setIsChartLoading] = useState(false)
|
||||||
color: 'red', /// Цвет линий целевых параметров
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOffsets = {
|
const domain = useMemo(() => ({
|
||||||
top: 10,
|
y: { min: 0, max: yDomain },
|
||||||
bottom: 30,
|
// x: { min: new Date('2021-11-04 03:57'), max: new Date('2022-06-17 13:16') }
|
||||||
left: 50,
|
}), [yDomain])
|
||||||
right: 10,
|
|
||||||
}
|
|
||||||
|
|
||||||
const optimizePoints = makePointsOptimizator((a, b) => a.target === b.target)
|
const onChartUpdate = useCallback(() => {
|
||||||
|
setIsChartLoading(true)
|
||||||
export const OperationsChart = memo(({ data, yDomain, width, height, offsets, barsStyle, targetLineStyle }) => {
|
setTimeout(() => setIsChartLoading(false), 2000)
|
||||||
const [ref, setRef] = useState(null)
|
}, [])
|
||||||
const [bars, setBars] = useState()
|
|
||||||
|
|
||||||
const offset = useMemo(() => ({ ...defaultOffsets, ...offsets }), [offsets])
|
|
||||||
const bar = useMemo(() => ({ ...defaultBar, ...barsStyle }), [barsStyle])
|
|
||||||
const target = useMemo(() => ({ ...defaultTarget, ...targetLineStyle }), [targetLineStyle])
|
|
||||||
|
|
||||||
const axisX = useRef(null)
|
|
||||||
const axisY = useRef(null)
|
|
||||||
const targetPath = useRef(null)
|
|
||||||
const chartBars = useRef(null)
|
|
||||||
|
|
||||||
const w = useMemo(() => Number.isFinite(+width) ? +width : ref?.offsetWidth, [width, ref])
|
|
||||||
const h = useMemo(() => Number.isFinite(+height) ? +height : ref?.offsetHeight, [height, ref])
|
|
||||||
|
|
||||||
const d = useMemo(() => data.map((row) => ({
|
|
||||||
date: new Date(row.dateStart),
|
|
||||||
value: row.value,
|
|
||||||
target: row.operationValue?.targetValue,
|
|
||||||
})), [data]) // Нормализуем данные для графика
|
|
||||||
|
|
||||||
const x = useMemo(() => d3
|
|
||||||
.scaleTime()
|
|
||||||
.range([0, w - offset.left - offset.right])
|
|
||||||
.domain([
|
|
||||||
d3.min(d, d => d.date),
|
|
||||||
d3.max(d, d => d.date)
|
|
||||||
])
|
|
||||||
, [w, d, offset]) // Создаём ось X
|
|
||||||
|
|
||||||
const y = useMemo(() => d3
|
|
||||||
.scaleLinear()
|
|
||||||
.range([h - offset.bottom - offset.top, 0])
|
|
||||||
.domain([0, yDomain ?? d3.max(d, d => d.value)])
|
|
||||||
, [h, d, offset, yDomain]) // Создаём ось Y
|
|
||||||
|
|
||||||
const targetLine = useMemo(() => d3.line()
|
|
||||||
.x(d => x(d.date))
|
|
||||||
.y(d => y(d.target))
|
|
||||||
.defined(d => (d.target ?? null) !== null && !Number.isNaN(d.target))
|
|
||||||
, [x, y])
|
|
||||||
|
|
||||||
useEffect(() => { d3.select(targetPath.current).attr('d', targetLine(optimizePoints(d))) }, [d, targetLine, target.color]) // Рисуем линию целевого значения
|
|
||||||
useEffect(() => { d3.select(axisX.current).call(d3.axisBottom(x)) }, [axisX, x]) // Рисуем ось X
|
|
||||||
useEffect(() => { d3.select(axisY.current).call(d3.axisLeft(y)) }, [axisY, y]) // Рисуем ось Y
|
|
||||||
|
|
||||||
useEffect(() => { // Рисуем столбики операций
|
|
||||||
const bars = d3
|
|
||||||
.select(chartBars.current)
|
|
||||||
.selectAll('line')
|
|
||||||
.data(d)
|
|
||||||
|
|
||||||
bars.exit().remove() // Удаляем лишние линии
|
|
||||||
bars.enter().append('line') // Добавляем новые, если нужно
|
|
||||||
|
|
||||||
const newBars = d3.select(chartBars.current)
|
|
||||||
.selectAll('line')
|
|
||||||
.attr('x1', d => x(d.date)) // Присваиваем значения
|
|
||||||
.attr('x2', d => x(d.date))
|
|
||||||
.attr('y1', h - offset.bottom - offset.top)
|
|
||||||
.attr('y2', d => y(d.value))
|
|
||||||
|
|
||||||
setBars(newBars)
|
|
||||||
}, [d, x, y, h, offset])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'page-left'} ref={setRef}>
|
<D3Chart
|
||||||
<svg width={width ?? '100%'} height={height ?? '100%'}>
|
xAxis={xAxis}
|
||||||
<g ref={axisX} className={'axis x'} transform={`translate(${offset.left}, ${h - offset.bottom})`} />
|
domain={domain}
|
||||||
<g ref={axisY} className={'axis y'} transform={`translate(${offset.left}, ${offset.top})`} />
|
datasets={chartDatasets}
|
||||||
<g transform={`translate(${offset.left}, ${offset.top})`}>
|
data={data}
|
||||||
<g ref={chartBars} stroke={bar.color} strokeWidth={bar.width} />
|
loading={isChartLoading}
|
||||||
<path ref={targetPath} stroke={target.color} strokeWidth={target.width} fill={'none'} />
|
height={height}
|
||||||
</g>
|
plugins={{
|
||||||
</svg>
|
tooltip: {
|
||||||
<D3Tooltip
|
enabled: true,
|
||||||
targets={bars}
|
},
|
||||||
onTargetHover={(e) => d3.select(e.target).attr('stroke', bar.hover)}
|
cursor: {
|
||||||
onTargetOut={(e) => d3.select(e.target).attr('stroke', bar.color)}
|
enabled: true,
|
||||||
content={(v) => (
|
},
|
||||||
<Grid>
|
menu: {
|
||||||
<GridItem row={1} col={1}>Дата:</GridItem>
|
enabled: true,
|
||||||
<GridItem row={1} col={2}>{formatDate(v.date)}</GridItem>
|
onUpdate: onChartUpdate,
|
||||||
<GridItem row={2} col={1}>Ключевой параметр:</GridItem>
|
}
|
||||||
<GridItem row={2} col={2}>{(v.value || 0).toFixed(2)}</GridItem>
|
}}
|
||||||
<GridItem row={3} col={1}>Целевой параметр:</GridItem>
|
ticks={{ color: '#F9F2ED', y: { visible: true }, x: { visible: true } }}
|
||||||
<GridItem row={3} col={2}>{(v.target || 0).toFixed(2)}</GridItem>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -129,13 +129,7 @@ const Operations = memo(() => {
|
|||||||
</div>
|
</div>
|
||||||
<LoaderPortal show={isLoading}>
|
<LoaderPortal show={isLoading}>
|
||||||
<div className={'page-main'}>
|
<div className={'page-main'}>
|
||||||
{data.operations ? (
|
|
||||||
<OperationsChart data={data.operations} height={'50vh'} yDomain={yDomain} />
|
<OperationsChart data={data.operations} height={'50vh'} yDomain={yDomain} />
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', height: '50vh', width: '100%', justifyContent: 'center', alignItems: 'center' }}>
|
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<OperationsTable data={data.stats} height={'20vh'} />
|
<OperationsTable data={data.stats} height={'20vh'} />
|
||||||
</div>
|
</div>
|
||||||
</LoaderPortal>
|
</LoaderPortal>
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
.asb-d3-tooltip {
|
.asb-d3-chart {
|
||||||
|
|
||||||
|
& .tooltip {
|
||||||
@color: white;
|
@color: white;
|
||||||
@bg-color: rgba(0, 0, 0, .75);
|
@bg-color: rgba(0, 0, 0, .75);
|
||||||
|
|
||||||
@ -28,3 +30,12 @@
|
|||||||
margin-left: -@arrow-size;
|
margin-left: -@arrow-size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& .chart-empty {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user