* Добавлен компонент графика d3

* Добавлены плагины для графиков
* Empty перемещён из опрелённых операций в компонент графика
* добавлена зависимость usehooks-ts
This commit is contained in:
goodmice 2022-06-25 16:03:08 +05:00
parent 8db3476c0e
commit 1a05704375
14 changed files with 836 additions and 248 deletions

27
package-lock.json generated
View File

@ -28,6 +28,7 @@
"react-router-dom": "^6.3.0",
"rxjs": "^7.5.5",
"typescript": "^4.7.2",
"usehooks-ts": "^2.6.0",
"web-vitals": "^2.1.4"
},
"devDependencies": {
@ -11065,9 +11066,9 @@
}
},
"node_modules/undici": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.4.0.tgz",
"integrity": "sha512-A1SRXysDg7J+mVP46jF+9cKANw0kptqSFZ8tGyL+HBiv0K1spjxPX8Z4EGu+Eu6pjClJUBdnUPlxrOafR668/g==",
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.5.1.tgz",
"integrity": "sha512-MEvryPLf18HvlCbLSzCW0U00IMftKGI5udnjrQbC5D4P0Hodwffhv+iGfWuJwg16Y/TK11ZFK8i+BPVW2z/eAw==",
"dev": true,
"engines": {
"node": ">=12.18"
@ -11171,6 +11172,14 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -19998,9 +20007,9 @@
"optional": true
},
"undici": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.4.0.tgz",
"integrity": "sha512-A1SRXysDg7J+mVP46jF+9cKANw0kptqSFZ8tGyL+HBiv0K1spjxPX8Z4EGu+Eu6pjClJUBdnUPlxrOafR668/g==",
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.5.1.tgz",
"integrity": "sha512-MEvryPLf18HvlCbLSzCW0U00IMftKGI5udnjrQbC5D4P0Hodwffhv+iGfWuJwg16Y/TK11ZFK8i+BPVW2z/eAw==",
"dev": true
},
"unicode-canonical-property-names-ecmascript": {
@ -20067,6 +20076,12 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -23,6 +23,7 @@
"react-router-dom": "^6.3.0",
"rxjs": "^7.5.5",
"typescript": "^4.7.2",
"usehooks-ts": "^2.6.0",
"web-vitals": "^2.1.4"
},
"scripts": {

View 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

View 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

View File

@ -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>
)
})

View File

@ -1,3 +1,2 @@
export * from './D3Tooltip'
export type { D3TooltipProps } from './D3Tooltip'
export * from './D3Chart'
export type { D3ChartProps, ChartAxis, ChartDataset, ChartDomain, ChartOffset } from './D3Chart'

View 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

View 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

View 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

View 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
}

View File

@ -0,0 +1,4 @@
export * from './base'
export * from './D3ContextMenu'
export * from './D3Cursor'
export * from './D3Tooltip'

View File

