forked from ddrilling/asb_cloud_front
* TVD перенесен на D3
* Добавлен плагин для отображения легенды на графике * Код отрисовки графиков вынесен в функций и разделён по файлам * Добавлен тип графика "Точечный" * улучшена работа с типами в графиках, часть компонентов теперь шаблонные * улучшена работа usePartialProps
This commit is contained in:
parent
fa751240ba
commit
01f1f98e53
@ -5,21 +5,35 @@ import { Empty } from 'antd'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
import LoaderPortal from '@components/LoaderPortal'
|
||||
import { formatDate, isDev, makePointsOptimizator, usePartialProps } from '@utils'
|
||||
import { isDev, usePartialProps } from '@utils'
|
||||
|
||||
import D3MouseZone from './D3MouseZone'
|
||||
import {
|
||||
renderLine,
|
||||
renderPoint,
|
||||
renderNeedle
|
||||
} from './renders'
|
||||
import {
|
||||
BasePluginSettings,
|
||||
D3ContextMenu,
|
||||
D3ContextMenuSettings,
|
||||
D3Cursor,
|
||||
D3CursorSettings,
|
||||
D3Legend,
|
||||
D3LegendSettings,
|
||||
D3Tooltip,
|
||||
D3TooltipSettings,
|
||||
} from './plugins'
|
||||
import type {
|
||||
ChartAxis,
|
||||
ChartDataset,
|
||||
ChartDomain,
|
||||
ChartOffset,
|
||||
ChartRegistry,
|
||||
ChartTicks
|
||||
} from './types'
|
||||
|
||||
import '@styles/d3.less'
|
||||
import { ChartAxis, ChartDataset, ChartDomain, ChartOffset, ChartRegistry, ChartTicks, DefaultDataType } from './types'
|
||||
|
||||
const defaultOffsets: ChartOffset = {
|
||||
top: 10,
|
||||
@ -28,17 +42,12 @@ const defaultOffsets: ChartOffset = {
|
||||
right: 10,
|
||||
}
|
||||
|
||||
const defaultXAxisConfig: ChartAxis<DefaultDataType> = {
|
||||
type: 'time',
|
||||
accessor: (d: any) => new Date(d.date)
|
||||
}
|
||||
|
||||
const getGroupClass = (key: string | number) => `chart-id-${key}`
|
||||
|
||||
const getByAccessor = <T extends Record<string, any>>(accessor: string | ((d: T) => any)) => {
|
||||
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: T) => d[accessor]
|
||||
return (d) => d[accessor]
|
||||
}
|
||||
|
||||
const createAxis = <DataType,>(config: ChartAxis<DataType>) => {
|
||||
@ -47,7 +56,7 @@ const createAxis = <DataType,>(config: ChartAxis<DataType>) => {
|
||||
return d3.scaleLinear()
|
||||
}
|
||||
|
||||
export type D3ChartProps<DataType = DefaultDataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
export type D3ChartProps<DataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
xAxis: ChartAxis<DataType>
|
||||
datasets: ChartDataset<DataType>[]
|
||||
data?: DataType[] | Record<string, DataType[]>
|
||||
@ -63,10 +72,16 @@ export type D3ChartProps<DataType = DefaultDataType> = React.DetailedHTMLProps<R
|
||||
menu?: BasePluginSettings & D3ContextMenuSettings
|
||||
tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
|
||||
cursor?: BasePluginSettings & D3CursorSettings
|
||||
legend?: BasePluginSettings & D3LegendSettings
|
||||
}
|
||||
}
|
||||
|
||||
export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
const getDefaultXAxisConfig = <DataType,>(): ChartAxis<DataType> => ({
|
||||
type: 'time',
|
||||
accessor: (d: any) => new Date(d.date)
|
||||
})
|
||||
|
||||
const _D3Chart = <DataType extends Record<string, unknown>>({
|
||||
className = '',
|
||||
xAxis: _xAxisConfig,
|
||||
datasets,
|
||||
@ -81,8 +96,9 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
ticks,
|
||||
plugins,
|
||||
...other
|
||||
}) => {
|
||||
const xAxisConfig = usePartialProps(_xAxisConfig, defaultXAxisConfig)
|
||||
}: D3ChartProps<DataType>) => {
|
||||
const xAxisConfig = usePartialProps<ChartAxis<DataType>>(_xAxisConfig, getDefaultXAxisConfig)
|
||||
|
||||
const offset = usePartialProps(_offset, defaultOffsets)
|
||||
|
||||
const [svgRef, setSvgRef] = useState<SVGSVGElement | null>(null)
|
||||
@ -94,15 +110,15 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
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<DefaultDataType>[]>([])
|
||||
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])
|
||||
|
||||
@ -125,7 +141,7 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
}
|
||||
|
||||
return xAxis
|
||||
}, [xAxisConfig, getX, data, domain, width, offset])
|
||||
}, [xAxisConfig, data, domain, width, offset])
|
||||
|
||||
const yAxis = useMemo(() => {
|
||||
if (!data) return
|
||||
@ -201,7 +217,7 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
console.warn(`Ключ датасета "${datasets[i].key}" неуникален (индексы ${i} и ${j})!`)
|
||||
|
||||
setCharts((oldCharts) => {
|
||||
const charts: ChartRegistry[] = []
|
||||
const charts: ChartRegistry<DataType>[] = []
|
||||
|
||||
for (const chart of oldCharts) { // Удаляем ненужные графики
|
||||
if (datasets.find(({ key }) => key === chart.key))
|
||||
@ -215,9 +231,14 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
if (chartIdx < 0)
|
||||
chartIdx = charts.length
|
||||
|
||||
const newChart: ChartRegistry = Object.assign(
|
||||
() => chartArea().select('.' + getGroupClass(dataset.key)),
|
||||
const newChart: ChartRegistry<DataType> = Object.assign(
|
||||
() => chartArea().select('.' + getGroupClass(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),
|
||||
@ -225,6 +246,9 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
}
|
||||
)
|
||||
|
||||
if (newChart.type === 'line')
|
||||
newChart.optimization = false
|
||||
|
||||
if (!newChart().node())
|
||||
chartArea()
|
||||
.append('g')
|
||||
@ -235,100 +259,42 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
|
||||
return charts
|
||||
})
|
||||
}, [chartArea, datasets])
|
||||
}, [xAxisConfig, chartArea, datasets, animDurationMs])
|
||||
|
||||
const redrawCharts = useCallback(() => {
|
||||
if (!data || !xAxis || !yAxis) return
|
||||
|
||||
charts.forEach((chart) => {
|
||||
chart()
|
||||
.attr('color', chart.color ?? null)
|
||||
.attr('color', chart.color || null)
|
||||
.attr('stroke', 'currentColor')
|
||||
.attr('stroke-width', chart.width ?? 1)
|
||||
.attr('opacity', chart.opacity ?? 1)
|
||||
.attr('stroke-width', chart.width ?? null)
|
||||
.attr('opacity', chart.opacity ?? null)
|
||||
.attr('fill', 'none')
|
||||
|
||||
let d = Array.isArray(data) ? data : data[String(chart.key).split(':')[0]]
|
||||
if (!d) return
|
||||
let elms
|
||||
let chartData = Array.isArray(data) ? data : data[String(chart.key).split(':')[0]]
|
||||
if (!chartData) return
|
||||
|
||||
switch (chart.type) {
|
||||
case 'needle':
|
||||
elms = chart()
|
||||
.selectAll('line')
|
||||
.data(d)
|
||||
|
||||
elms.exit().remove()
|
||||
elms.enter().append('line')
|
||||
|
||||
elms = chart()
|
||||
.selectAll('line')
|
||||
.transition()
|
||||
.duration(chart.animDurationMs ?? animDurationMs)
|
||||
.attr('x1', (d: any) => xAxis(chart.x(d)))
|
||||
.attr('x2', (d: any) => xAxis(chart.x(d)))
|
||||
.attr('y1', height - offset.bottom - offset.top)
|
||||
.attr('y2', (d: any) => yAxis(chart.y(d)))
|
||||
|
||||
chartData = renderNeedle<DataType>(xAxis, yAxis, chart, chartData, height, offset)
|
||||
break
|
||||
case 'line': {
|
||||
let line = d3.line()
|
||||
.x(d => xAxis(chart.x(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')
|
||||
|
||||
chart().selectAll('path')
|
||||
.transition()
|
||||
.duration(chart.animDurationMs ?? animDurationMs)
|
||||
.attr('d', line(d as any))
|
||||
|
||||
const radius = chart.point?.radius ?? 3
|
||||
elms = chart()
|
||||
.selectAll('circle')
|
||||
.data(d)
|
||||
|
||||
elms.exit().remove()
|
||||
elms.enter().append('circle')
|
||||
|
||||
elms = chart()
|
||||
.selectAll('circle')
|
||||
.transition()
|
||||
.duration(chart.animDurationMs ?? animDurationMs)
|
||||
.attr('cx', (d: any) => xAxis(chart.x(d)))
|
||||
.attr('cy', (d: any) => yAxis(chart.y(d)))
|
||||
.attr('r', radius)
|
||||
.attr('stroke-width', chart.point?.strokeWidth ?? null)
|
||||
.attr('stroke', chart.point?.strokeColor ?? null)
|
||||
.attr('fill', chart.point?.fillColor ?? null)
|
||||
|
||||
case 'line':
|
||||
chartData = renderLine<DataType>(xAxis, yAxis, chart, chartData)
|
||||
break
|
||||
case 'point':
|
||||
chartData = renderPoint<DataType>(xAxis, yAxis, chart, chartData)
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if (chart.point)
|
||||
renderPoint<DataType>(xAxis, yAxis, chart, chartData)
|
||||
|
||||
chart.afterDraw?.(chart)
|
||||
})
|
||||
}, [charts, data, xAxis, yAxis, height])
|
||||
}, [charts, data, xAxis, yAxis, height, offset])
|
||||
|
||||
useEffect(() => {
|
||||
redrawCharts()
|
||||
@ -353,11 +319,16 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
<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} />
|
||||
<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}>
|
||||
{(plugins?.cursor?.enabled ?? true) && ( <D3Cursor {...plugins?.cursor} /> )}
|
||||
{(plugins?.tooltip?.enabled ?? true) && ( <D3Tooltip {...plugins?.tooltip} charts={charts} /> )}
|
||||
<D3Cursor {...plugins?.cursor} />
|
||||
<D3Legend<DataType> charts={charts} {...plugins?.legend} />
|
||||
<D3Tooltip<DataType> charts={charts} {...plugins?.tooltip} />
|
||||
</D3MouseZone>
|
||||
</svg>
|
||||
</D3ContextMenu>
|
||||
@ -369,6 +340,8 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
</div>
|
||||
</LoaderPortal>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const D3Chart = memo(_D3Chart) as typeof _D3Chart
|
||||
|
||||
export default D3Chart
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { createContext, memo, ReactNode, useCallback, useContext, useMemo, useRef, useState } from 'react'
|
||||
import { createContext, memo, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
import '@styles/d3.less'
|
||||
@ -25,7 +25,7 @@ export type D3MouseZoneProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const D3MouseZoneContext = createContext<D3MouseZoneContext>({
|
||||
export const defaultMouseZoneContext: D3MouseZoneContext = {
|
||||
mouseState: {
|
||||
x: NaN,
|
||||
y: NaN,
|
||||
@ -34,15 +34,17 @@ export const D3MouseZoneContext = createContext<D3MouseZoneContext>({
|
||||
zone: null,
|
||||
zoneRect: null,
|
||||
subscribe: () => null
|
||||
})
|
||||
}
|
||||
|
||||
export const D3MouseZoneContext = createContext<D3MouseZoneContext>(defaultMouseZoneContext)
|
||||
|
||||
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 [childContext, setChildContext] = useState<D3MouseZoneContext>(defaultMouseZoneContext)
|
||||
|
||||
const subscribeEvent: SubscribeFunction = useCallback((name, handler) => {
|
||||
if (!rectRef.current) return null
|
||||
@ -54,6 +56,17 @@ export const D3MouseZone = memo<D3MouseZoneProps>(({ width, height, offset, chil
|
||||
}
|
||||
}, [rectRef.current])
|
||||
|
||||
const updateContext = useCallback(() => {
|
||||
const zone = rectRef.current ? (() => d3.select(rectRef.current)) : null
|
||||
|
||||
setChildContext({
|
||||
mouseState: state,
|
||||
zone,
|
||||
zoneRect: rectRef.current?.getBoundingClientRect() || null,
|
||||
subscribe: subscribeEvent,
|
||||
})
|
||||
}, [rectRef.current, state, subscribeEvent])
|
||||
|
||||
const onMouse = useCallback((e: any) => {
|
||||
const rect = e.target.getBoundingClientRect()
|
||||
|
||||
@ -71,25 +84,18 @@ export const D3MouseZone = memo<D3MouseZoneProps>(({ width, height, offset, chil
|
||||
}))
|
||||
}, [])
|
||||
|
||||
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])
|
||||
useEffect(() => {
|
||||
updateContext()
|
||||
}, [updateContext])
|
||||
|
||||
return (
|
||||
<g ref={zoneRef} className={'asb-d3-mouse-zone'} transform={`translate(${offset.left}, ${offset.top})`}>
|
||||
<g 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}
|
||||
width={Math.max(width - offset.left - offset.right, 0)}
|
||||
height={Math.max(height - offset.top - offset.bottom, 0)}
|
||||
fill={'none'}
|
||||
stroke={'none'}
|
||||
onMouseMove={onMouse}
|
||||
|
142
src/components/d3/plugins/D3Legend.tsx
Normal file
142
src/components/d3/plugins/D3Legend.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { Property } from 'csstype'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
import { ChartRegistry } from '@components/d3/types'
|
||||
import { useD3MouseZone } from '@components/d3/D3MouseZone'
|
||||
import { usePartialProps } from '@utils'
|
||||
|
||||
import { wrapPlugin } from './base'
|
||||
|
||||
export type LegendPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center'
|
||||
|
||||
export type D3LegendSettings = {
|
||||
width?: number
|
||||
height?: number
|
||||
offset?: {
|
||||
x?: number
|
||||
y?: number
|
||||
}
|
||||
position?: LegendPosition
|
||||
color?: Property.Color
|
||||
backgroundColor?: Property.Color
|
||||
type?: 'horizontal' | 'vertical'
|
||||
}
|
||||
|
||||
const defaultOffset = { x: 10, y: 10 }
|
||||
|
||||
export type D3LegendProps<DataType> = D3LegendSettings & {
|
||||
charts: ChartRegistry<DataType>[]
|
||||
}
|
||||
|
||||
const _D3Legend = <DataType, >({
|
||||
charts,
|
||||
width,
|
||||
height,
|
||||
offset: _offset,
|
||||
position = 'top-center',
|
||||
backgroundColor = 'transparent',
|
||||
color = 'black',
|
||||
type = 'vertical',
|
||||
}: D3LegendProps<DataType>) => {
|
||||
const legendRef = useRef<SVGGElement>(null)
|
||||
const offset = usePartialProps(_offset, defaultOffset)
|
||||
|
||||
const { zoneRect } = useD3MouseZone()
|
||||
|
||||
const maxLength = useMemo(() => {
|
||||
let max = 0
|
||||
charts.forEach((chart) => {
|
||||
const key = String(chart.label ?? chart.key).length
|
||||
if (key > max) max = key
|
||||
})
|
||||
return max
|
||||
}, [charts])
|
||||
|
||||
const [x, y] = useMemo(() => {
|
||||
if (!legendRef.current || !zoneRect) return [0, 0]
|
||||
|
||||
let x = offset.x
|
||||
let y = offset.y
|
||||
|
||||
const legendRect = legendRef.current.getBoundingClientRect()
|
||||
|
||||
if (position.includes('bottom'))
|
||||
y = zoneRect.height - offset.y - legendRect.height
|
||||
|
||||
if (position.includes('center'))
|
||||
x = (zoneRect.width - legendRect.width) / 2
|
||||
if (position.includes('right'))
|
||||
x = zoneRect.width - offset.x - legendRect.width
|
||||
|
||||
return [x, y]
|
||||
}, [zoneRect, legendRef.current, position, offset])
|
||||
|
||||
const defaultSizes = useMemo(() => {
|
||||
const out = {
|
||||
width: 10 + maxLength * 10 * (type === 'horizontal' ? charts.length : 1),
|
||||
height: 20 * (type === 'vertical' ? charts.length + 0.5 : 1)
|
||||
}
|
||||
return out
|
||||
}, [maxLength])
|
||||
|
||||
useEffect(() => {
|
||||
if (!legendRef.current) return
|
||||
|
||||
const currentElms = d3.select(legendRef.current)
|
||||
.selectAll('.legend')
|
||||
.data(charts)
|
||||
|
||||
currentElms.exit().remove() /// Удаляем лишние
|
||||
|
||||
/// Добавляем новые
|
||||
const newElms = currentElms.enter()
|
||||
.append('g')
|
||||
.attr('class', 'legend')
|
||||
|
||||
newElms.append('rect')
|
||||
.attr('x', 5)
|
||||
.attr('y', 4)
|
||||
.attr('width', 10)
|
||||
.attr('height', 10)
|
||||
|
||||
newElms.append('text')
|
||||
.attr('x', 20)
|
||||
.attr('y', 9)
|
||||
.attr('dy', '.35em')
|
||||
.style('text-anchor', 'start')
|
||||
.attr('fill', color)
|
||||
|
||||
const allElms = d3.select(legendRef.current)
|
||||
.selectAll('.legend')
|
||||
|
||||
/// Обновляем значения
|
||||
if (type === 'vertical') {
|
||||
allElms.attr('transform', (d, i) => `translate(5, ${5 + i * 20})`)
|
||||
} else {
|
||||
allElms.attr('transform', (d, i) => `translate(${5 + maxLength * 10 * i}, 0)`)
|
||||
}
|
||||
|
||||
allElms.selectAll('rect').style('fill', (d: any) => d.color)
|
||||
allElms.selectAll('text').text((d: any) => d.label ?? d.key)
|
||||
}, [legendRef.current, charts, color, maxLength])
|
||||
|
||||
return (
|
||||
<g
|
||||
ref={legendRef}
|
||||
pointerEvents={'none'}
|
||||
className={'legendTable'}
|
||||
transform={`translate(${x}, ${y})`}
|
||||
>
|
||||
<rect
|
||||
width={width ?? defaultSizes.width}
|
||||
height={height ?? defaultSizes.height}
|
||||
fill={backgroundColor}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export const D3Legend = wrapPlugin(_D3Legend) as typeof _D3Legend
|
||||
|
||||
export default D3Legend
|
@ -1,11 +1,11 @@
|
||||
import { CSSProperties, ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { BarChartOutlined, LineChartOutlined } from '@ant-design/icons'
|
||||
import { CSSProperties, memo, ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { BarChartOutlined, DotChartOutlined, LineChartOutlined } from '@ant-design/icons'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
import { formatDate, isDev } from '@utils'
|
||||
import { isDev } from '@utils'
|
||||
|
||||
import { D3MouseState, useD3MouseZone } from '../D3MouseZone'
|
||||
import { ChartRegistry, DefaultDataType } from '../types'
|
||||
import { ChartRegistry } from '../types'
|
||||
import { wrapPlugin } from './base'
|
||||
|
||||
import '@styles/d3.less'
|
||||
@ -21,7 +21,7 @@ export type D3RenderData<DataType> = {
|
||||
|
||||
export type D3RenderFunction<DataType> = (data: D3RenderData<DataType>, mouseState: D3MouseState) => ReactNode
|
||||
|
||||
export type BaseTooltip<DataType> = {
|
||||
export type D3TooltipSettings<DataType> = {
|
||||
render?: D3RenderFunction<DataType>
|
||||
width?: number | string
|
||||
height?: number | string
|
||||
@ -29,27 +29,24 @@ export type BaseTooltip<DataType> = {
|
||||
position?: D3TooltipPosition
|
||||
className?: string
|
||||
touchType?: D3TooltipTouchType
|
||||
}
|
||||
|
||||
export type AccurateTooltip = { type: 'accurate' }
|
||||
export type NearestTooltip = {
|
||||
type: 'nearest'
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type D3TooltipSettings<DataType> = BaseTooltip<DataType> & (AccurateTooltip | NearestTooltip)
|
||||
|
||||
const defaultRender: D3RenderFunction<Record<string, any>> = (data, mouseState) => (
|
||||
const makeDefaultRender = <DataType,>(): D3RenderFunction<DataType> => (data, mouseState) => (
|
||||
<>
|
||||
{data.length > 0 ? data.map(({ chart, data }) => {
|
||||
let Icon
|
||||
switch (chart.type) {
|
||||
case 'needle': Icon = BarChartOutlined; break
|
||||
case 'line': Icon = LineChartOutlined; break
|
||||
case 'point': Icon = DotChartOutlined; break
|
||||
// case 'area': Icon = AreaChartOutlined; break
|
||||
// case 'dot': Icon = DotChartOutLined; break
|
||||
}
|
||||
|
||||
const xFormat = (d: number | Date) => chart.xAxis.format?.(d) ?? `${(+d).toFixed(2)} ${chart.xAxis.unit ?? ''}`
|
||||
const yFormat = (d: number) => chart.yAxis.format?.(d) ?? `${d?.toFixed(2)} ${chart.yAxis.unit ?? ''}`
|
||||
|
||||
return (
|
||||
<div className={'tooltip-group'} key={chart.key}>
|
||||
<div className={'group-label'}>
|
||||
@ -58,7 +55,7 @@ const defaultRender: D3RenderFunction<Record<string, any>> = (data, mouseState)
|
||||
</div>
|
||||
{data.map((d, i) => (
|
||||
<span key={`${i}`}>
|
||||
{formatDate(chart.x(d))} {chart.xAxis.unit} :: {chart.y(d).toFixed(2)} {chart.yAxis.unit}
|
||||
{xFormat(chart.x(d))} :: {yFormat(chart.y(d))}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@ -69,7 +66,7 @@ const defaultRender: D3RenderFunction<Record<string, any>> = (data, mouseState)
|
||||
</>
|
||||
)
|
||||
|
||||
export type D3TooltipProps<DataType = DefaultDataType> = Partial<D3TooltipSettings<DataType>> & {
|
||||
export type D3TooltipProps<DataType> = Partial<D3TooltipSettings<DataType>> & {
|
||||
charts: ChartRegistry<DataType>[],
|
||||
}
|
||||
|
||||
@ -114,31 +111,41 @@ const getTouchedElements = <DataType,>(
|
||||
let nodes: d3.Selection<any, any, any, any>
|
||||
switch (chart.type) {
|
||||
case 'line':
|
||||
nodes = chart().selectAll('circle')
|
||||
if (touchType === 'all')
|
||||
nodes = nodes.filter(makeIsCircleTouched(x, y, limit))
|
||||
case 'point': {
|
||||
const tag = chart.point?.shape ?? 'circle'
|
||||
nodes = chart().selectAll(tag)
|
||||
if (touchType === 'all') {
|
||||
switch (tag) {
|
||||
case 'circle':
|
||||
nodes = nodes.filter(makeIsCircleTouched(x, y, chart.tooltip?.limit ?? limit))
|
||||
break
|
||||
case 'line':
|
||||
nodes = nodes.filter(makeIsLineTouched(x, y, chart.tooltip?.limit ?? limit))
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'needle':
|
||||
nodes = chart().selectAll('line')
|
||||
if (touchType === 'all')
|
||||
nodes = nodes.filter(makeIsLineTouched(x, y, limit))
|
||||
nodes = nodes.filter(makeIsLineTouched(x, y, chart.tooltip?.limit ?? limit))
|
||||
break
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
export const D3Tooltip = wrapPlugin<D3TooltipProps>(function D3Tooltip({
|
||||
type = 'accurate',
|
||||
function _D3Tooltip<DataType extends Record<string, unknown>>({
|
||||
width = 200,
|
||||
height = 120,
|
||||
render = defaultRender,
|
||||
render = makeDefaultRender<DataType>(),
|
||||
charts,
|
||||
position: _position = 'bottom',
|
||||
className,
|
||||
className = '',
|
||||
style: _style = {},
|
||||
touchType = 'all',
|
||||
...other
|
||||
}) {
|
||||
limit = 2
|
||||
}: D3TooltipProps<DataType>) {
|
||||
const { mouseState, zoneRect, subscribe } = useD3MouseZone()
|
||||
const [tooltipBody, setTooltipBody] = useState<any>()
|
||||
const [style, setStyle] = useState<CSSProperties>(_style)
|
||||
@ -179,8 +186,10 @@ export const D3Tooltip = wrapPlugin<D3TooltipProps>(function D3Tooltip({
|
||||
const offsetX = -rect.width / 2 // По центру
|
||||
const offsetY = 15 // Чуть выше курсора
|
||||
|
||||
const left = Math.max(0, Math.min(zoneRect.width - rect.width, mouseState.x + offsetX))
|
||||
const top = mouseState.y - offsetY - rect.height
|
||||
const left = Math.max(10, Math.min(zoneRect.width - rect.width - 10, mouseState.x + offsetX))
|
||||
let top = mouseState.y - offsetY - rect.height
|
||||
setPosition(top <= 0 ? 'top' : 'bottom')
|
||||
if (top <= 0) top = mouseState.y + offsetY
|
||||
|
||||
setStyle((prev) => ({
|
||||
...prev,
|
||||
@ -194,9 +203,9 @@ export const D3Tooltip = wrapPlugin<D3TooltipProps>(function D3Tooltip({
|
||||
if (!mouseState.visible)
|
||||
return setVisible(false)
|
||||
|
||||
const data: D3RenderData<DefaultDataType> = []
|
||||
const data: D3RenderData<DataType> = []
|
||||
charts.forEach((chart) => {
|
||||
const touched = getTouchedElements(chart, mouseState.x, mouseState.y, 2, touchType)
|
||||
const touched = getTouchedElements(chart, mouseState.x, mouseState.y, limit, touchType)
|
||||
|
||||
if (touched.empty()) return
|
||||
|
||||
@ -210,7 +219,7 @@ export const D3Tooltip = wrapPlugin<D3TooltipProps>(function D3Tooltip({
|
||||
setVisible(data.length > 0)
|
||||
if (data.length > 0)
|
||||
setTooltipBody(render(data, mouseState))
|
||||
}, [charts, touchType, mouseState, fixed])
|
||||
}, [charts, touchType, mouseState, fixed, limit])
|
||||
|
||||
return (
|
||||
<foreignObject
|
||||
@ -220,17 +229,20 @@ export const D3Tooltip = wrapPlugin<D3TooltipProps>(function D3Tooltip({
|
||||
height={height}
|
||||
opacity={visible ? 1 : 0}
|
||||
pointerEvents={fixed ? 'all' : 'none'}
|
||||
style={{ transition: 'opacity .1s ease-out' }}
|
||||
style={{ transition: 'opacity .1s ease-out', userSelect: fixed ? 'auto' : 'none' }}
|
||||
>
|
||||
<div
|
||||
{...other}
|
||||
ref={tooltipRef}
|
||||
className={`tooltip ${position} ${className}`}
|
||||
>
|
||||
{tooltipBody}
|
||||
<div className={'tooltip-content'}>
|
||||
{tooltipBody}
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const D3Tooltip = wrapPlugin(_D3Tooltip) as typeof _D3Tooltip
|
||||
|
||||
export default D3Tooltip
|
||||
|
@ -4,12 +4,18 @@ export type BasePluginSettings = {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const wrapPlugin = <S,>(Component: FC<S>, defaultEnabled?: boolean) => {
|
||||
const wrappedComponent = memo<S & BasePluginSettings>(({ enabled, ...props }) => {
|
||||
type InferArgs<T> = T extends (...t: [...infer Arg]) => any ? Arg : never
|
||||
type InferReturn<T> = T extends (...t: [...infer Arg]) => infer Res ? Res : never
|
||||
|
||||
export const wrapPlugin = <TProps,>(
|
||||
Component: FC<TProps>,
|
||||
defaultEnabled?: boolean
|
||||
): FC<TProps & BasePluginSettings> => {
|
||||
const wrappedComponent = ({ enabled, ...props }: TProps & BasePluginSettings) => {
|
||||
if (!(enabled ?? defaultEnabled)) return <></>
|
||||
|
||||
return <Component {...(props as S)} />
|
||||
})
|
||||
return <Component {...(props as TProps)} />
|
||||
}
|
||||
|
||||
return wrappedComponent
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
export * from './base'
|
||||
export * from './D3ContextMenu'
|
||||
export * from './D3Cursor'
|
||||
export * from './D3Legend'
|
||||
export * from './D3Tooltip'
|
||||
|
3
src/components/d3/renders/index.ts
Normal file
3
src/components/d3/renders/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './line'
|
||||
export * from './needle'
|
||||
export * from './points'
|
44
src/components/d3/renders/line.ts
Normal file
44
src/components/d3/renders/line.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import * as d3 from 'd3'
|
||||
|
||||
import { ChartRegistry } from '@components/d3/types'
|
||||
import { makePointsOptimizator } from '@utils'
|
||||
|
||||
export const renderLine = <DataType extends Record<string, unknown>>(
|
||||
xAxis: (value: any) => number,
|
||||
yAxis: (value: any) => number,
|
||||
chart: ChartRegistry<DataType>,
|
||||
data: DataType[]
|
||||
): DataType[] => {
|
||||
if (chart.type !== 'line') return data
|
||||
|
||||
let line = d3.line()
|
||||
.x(d => xAxis(chart.x(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':
|
||||
data = data.filter(chart.y)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if (chart.optimization) {
|
||||
const optimize = makePointsOptimizator<DataType>((a, b) => chart.y(a) === chart.y(b))
|
||||
data = optimize(data)
|
||||
}
|
||||
|
||||
|
||||
if (chart().selectAll('path').empty())
|
||||
chart().append('path')
|
||||
|
||||
chart().selectAll('path')
|
||||
.transition()
|
||||
.duration(chart.animDurationMs ?? 0)
|
||||
.attr('d', line(data as any))
|
||||
|
||||
return data
|
||||
}
|
32
src/components/d3/renders/needle.ts
Normal file
32
src/components/d3/renders/needle.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { ChartOffset, ChartRegistry } from '@components/d3/types'
|
||||
|
||||
export const renderNeedle = <DataType extends Record<string, unknown>>(
|
||||
xAxis: (value: d3.NumberValue) => number,
|
||||
yAxis: (value: d3.NumberValue) => number,
|
||||
chart: ChartRegistry<DataType>,
|
||||
data: DataType[],
|
||||
height: number,
|
||||
offset: ChartOffset
|
||||
): DataType[] => {
|
||||
if (chart.type !== 'needle') return data
|
||||
|
||||
data = data.filter(chart.y)
|
||||
|
||||
const currentNeedles = chart()
|
||||
.selectAll('line')
|
||||
.data(data)
|
||||
|
||||
currentNeedles.exit().remove()
|
||||
currentNeedles.enter().append('line')
|
||||
|
||||
chart()
|
||||
.selectAll('line')
|
||||
.transition()
|
||||
.duration(chart.animDurationMs ?? 0)
|
||||
.attr('x1', (d: any) => xAxis(chart.x(d)))
|
||||
.attr('x2', (d: any) => xAxis(chart.x(d)))
|
||||
.attr('y1', height - offset.bottom - offset.top)
|
||||
.attr('y2', (d: any) => yAxis(chart.y(d)))
|
||||
|
||||
return data
|
||||
}
|
55
src/components/d3/renders/points.ts
Normal file
55
src/components/d3/renders/points.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { ChartRegistry, PointChartDataset } from '@components/d3/types'
|
||||
|
||||
export const renderPoint = <DataType extends Record<string, unknown>>(
|
||||
xAxis: (value: any) => number,
|
||||
yAxis: (value: any) => number,
|
||||
chart: ChartRegistry<DataType>,
|
||||
data: DataType[]
|
||||
): DataType[] => {
|
||||
let config: Required<Omit<PointChartDataset, 'type'>> = {
|
||||
radius: 3,
|
||||
shape: 'circle',
|
||||
strokeWidth: 0,
|
||||
strokeColor: 'currentColor',
|
||||
strokeOpacity: 1,
|
||||
fillColor: 'currentColor',
|
||||
fillOpacity: 1,
|
||||
}
|
||||
|
||||
if (chart.type === 'point')
|
||||
config = { ...config, ...chart }
|
||||
else if (!chart.point)
|
||||
return data
|
||||
else
|
||||
config = { ...config, ...chart.point }
|
||||
|
||||
const currentPoints = chart()
|
||||
.selectAll(config.shape)
|
||||
.data(data.filter(chart.y))
|
||||
|
||||
currentPoints.exit().remove()
|
||||
currentPoints.enter().append(config.shape)
|
||||
|
||||
const newPoints = chart()
|
||||
.selectAll(config.shape)
|
||||
.transition()
|
||||
.duration(chart.animDurationMs ?? 0)
|
||||
|
||||
if (config.shape === 'circle')
|
||||
newPoints.attr('r', config.radius)
|
||||
.attr('cx', (d: any) => xAxis(chart.x(d)))
|
||||
.attr('cy', (d: any) => yAxis(chart.y(d)))
|
||||
else
|
||||
newPoints.attr('x1', (d: any) => xAxis(chart.x(d)))
|
||||
.attr('x2', (d: any) => xAxis(chart.x(d)))
|
||||
.attr('y1', (d: any) => yAxis(chart.y(d)) - config.radius)
|
||||
.attr('y2', (d: any) => yAxis(chart.y(d)) + config.radius)
|
||||
|
||||
newPoints.attr('stroke-width', config.strokeWidth)
|
||||
.attr('fill-opacity', config.fillOpacity)
|
||||
.attr('fill', config.fillColor)
|
||||
.attr('stroke', config.strokeColor)
|
||||
.attr('stroke-opacity', config.strokeOpacity)
|
||||
|
||||
return data
|
||||
}
|
@ -5,36 +5,40 @@ import {
|
||||
D3TooltipSettings
|
||||
} from './plugins'
|
||||
|
||||
export type DefaultDataType = Record<string, any>
|
||||
type Selection = d3.Selection<any, any, null, undefined>
|
||||
|
||||
export type ChartAxis<DataType> = {
|
||||
type: 'linear' | 'time',
|
||||
accessor: keyof DataType | ((d: DataType) => any)
|
||||
unit?: ReactNode
|
||||
format?: (v: d3.NumberValue) => ReactNode
|
||||
}
|
||||
|
||||
export type PointChartDataset = {
|
||||
type: 'point'
|
||||
radius?: number
|
||||
shape?: 'circle' | 'line'
|
||||
strokeColor?: Property.Color
|
||||
strokeWidth?: number | string
|
||||
strokeOpacity?: number
|
||||
fillColor?: Property.Color
|
||||
fillOpacity?: number
|
||||
}
|
||||
|
||||
export type BaseChartDataset<DataType> = {
|
||||
key: string | number
|
||||
label?: ReactNode
|
||||
yAxis: ChartAxis<DataType>
|
||||
xAxis: ChartAxis<DataType>
|
||||
label?: ReactNode
|
||||
color?: Property.Color
|
||||
opacity?: number
|
||||
width?: Property.StrokeWidth
|
||||
width?: number | string
|
||||
tooltip?: D3TooltipSettings<DataType>
|
||||
animDurationMs?: number
|
||||
point?: Omit<PointChartDataset, 'type'>
|
||||
afterDraw?: (d: any) => void
|
||||
}
|
||||
|
||||
export type LineChartDataset = {
|
||||
type: 'line'
|
||||
point?: {
|
||||
radius?: number
|
||||
strokeColor?: Property.Stroke
|
||||
strokeWidth?: Property.StrokeWidth
|
||||
fillColor?: Property.Fill
|
||||
}
|
||||
nullValues?: 'skip' | 'gap' | 'none'
|
||||
optimization?: boolean
|
||||
}
|
||||
@ -45,7 +49,8 @@ export type NeedleChartDataset = {
|
||||
|
||||
export type ChartDataset<DataType> = BaseChartDataset<DataType> & (
|
||||
LineChartDataset |
|
||||
NeedleChartDataset
|
||||
NeedleChartDataset |
|
||||
PointChartDataset
|
||||
)
|
||||
|
||||
export type ChartDomain = {
|
||||
@ -70,8 +75,8 @@ export type ChartTicks<DataType> = {
|
||||
y?: { visible?: boolean, count?: number }
|
||||
}
|
||||
|
||||
export type ChartRegistry<DataType = DefaultDataType> = ChartDataset<DataType> & {
|
||||
(): Selection
|
||||
export type ChartRegistry<DataType> = ChartDataset<DataType> & {
|
||||
(): d3.Selection<SVGGElement, DataType, any, any>
|
||||
y: (value: any) => number
|
||||
x: (value: any) => number
|
||||
}
|
||||
|
@ -18,6 +18,18 @@ const chartDatasets = [{
|
||||
type: 'linear',
|
||||
accessor: (row) => row.operationValue?.standardValue,
|
||||
},
|
||||
}, {
|
||||
key: 'normLine',
|
||||
label: 'Нормативные значения',
|
||||
type: 'line',
|
||||
optimization: false,
|
||||
width: 2,
|
||||
color: '#FFB562',
|
||||
opacity: 0.65,
|
||||
yAxis: {
|
||||
type: 'linear',
|
||||
accessor: (row) => row.operationValue?.standardValue,
|
||||
},
|
||||
}, {
|
||||
key: 'bars',
|
||||
label: 'Действительные значения',
|
||||
@ -36,7 +48,13 @@ const chartDatasets = [{
|
||||
color: '#F87474',
|
||||
yAxis: {
|
||||
type: 'linear',
|
||||
accessor: (row) => row.operationValue?.targetValue,
|
||||
accessor: (row) => row.operationValue?.targetValue ?? null,
|
||||
},
|
||||
optimization: false,
|
||||
point: {
|
||||
radius: 2,
|
||||
strokeColor: 'none',
|
||||
fillColor: 'currentColor',
|
||||
},
|
||||
nullValues: 'gap',
|
||||
}]
|
||||
@ -44,6 +62,7 @@ const chartDatasets = [{
|
||||
const xAxis = {
|
||||
type: 'time',
|
||||
accessor: (row) => new Date(row.dateStart),
|
||||
format: (d) => formatDate(d),
|
||||
}
|
||||
|
||||
const ticks = {
|
||||
@ -69,7 +88,8 @@ export const OperationsChart = memo(({ data, yDomain, height }) => {
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
type: 'nearest',
|
||||
limit: 10,
|
||||
height: 150,
|
||||
limit: 4,
|
||||
},
|
||||
cursor: {
|
||||
enabled: true,
|
||||
|
@ -59,7 +59,7 @@ export const AdditionalTables = memo(({ operations, xLabel, setIsLoading }) => {
|
||||
<Item label={'Длительность НПВ (ч)'}>{numericRender(nptSum)}</Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
<div className={'tvd-bl-table'} style={{ bottom: xLabel === 'day' ? '35px' : '85px' }}>
|
||||
<div className={'tvd-bl-table'}>
|
||||
<Descriptions bordered column={1} size={'small'} style={{ backgroundColor: 'white' }}>
|
||||
<Item label={'Отставание (сут)'}>{numericRender(additionalData.lag)}</Item>
|
||||
<Item label={'Начало цикла (план)'}>{printDate(additionalData.planStartDate)}</Item>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { memo, useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { DoubleLeftOutlined, DoubleRightOutlined } from '@ant-design/icons'
|
||||
import { DoubleLeftOutlined, DoubleRightOutlined, LineChartOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import { Switch, Button } from 'antd'
|
||||
|
||||
import { useIdWell } from '@asb/context'
|
||||
@ -8,7 +8,6 @@ import { D3Chart } from '@components/d3'
|
||||
import LoaderPortal from '@components/LoaderPortal'
|
||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||
import { formatDate, fractionalSum, wrapPrivateComponent, getOperations } from '@utils'
|
||||
import { unique } from '@utils/filters'
|
||||
|
||||
import NptTable from './NptTable'
|
||||
import NetGraphExport from './NetGraphExport'
|
||||
@ -17,57 +16,53 @@ import AdditionalTables from './AdditionalTables'
|
||||
import '@styles/index.css'
|
||||
import '@styles/tvd.less'
|
||||
|
||||
const datasets = [{
|
||||
key: 'fact',
|
||||
label: 'Факт',
|
||||
type: 'line',
|
||||
color: '#0A0',
|
||||
width: 3,
|
||||
yAxis: {
|
||||
type: 'linear',
|
||||
accessor: (row) => row.depth,
|
||||
unit: 'м',
|
||||
},
|
||||
}, {
|
||||
key: 'plan',
|
||||
label: 'План',
|
||||
type: 'line',
|
||||
color: '#F00',
|
||||
width: 3,
|
||||
yAxis: {
|
||||
type: 'linear',
|
||||
accessor: (row) => row.depth,
|
||||
unit: 'м',
|
||||
},
|
||||
}, {
|
||||
key: 'predict',
|
||||
label: 'Прогноз',
|
||||
type: 'line',
|
||||
color: 'purple',
|
||||
width: 1,
|
||||
afterDraw: (d) => d().selectAll('path').attr('stroke-dasharray', [7, 3]),
|
||||
yAxis: {
|
||||
type: 'linear',
|
||||
accessor: 'depth',
|
||||
unit: 'м',
|
||||
},
|
||||
}, {
|
||||
key: 'withoutNpt',
|
||||
label: '',
|
||||
type: 'line',
|
||||
color: '#00F',
|
||||
width: 3,
|
||||
yAxis: {
|
||||
type: 'linear',
|
||||
accessor: 'depth',
|
||||
unit: 'м',
|
||||
},
|
||||
}]
|
||||
const numericRender = (d) => d && Number.isFinite(+d) ? (+d).toFixed(2) : '-'
|
||||
|
||||
const tooltipRender = (data) => {
|
||||
if (!data || data.length <= 0) return (
|
||||
<span>Данных нет</span>
|
||||
)
|
||||
|
||||
return data.map(({ chart, data }) => {
|
||||
const xFormat = (d) => chart.xAxis.format?.(d) ?? `${numericRender(d)} ${chart.xAxis.unit ?? ''}`
|
||||
const yFormat = (d) => chart.yAxis.format?.(d) ?? `${numericRender(d)} ${chart.yAxis.unit ?? ''}`
|
||||
|
||||
return (
|
||||
<div className={'tooltip-group'} key={chart.key}>
|
||||
<div className={'group-label'}>
|
||||
<LineChartOutlined style={{ color: chart.color }} />
|
||||
<span>{chart.label}:</span>
|
||||
</div>
|
||||
{data.map((d, i) => {
|
||||
const text = `${xFormat(chart.x(d))} :: ${yFormat(chart.y(d))}`
|
||||
|
||||
const href = ['plan', 'fact'].includes(chart.key) && `/well/${d.idWell}/operations/${chart.key}/?selectedId=${d.id}`
|
||||
|
||||
return (
|
||||
<div key={`${i}`}>
|
||||
{href ? (
|
||||
<Link style={{ color: 'inherit', textDecoration: 'underline' }} to={href} title={'Перейти к таблице операций'}>
|
||||
<span style={{ marginRight: '5px' }}>{text}</span>
|
||||
<LinkOutlined />
|
||||
</Link>
|
||||
) : (
|
||||
<span title={'Нельзя осуществить переход к этой операции'}>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const xAxis = {
|
||||
date: {
|
||||
type: 'time',
|
||||
accessor: (row) => new Date(row.dateStart),
|
||||
format: (d) => formatDate(d),
|
||||
},
|
||||
day: {
|
||||
type: 'linear',
|
||||
@ -80,6 +75,7 @@ const ticks = {
|
||||
day: {
|
||||
x: {
|
||||
visible: true,
|
||||
count: 20,
|
||||
format: (d) => d,
|
||||
},
|
||||
y: { visible: true },
|
||||
@ -87,6 +83,7 @@ const ticks = {
|
||||
date: {
|
||||
x: {
|
||||
visible: true,
|
||||
count: 20,
|
||||
format: (d) => formatDate(d, undefined, 'YYYY-MM-DD'),
|
||||
},
|
||||
y: { visible: true },
|
||||
@ -103,31 +100,44 @@ const domain = {
|
||||
}
|
||||
}
|
||||
|
||||
const numericRender = (value) => Number.isFinite(value) ? (+value).toFixed(2) : '-'
|
||||
const plugins = {
|
||||
tooltip: { enabled: true, limit: 3, render: tooltipRender },
|
||||
menu: { enabled: false },
|
||||
legend: { enabled: true, offset: { x: 400 }, type: 'horizontal' },
|
||||
cursor: { enabled: false }
|
||||
}
|
||||
|
||||
const makeDataset = (key, label, color, width, radius) => ({
|
||||
key,
|
||||
type: 'line',
|
||||
label,
|
||||
color,
|
||||
width,
|
||||
yAxis: {
|
||||
type: 'linear',
|
||||
accessor: 'depth',
|
||||
unit: 'м',
|
||||
},
|
||||
point: {
|
||||
strokeColor: 'currentColor',
|
||||
strokeOpacity: 0.7,
|
||||
fillOpacity: 0.1,
|
||||
shape: 'line',
|
||||
strokeWidth: 1.5,
|
||||
radius
|
||||
},
|
||||
})
|
||||
|
||||
const Tvd = memo(({ idWell: wellId, title, ...other }) => {
|
||||
const [xLabel, setXLabel] = useState('day')
|
||||
const [operations, setOperations] = useState({})
|
||||
const [tableVisible, setTableVisible] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [pointsEnabled, setPointsEnabled] = useState(true)
|
||||
|
||||
const idWellContext = useIdWell()
|
||||
const idWell = useMemo(() => wellId ?? idWellContext, [wellId, idWellContext])
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
const onPointClick = useCallback((e) => {
|
||||
const points = e?.chart?.tooltip?.dataPoints
|
||||
if (!points || !(points.length > 0)) return
|
||||
|
||||
const datasetId = points.find((p) => p.datasetIndex !== 1)?.datasetIndex
|
||||
if (typeof datasetId === 'undefined') return
|
||||
|
||||
const datasetName = datasetId === 2 ? 'plan' : 'fact'
|
||||
const ids = points.map((p) => p.raw.id).filter(Boolean).filter(unique).join(',')
|
||||
navigate(`/well/${idWell}/operations/${datasetName}/?selectedId=${ids}`)
|
||||
}, [idWell, navigate])
|
||||
|
||||
const toogleTable = useCallback(() => {
|
||||
setOperations(pre => ({ ...pre }))
|
||||
setTableVisible(v => !v)
|
||||
@ -139,9 +149,9 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
|
||||
if (row?.isNPT !== false) return
|
||||
const nptH = +(row.nptHours ?? 0)
|
||||
withoutNpt.push({
|
||||
...row,
|
||||
depth: row.depth,
|
||||
day: row.day - nptH / 24,
|
||||
date: fractionalSum(row.date, -nptH, 'hour'),
|
||||
dateStart: fractionalSum(row.date, -nptH, 'hour'),
|
||||
})
|
||||
})
|
||||
|
||||
@ -157,17 +167,37 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
|
||||
)
|
||||
}, [idWell])
|
||||
|
||||
const datasets = useMemo(() => {
|
||||
const radius = pointsEnabled ? 6 : 1
|
||||
|
||||
return [
|
||||
makeDataset('withoutNpt', 'Факт без НПВ', '#548CFF', 2, radius),
|
||||
makeDataset('plan', 'План', '#EB5353', 2, radius),
|
||||
makeDataset('predict', 'Прогноз', '#BD4291', 1, radius),
|
||||
makeDataset('fact', 'Факт', '#36AE7C', 2, radius),
|
||||
]
|
||||
}, [pointsEnabled])
|
||||
|
||||
return (
|
||||
<div className={'container tvd-page'} {...other}>
|
||||
<div className={'tvd-top'}>
|
||||
<h2>{title || 'График Глубина-день'}</h2>
|
||||
<div>
|
||||
<Switch
|
||||
defaultChecked
|
||||
checkedChildren={'С рисками'}
|
||||
unCheckedChildren={'Без рисок'}
|
||||
onChange={(checked) => setPointsEnabled(checked)}
|
||||
style={{ marginRight: 20 }}
|
||||
title={'Нажмите для переключения видимости засечек на графиках'}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren={'Дата'}
|
||||
unCheckedChildren={'Дни со старта'}
|
||||
loading={isLoading}
|
||||
onChange={(checked) => setXLabel(checked ? 'date' : 'day')}
|
||||
style={{ marginRight: '20px' }}
|
||||
title={'Нажмите для переключения горизонтальной оси'}
|
||||
/>
|
||||
<NetGraphExport idWell={idWell} />
|
||||
<Button icon={tableVisible ? <DoubleRightOutlined /> : <DoubleLeftOutlined />} onClick={toogleTable}>НПВ</Button>
|
||||
@ -184,11 +214,8 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
|
||||
datasets={datasets}
|
||||
loading={isLoading}
|
||||
ticks={ticks[xLabel]}
|
||||
plugins={{
|
||||
tooltip: {
|
||||
enabled: true
|
||||
}
|
||||
}}
|
||||
plugins={plugins}
|
||||
animDurationMs={0}
|
||||
/>
|
||||
</div>
|
||||
{tableVisible && <NptTable operations={operations?.fact} />}
|
||||
|
@ -28,27 +28,49 @@
|
||||
border: @arrow-size solid transparent;
|
||||
}
|
||||
|
||||
&.bottom::after {
|
||||
border-top-color: @bg-color;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -@arrow-size;
|
||||
&.top {
|
||||
margin-top: @arrow-size;
|
||||
|
||||
&::after {
|
||||
border-bottom-color: @bg-color;
|
||||
top: -@arrow-size*2;
|
||||
left: 50%;
|
||||
margin-left: -@arrow-size;
|
||||
}
|
||||
}
|
||||
|
||||
& .tooltip-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
align-items: flex-start;
|
||||
|
||||
& .group-label {
|
||||
width: 200%;
|
||||
overflow: hidden;
|
||||
&.bottom {
|
||||
margin-top: 0;
|
||||
|
||||
& span {
|
||||
font-weight: 600;
|
||||
margin-left: 5px;
|
||||
white-space: nowrap;
|
||||
&::after {
|
||||
border-top-color: @bg-color;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -@arrow-size;
|
||||
}
|
||||
}
|
||||
|
||||
& .tooltip-content {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
& .tooltip-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
align-items: flex-start;
|
||||
|
||||
& .group-label {
|
||||
width: 200%;
|
||||
overflow: hidden;
|
||||
|
||||
& span {
|
||||
font-weight: 600;
|
||||
margin-left: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -33,13 +33,13 @@
|
||||
}
|
||||
|
||||
&.tvd-tr-table {
|
||||
right: 15px;
|
||||
top: 38px;
|
||||
right: 10px;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&.tvd-bl-table {
|
||||
bottom: 35px;
|
||||
left: 50px;
|
||||
left: 55px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
export const makePointsOptimizator = <T,>(isEquals: (a: Record<string, T>, b: Record<string, T>) => boolean) => (points: Record<string, T>[]) => {
|
||||
export const makePointsOptimizator = <DataType extends Record<string, unknown>>(isEquals: (a: DataType, b: DataType) => boolean) => (points: DataType[]) => {
|
||||
if (!Array.isArray(points) || points.length < 3) return points
|
||||
|
||||
const out: Record<string, T>[] = []
|
||||
const out: DataType[] = []
|
||||
for (let i = 1; i < points.length - 1; i++)
|
||||
if (!isEquals(points[i - 1], points[i]) || !isEquals(points[i], points[i + 1]))
|
||||
out.push(points[i])
|
||||
|
@ -1,10 +1,13 @@
|
||||
import { useMemo } from "react"
|
||||
import { useMemo } from 'react'
|
||||
import { FunctionalValue, useFunctionalValue } from './functionalValue'
|
||||
|
||||
export const usePartialProps = <T,>(prop: Partial<T> | null | undefined, defaultValue: FunctionalValue<T>): T => {
|
||||
const def = useFunctionalValue(defaultValue)
|
||||
|
||||
export const usePartialProps = <T,>(prop: Partial<T> | null | undefined, defaultValue: T): T => {
|
||||
const result: T = useMemo(() => {
|
||||
if (!prop || typeof prop !== 'object') return defaultValue
|
||||
return { ...defaultValue, ...prop }
|
||||
}, [prop, defaultValue])
|
||||
if (!prop || typeof prop !== 'object') return def()
|
||||
return { ...def(), ...prop }
|
||||
}, [prop, def])
|
||||
|
||||
return result
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user