@ -1,125 +1,93 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import * as d3 from 'd3'
import { memo, useCallback, useMemo, useState } from 'react'
import { D3Tooltip } from '@components/d3'
import { Grid, GridItem } from '@components/Grid'
import { makePointsOptimizator } from '@utils/functions/chart'
import { formatDate } from '@utils'
import { D3Chart } from '@components/d3'
import '@styles/detected_operations.less'
const defaultBar = {
width: 2, /// Толщина столбцов графика
color: 'royalblue', /// Цвет столбца операций
hover: 'red', /// Цвет выделеного столбца операций
// Палитра: https://colorhunt.co/palette/f9f2ed3ab0ffffb562f87474
const chartDatasets = [{
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 = {
width: 1, /// Толщина линий целевых параметров
color: 'red', /// Цвет линий целевых параметров
}
export const OperationsChart = memo(({ data, yDomain, height }) => {
const [isChartLoading, setIsChartLoading] = useState(false)
const defaultOffsets = {
top: 10,
bottom: 30,
left: 50,
right: 10,
}
const domain = useMemo(() => ({
y: { min: 0, max: yDomain },
// x: { min: new Date('2021-11-04 03:57'), max: new Date('2022-06-17 13:16') }
}), [yDomain])
const optimizePoints = makePointsOptimizator((a, b) => a.target === b.target)
export const OperationsChart = memo(({ data, yDomain, width, height, offsets, barsStyle, targetLineStyle }) => {
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])
const onChartUpdate = useCallback(() => {
setIsChartLoading(true)
setTimeout(() => setIsChartLoading(false), 2000)
}, [])
return (
<div className={'page-left'} ref={setRef}>
<svg width={width ?? '100%'} height={height ?? '100%'}>
<g ref={axisX} className={'axis x'} transform={`translate(${offset.left}, ${h - offset.bottom})`} />
<g ref={axisY} className={'axis y'} transform={`translate(${offset.left}, ${offset.top})`} />
<g transform={`translate(${offset.left}, ${offset.top})`}>
<g ref={chartBars} stroke={bar.color} strokeWidth={bar.width} />
<path ref={targetPath} stroke={target.color} strokeWidth={target.width} fill={'none'} />
</g>
</svg>
<D3Tooltip
targets={bars}
onTargetHover={(e) => d3.select(e.target).attr('stroke', bar.hover)}
onTargetOut={(e) => d3.select(e.target).attr('stroke', bar.color)}
content={(v) => (
<Grid>
<GridItem row={1} col={1}>Дата:</GridItem>
<GridItem row={1} col={2}>{formatDate(v.date)}</GridItem>
<GridItem row={2} col={1}>Ключевой параметр:</GridItem>
<GridItem row={2} col={2}>{(v.value || 0).toFixed(2)}</GridItem>
<GridItem row={3} col={1}>Целевой параметр:</GridItem>
<GridItem row={3} col={2}>{(v.target || 0).toFixed(2)}</GridItem>
</Grid>
)}
/>
</div>
<D3Chart
xAxis={xAxis}
domain={domain}
datasets={chartDatasets}
data={data}
loading={isChartLoading}
height={height}
plugins={{
tooltip: {
enabled: true,
},
cursor: {
enabled: true,
},
menu: {
enabled: true,
onUpdate: onChartUpdate,
}
}}
ticks={{ color: '#F9F2ED', y: { visible: true }, x: { visible: true } }}
/>
)
})

View File

@ -129,13 +129,7 @@ const Operations = memo(() => {
</div>
<LoaderPortal show={isLoading}>
<div className={'page-main'}>
{data.operations ? (
<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>
)}
<OperationsChart data={data.operations} height={'50vh'} yDomain={yDomain} />
<OperationsTable data={data.stats} height={'20vh'} />
</div>
</LoaderPortal>

View File

@ -1,30 +1,41 @@
.asb-d3-tooltip {
@color: white;
@bg-color: rgba(0, 0, 0, .75);
.asb-d3-chart {
color: @color;
position: absolute;
padding: 0;
border: none;
border-radius: 2px;
background-color: @bg-color;
& .tooltip {
@color: white;
@bg-color: rgba(0, 0, 0, .75);
transition: opacity .1s ease-out;
@arrow-size: 8px;
&::after {
content: ' ';
color: @color;
position: absolute;
width: 0;
height: 0;
border: @arrow-size solid transparent;
padding: 0;
border: none;
border-radius: 2px;
background-color: @bg-color;
transition: opacity .1s ease-out;
@arrow-size: 8px;
&::after {
content: ' ';
position: absolute;
width: 0;
height: 0;
border: @arrow-size solid transparent;
}
&.bottom::after {
border-top-color: @bg-color;
top: 100%;
left: 50%;
margin-left: -@arrow-size;
}
}
&.bottom::after {
border-top-color: @bg-color;
top: 100%;
left: 50%;
margin-left: -@arrow-size;
& .chart-empty {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
}