* TVD перенесен на D3

* Добавлен плагин для отображения легенды на графике
* Код отрисовки графиков вынесен в функций и разделён по файлам
* Добавлен тип графика "Точечный"
* улучшена работа с типами в графиках, часть компонентов теперь шаблонные
* улучшена работа usePartialProps
This commit is contained in:
goodmice 2022-06-29 18:08:37 +05:00
parent fa751240ba
commit 01f1f98e53
18 changed files with 625 additions and 274 deletions

View File

@ -5,21 +5,35 @@ import { Empty } from 'antd'
import * as d3 from 'd3' import * as d3 from 'd3'
import LoaderPortal from '@components/LoaderPortal' import LoaderPortal from '@components/LoaderPortal'
import { formatDate, isDev, makePointsOptimizator, usePartialProps } from '@utils' import { isDev, usePartialProps } from '@utils'
import D3MouseZone from './D3MouseZone' import D3MouseZone from './D3MouseZone'
import {
renderLine,
renderPoint,
renderNeedle
} from './renders'
import { import {
BasePluginSettings, BasePluginSettings,
D3ContextMenu, D3ContextMenu,
D3ContextMenuSettings, D3ContextMenuSettings,
D3Cursor, D3Cursor,
D3CursorSettings, D3CursorSettings,
D3Legend,
D3LegendSettings,
D3Tooltip, D3Tooltip,
D3TooltipSettings, D3TooltipSettings,
} from './plugins' } from './plugins'
import type {
ChartAxis,
ChartDataset,
ChartDomain,
ChartOffset,
ChartRegistry,
ChartTicks
} from './types'
import '@styles/d3.less' import '@styles/d3.less'
import { ChartAxis, ChartDataset, ChartDomain, ChartOffset, ChartRegistry, ChartTicks, DefaultDataType } from './types'
const defaultOffsets: ChartOffset = { const defaultOffsets: ChartOffset = {
top: 10, top: 10,
@ -28,17 +42,12 @@ const defaultOffsets: ChartOffset = {
right: 10, right: 10,
} }
const defaultXAxisConfig: ChartAxis<DefaultDataType> = {
type: 'time',
accessor: (d: any) => new Date(d.date)
}
const getGroupClass = (key: string | number) => `chart-id-${key}` 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') if (typeof accessor === 'function')
return accessor return accessor
return (d: T) => d[accessor] return (d) => d[accessor]
} }
const createAxis = <DataType,>(config: ChartAxis<DataType>) => { const createAxis = <DataType,>(config: ChartAxis<DataType>) => {
@ -47,7 +56,7 @@ const createAxis = <DataType,>(config: ChartAxis<DataType>) => {
return d3.scaleLinear() 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> xAxis: ChartAxis<DataType>
datasets: ChartDataset<DataType>[] datasets: ChartDataset<DataType>[]
data?: DataType[] | Record<string, DataType[]> data?: DataType[] | Record<string, DataType[]>
@ -63,10 +72,16 @@ export type D3ChartProps<DataType = DefaultDataType> = React.DetailedHTMLProps<R
menu?: BasePluginSettings & D3ContextMenuSettings menu?: BasePluginSettings & D3ContextMenuSettings
tooltip?: BasePluginSettings & D3TooltipSettings<DataType> tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
cursor?: BasePluginSettings & D3CursorSettings 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 = '', className = '',
xAxis: _xAxisConfig, xAxis: _xAxisConfig,
datasets, datasets,
@ -81,8 +96,9 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
ticks, ticks,
plugins, plugins,
...other ...other
}) => { }: D3ChartProps<DataType>) => {
const xAxisConfig = usePartialProps(_xAxisConfig, defaultXAxisConfig) const xAxisConfig = usePartialProps<ChartAxis<DataType>>(_xAxisConfig, getDefaultXAxisConfig)
const offset = usePartialProps(_offset, defaultOffsets) const offset = usePartialProps(_offset, defaultOffsets)
const [svgRef, setSvgRef] = useState<SVGSVGElement | null>(null) 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 yAxisArea = useCallback(() => d3.select(yAxisRef), [yAxisRef])
const chartArea = useCallback(() => d3.select(chartAreaRef), [chartAreaRef]) const chartArea = useCallback(() => d3.select(chartAreaRef), [chartAreaRef])
const getX = useMemo(() => getByAccessor(xAxisConfig.accessor), [xAxisConfig.accessor]) const [charts, setCharts] = useState<ChartRegistry<DataType>[]>([])
const [charts, setCharts] = useState<ChartRegistry<DefaultDataType>[]>([])
const [rootRef, { width, height }] = useElementSize() const [rootRef, { width, height }] = useElementSize()
const xAxis = useMemo(() => { const xAxis = useMemo(() => {
if (!data) return if (!data) return
const getX = getByAccessor(xAxisConfig.accessor)
const xAxis = createAxis(xAxisConfig) const xAxis = createAxis(xAxisConfig)
xAxis.range([0, width - offset.left - offset.right]) xAxis.range([0, width - offset.left - offset.right])
@ -125,7 +141,7 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
} }
return xAxis return xAxis
}, [xAxisConfig, getX, data, domain, width, offset]) }, [xAxisConfig, data, domain, width, offset])
const yAxis = useMemo(() => { const yAxis = useMemo(() => {
if (!data) return if (!data) return
@ -201,7 +217,7 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
console.warn(`Ключ датасета "${datasets[i].key}" неуникален (индексы ${i} и ${j})!`) console.warn(`Ключ датасета "${datasets[i].key}" неуникален (индексы ${i} и ${j})!`)
setCharts((oldCharts) => { setCharts((oldCharts) => {
const charts: ChartRegistry[] = [] const charts: ChartRegistry<DataType>[] = []
for (const chart of oldCharts) { // Удаляем ненужные графики for (const chart of oldCharts) { // Удаляем ненужные графики
if (datasets.find(({ key }) => key === chart.key)) if (datasets.find(({ key }) => key === chart.key))
@ -215,9 +231,14 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
if (chartIdx < 0) if (chartIdx < 0)
chartIdx = charts.length chartIdx = charts.length
const newChart: ChartRegistry = Object.assign( const newChart: ChartRegistry<DataType> = Object.assign(
() => chartArea().select('.' + getGroupClass(dataset.key)), () => chartArea().select('.' + getGroupClass(dataset.key)) as d3.Selection<SVGGElement, DataType, any, unknown>,
{ {
width: 1,
opacity: 1,
label: dataset.key,
color: 'gray',
animDurationMs,
...dataset, ...dataset,
xAxis: dataset.xAxis ?? xAxisConfig, xAxis: dataset.xAxis ?? xAxisConfig,
y: getByAccessor(dataset.yAxis.accessor), 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()) if (!newChart().node())
chartArea() chartArea()
.append('g') .append('g')
@ -235,100 +259,42 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
return charts return charts
}) })
}, [chartArea, datasets]) }, [xAxisConfig, chartArea, datasets, animDurationMs])
const redrawCharts = useCallback(() => { const redrawCharts = useCallback(() => {
if (!data || !xAxis || !yAxis) return if (!data || !xAxis || !yAxis) return
charts.forEach((chart) => { charts.forEach((chart) => {
chart() chart()
.attr('color', chart.color ?? null) .attr('color', chart.color || null)
.attr('stroke', 'currentColor') .attr('stroke', 'currentColor')
.attr('stroke-width', chart.width ?? 1) .attr('stroke-width', chart.width ?? null)
.attr('opacity', chart.opacity ?? 1) .attr('opacity', chart.opacity ?? null)
.attr('fill', 'none') .attr('fill', 'none')
let d = Array.isArray(data) ? data : data[String(chart.key).split(':')[0]] let chartData = Array.isArray(data) ? data : data[String(chart.key).split(':')[0]]
if (!d) return if (!chartData) return
let elms
switch (chart.type) { switch (chart.type) {
case 'needle': case 'needle':
elms = chart() chartData = renderNeedle<DataType>(xAxis, yAxis, chart, chartData, height, offset)
.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)))
break break
case 'line': { case 'line':
let line = d3.line() chartData = renderLine<DataType>(xAxis, yAxis, chart, chartData)
.x(d => xAxis(chart.x(d))) break
.y(d => yAxis(chart.y(d))) case 'point':
chartData = renderPoint<DataType>(xAxis, yAxis, chart, chartData)
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)
break break
}
default: default:
break break
} }
if (chart.point)
renderPoint<DataType>(xAxis, yAxis, chart, chartData)
chart.afterDraw?.(chart) chart.afterDraw?.(chart)
}) })
}, [charts, data, xAxis, yAxis, height]) }, [charts, data, xAxis, yAxis, height, offset])
useEffect(() => { useEffect(() => {
redrawCharts() 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={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={setYAxisRef} className={'axis y'} transform={`translate(${offset.left}, ${offset.top})`} />
<g ref={setChartAreaRef} className={'chart-area'} 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> </g>
<D3MouseZone width={width} height={height} offset={offset}> <D3MouseZone width={width} height={height} offset={offset}>
{(plugins?.cursor?.enabled ?? true) && ( <D3Cursor {...plugins?.cursor} /> )} <D3Cursor {...plugins?.cursor} />
{(plugins?.tooltip?.enabled ?? true) && ( <D3Tooltip {...plugins?.tooltip} charts={charts} /> )} <D3Legend<DataType> charts={charts} {...plugins?.legend} />
<D3Tooltip<DataType> charts={charts} {...plugins?.tooltip} />
</D3MouseZone> </D3MouseZone>
</svg> </svg>
</D3ContextMenu> </D3ContextMenu>
@ -369,6 +340,8 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
</div> </div>
</LoaderPortal> </LoaderPortal>
) )
}) }
export const D3Chart = memo(_D3Chart) as typeof _D3Chart
export default D3Chart export default D3Chart

View File

@ -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 * as d3 from 'd3'
import '@styles/d3.less' import '@styles/d3.less'
@ -25,7 +25,7 @@ export type D3MouseZoneProps = {
children: ReactNode children: ReactNode
} }
export const D3MouseZoneContext = createContext<D3MouseZoneContext>({ export const defaultMouseZoneContext: D3MouseZoneContext = {
mouseState: { mouseState: {
x: NaN, x: NaN,
y: NaN, y: NaN,
@ -34,15 +34,17 @@ export const D3MouseZoneContext = createContext<D3MouseZoneContext>({
zone: null, zone: null,
zoneRect: null, zoneRect: null,
subscribe: () => null subscribe: () => null
}) }
export const D3MouseZoneContext = createContext<D3MouseZoneContext>(defaultMouseZoneContext)
export const useD3MouseZone = () => useContext(D3MouseZoneContext) export const useD3MouseZone = () => useContext(D3MouseZoneContext)
export const D3MouseZone = memo<D3MouseZoneProps>(({ width, height, offset, children }) => { export const D3MouseZone = memo<D3MouseZoneProps>(({ width, height, offset, children }) => {
const zoneRef = useRef<SVGGElement>(null)
const rectRef = useRef<SVGRectElement>(null) const rectRef = useRef<SVGRectElement>(null)
const [state, setState] = useState<D3MouseState>({ x: 0, y: 0, visible: false }) const [state, setState] = useState<D3MouseState>({ x: 0, y: 0, visible: false })
const [childContext, setChildContext] = useState<D3MouseZoneContext>(defaultMouseZoneContext)
const subscribeEvent: SubscribeFunction = useCallback((name, handler) => { const subscribeEvent: SubscribeFunction = useCallback((name, handler) => {
if (!rectRef.current) return null if (!rectRef.current) return null
@ -54,6 +56,17 @@ export const D3MouseZone = memo<D3MouseZoneProps>(({ width, height, offset, chil
} }
}, [rectRef.current]) }, [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 onMouse = useCallback((e: any) => {
const rect = e.target.getBoundingClientRect() const rect = e.target.getBoundingClientRect()
@ -71,25 +84,18 @@ export const D3MouseZone = memo<D3MouseZoneProps>(({ width, height, offset, chil
})) }))
}, []) }, [])
const childContext: D3MouseZoneContext = useMemo(() => { useEffect(() => {
const zone = zoneRef.current ? (() => d3.select(zoneRef.current)) : null updateContext()
}, [updateContext])
return {
mouseState: state,
zone,
zoneRect: zoneRef.current?.getBoundingClientRect() || null,
subscribe: subscribeEvent,
}
}, [zoneRef.current, state, subscribeEvent])
return ( 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 <rect
ref={rectRef} ref={rectRef}
pointerEvents={'all'} pointerEvents={'all'}
className={'event-zone'} className={'event-zone'}
width={width - offset.left - offset.right} width={Math.max(width - offset.left - offset.right, 0)}
height={height - offset.top - offset.bottom} height={Math.max(height - offset.top - offset.bottom, 0)}
fill={'none'} fill={'none'}
stroke={'none'} stroke={'none'}
onMouseMove={onMouse} onMouseMove={onMouse}

View 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

View File

@ -1,11 +1,11 @@
import { CSSProperties, ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { CSSProperties, memo, ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { BarChartOutlined, LineChartOutlined } from '@ant-design/icons' import { BarChartOutlined, DotChartOutlined, LineChartOutlined } from '@ant-design/icons'
import * as d3 from 'd3' import * as d3 from 'd3'
import { formatDate, isDev } from '@utils' import { isDev } from '@utils'
import { D3MouseState, useD3MouseZone } from '../D3MouseZone' import { D3MouseState, useD3MouseZone } from '../D3MouseZone'
import { ChartRegistry, DefaultDataType } from '../types' import { ChartRegistry } from '../types'
import { wrapPlugin } from './base' import { wrapPlugin } from './base'
import '@styles/d3.less' import '@styles/d3.less'
@ -21,7 +21,7 @@ export type D3RenderData<DataType> = {
export type D3RenderFunction<DataType> = (data: D3RenderData<DataType>, mouseState: D3MouseState) => ReactNode export type D3RenderFunction<DataType> = (data: D3RenderData<DataType>, mouseState: D3MouseState) => ReactNode
export type BaseTooltip<DataType> = { export type D3TooltipSettings<DataType> = {
render?: D3RenderFunction<DataType> render?: D3RenderFunction<DataType>
width?: number | string width?: number | string
height?: number | string height?: number | string
@ -29,27 +29,24 @@ export type BaseTooltip<DataType> = {
position?: D3TooltipPosition position?: D3TooltipPosition
className?: string className?: string
touchType?: D3TooltipTouchType touchType?: D3TooltipTouchType
}
export type AccurateTooltip = { type: 'accurate' }
export type NearestTooltip = {
type: 'nearest'
limit?: number limit?: number
} }
export type D3TooltipSettings<DataType> = BaseTooltip<DataType> & (AccurateTooltip | NearestTooltip) const makeDefaultRender = <DataType,>(): D3RenderFunction<DataType> => (data, mouseState) => (
const defaultRender: D3RenderFunction<Record<string, any>> = (data, mouseState) => (
<> <>
{data.length > 0 ? data.map(({ chart, data }) => { {data.length > 0 ? data.map(({ chart, data }) => {
let Icon let Icon
switch (chart.type) { switch (chart.type) {
case 'needle': Icon = BarChartOutlined; break case 'needle': Icon = BarChartOutlined; break
case 'line': Icon = LineChartOutlined; break case 'line': Icon = LineChartOutlined; break
case 'point': Icon = DotChartOutlined; break
// case 'area': Icon = AreaChartOutlined; break // case 'area': Icon = AreaChartOutlined; break
// case 'dot': Icon = DotChartOutLined; 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 ( return (
<div className={'tooltip-group'} key={chart.key}> <div className={'tooltip-group'} key={chart.key}>
<div className={'group-label'}> <div className={'group-label'}>
@ -58,7 +55,7 @@ const defaultRender: D3RenderFunction<Record<string, any>> = (data, mouseState)
</div> </div>
{data.map((d, i) => ( {data.map((d, i) => (
<span key={`${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> </span>
))} ))}
</div> </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>[], charts: ChartRegistry<DataType>[],
} }
@ -114,31 +111,41 @@ const getTouchedElements = <DataType,>(
let nodes: d3.Selection<any, any, any, any> let nodes: d3.Selection<any, any, any, any>
switch (chart.type) { switch (chart.type) {
case 'line': case 'line':
nodes = chart().selectAll('circle') case 'point': {
if (touchType === 'all') const tag = chart.point?.shape ?? 'circle'
nodes = nodes.filter(makeIsCircleTouched(x, y, limit)) 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 break
}
case 'needle': case 'needle':
nodes = chart().selectAll('line') nodes = chart().selectAll('line')
if (touchType === 'all') if (touchType === 'all')
nodes = nodes.filter(makeIsLineTouched(x, y, limit)) nodes = nodes.filter(makeIsLineTouched(x, y, chart.tooltip?.limit ?? limit))
break break
} }
return nodes return nodes
} }
export const D3Tooltip = wrapPlugin<D3TooltipProps>(function D3Tooltip({ function _D3Tooltip<DataType extends Record<string, unknown>>({
type = 'accurate',
width = 200, width = 200,
height = 120, height = 120,
render = defaultRender, render = makeDefaultRender<DataType>(),
charts, charts,
position: _position = 'bottom', position: _position = 'bottom',
className, className = '',
style: _style = {}, style: _style = {},
touchType = 'all', touchType = 'all',
...other limit = 2
}) { }: D3TooltipProps<DataType>) {
const { mouseState, zoneRect, subscribe } = useD3MouseZone() const { mouseState, zoneRect, subscribe } = useD3MouseZone()
const [tooltipBody, setTooltipBody] = useState<any>() const [tooltipBody, setTooltipBody] = useState<any>()
const [style, setStyle] = useState<CSSProperties>(_style) const [style, setStyle] = useState<CSSProperties>(_style)
@ -179,8 +186,10 @@ export const D3Tooltip = wrapPlugin<D3TooltipProps>(function D3Tooltip({
const offsetX = -rect.width / 2 // По центру const offsetX = -rect.width / 2 // По центру
const offsetY = 15 // Чуть выше курсора const offsetY = 15 // Чуть выше курсора
const left = Math.max(0, Math.min(zoneRect.width - rect.width, mouseState.x + offsetX)) const left = Math.max(10, Math.min(zoneRect.width - rect.width - 10, mouseState.x + offsetX))
const top = mouseState.y - offsetY - rect.height let top = mouseState.y - offsetY - rect.height
setPosition(top <= 0 ? 'top' : 'bottom')
if (top <= 0) top = mouseState.y + offsetY
setStyle((prev) => ({ setStyle((prev) => ({
...prev, ...prev,
@ -194,9 +203,9 @@ export const D3Tooltip = wrapPlugin<D3TooltipProps>(function D3Tooltip({
if (!mouseState.visible) if (!mouseState.visible)
return setVisible(false) return setVisible(false)
const data: D3RenderData<DefaultDataType> = [] const data: D3RenderData<DataType> = []
charts.forEach((chart) => { 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 if (touched.empty()) return
@ -210,7 +219,7 @@ export const D3Tooltip = wrapPlugin<D3TooltipProps>(function D3Tooltip({
setVisible(data.length > 0) setVisible(data.length > 0)
if (data.length > 0) if (data.length > 0)
setTooltipBody(render(data, mouseState)) setTooltipBody(render(data, mouseState))
}, [charts, touchType, mouseState, fixed]) }, [charts, touchType, mouseState, fixed, limit])
return ( return (
<foreignObject <foreignObject
@ -220,17 +229,20 @@ export const D3Tooltip = wrapPlugin<D3TooltipProps>(function D3Tooltip({
height={height} height={height}
opacity={visible ? 1 : 0} opacity={visible ? 1 : 0}
pointerEvents={fixed ? 'all' : 'none'} pointerEvents={fixed ? 'all' : 'none'}
style={{ transition: 'opacity .1s ease-out' }} style={{ transition: 'opacity .1s ease-out', userSelect: fixed ? 'auto' : 'none' }}
> >
<div <div
{...other}
ref={tooltipRef} ref={tooltipRef}
className={`tooltip ${position} ${className}`} className={`tooltip ${position} ${className}`}
> >
{tooltipBody} <div className={'tooltip-content'}>
{tooltipBody}
</div>
</div> </div>
</foreignObject> </foreignObject>
) )
}) }
export const D3Tooltip = wrapPlugin(_D3Tooltip) as typeof _D3Tooltip
export default D3Tooltip export default D3Tooltip

View File

@ -4,12 +4,18 @@ export type BasePluginSettings = {
enabled?: boolean enabled?: boolean
} }
export const wrapPlugin = <S,>(Component: FC<S>, defaultEnabled?: boolean) => { type InferArgs<T> = T extends (...t: [...infer Arg]) => any ? Arg : never
const wrappedComponent = memo<S & BasePluginSettings>(({ enabled, ...props }) => { 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 <></> if (!(enabled ?? defaultEnabled)) return <></>
return <Component {...(props as S)} /> return <Component {...(props as TProps)} />
}) }
return wrappedComponent return wrappedComponent
} }

View File

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

View File

@ -0,0 +1,3 @@
export * from './line'
export * from './needle'
export * from './points'

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

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

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

View File

@ -5,36 +5,40 @@ import {
D3TooltipSettings D3TooltipSettings
} from './plugins' } from './plugins'
export type DefaultDataType = Record<string, any>
type Selection = d3.Selection<any, any, null, undefined>
export type ChartAxis<DataType> = { export type ChartAxis<DataType> = {
type: 'linear' | 'time', type: 'linear' | 'time',
accessor: keyof DataType | ((d: DataType) => any) accessor: keyof DataType | ((d: DataType) => any)
unit?: ReactNode 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> = { export type BaseChartDataset<DataType> = {
key: string | number key: string | number
label?: ReactNode
yAxis: ChartAxis<DataType> yAxis: ChartAxis<DataType>
xAxis: ChartAxis<DataType> xAxis: ChartAxis<DataType>
label?: ReactNode
color?: Property.Color color?: Property.Color
opacity?: number opacity?: number
width?: Property.StrokeWidth width?: number | string
tooltip?: D3TooltipSettings<DataType> tooltip?: D3TooltipSettings<DataType>
animDurationMs?: number animDurationMs?: number
point?: Omit<PointChartDataset, 'type'>
afterDraw?: (d: any) => void afterDraw?: (d: any) => void
} }
export type LineChartDataset = { export type LineChartDataset = {
type: 'line' type: 'line'
point?: {
radius?: number
strokeColor?: Property.Stroke
strokeWidth?: Property.StrokeWidth
fillColor?: Property.Fill
}
nullValues?: 'skip' | 'gap' | 'none' nullValues?: 'skip' | 'gap' | 'none'
optimization?: boolean optimization?: boolean
} }
@ -45,7 +49,8 @@ export type NeedleChartDataset = {
export type ChartDataset<DataType> = BaseChartDataset<DataType> & ( export type ChartDataset<DataType> = BaseChartDataset<DataType> & (
LineChartDataset | LineChartDataset |
NeedleChartDataset NeedleChartDataset |
PointChartDataset
) )
export type ChartDomain = { export type ChartDomain = {
@ -70,8 +75,8 @@ export type ChartTicks<DataType> = {
y?: { visible?: boolean, count?: number } y?: { visible?: boolean, count?: number }
} }
export type ChartRegistry<DataType = DefaultDataType> = ChartDataset<DataType> & { export type ChartRegistry<DataType> = ChartDataset<DataType> & {
(): Selection (): d3.Selection<SVGGElement, DataType, any, any>
y: (value: any) => number y: (value: any) => number
x: (value: any) => number x: (value: any) => number
} }

View File

@ -18,6 +18,18 @@ const chartDatasets = [{
type: 'linear', type: 'linear',
accessor: (row) => row.operationValue?.standardValue, 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', key: 'bars',
label: 'Действительные значения', label: 'Действительные значения',
@ -36,7 +48,13 @@ const chartDatasets = [{
color: '#F87474', color: '#F87474',
yAxis: { yAxis: {
type: 'linear', type: 'linear',
accessor: (row) => row.operationValue?.targetValue, accessor: (row) => row.operationValue?.targetValue ?? null,
},
optimization: false,
point: {
radius: 2,
strokeColor: 'none',
fillColor: 'currentColor',
}, },
nullValues: 'gap', nullValues: 'gap',
}] }]
@ -44,6 +62,7 @@ const chartDatasets = [{
const xAxis = { const xAxis = {
type: 'time', type: 'time',
accessor: (row) => new Date(row.dateStart), accessor: (row) => new Date(row.dateStart),
format: (d) => formatDate(d),
} }
const ticks = { const ticks = {
@ -69,7 +88,8 @@ export const OperationsChart = memo(({ data, yDomain, height }) => {
tooltip: { tooltip: {
enabled: true, enabled: true,
type: 'nearest', type: 'nearest',
limit: 10, height: 150,
limit: 4,
}, },
cursor: { cursor: {
enabled: true, enabled: true,

View File

@ -59,7 +59,7 @@ export const AdditionalTables = memo(({ operations, xLabel, setIsLoading }) => {
<Item label={'Длительность НПВ (ч)'}>{numericRender(nptSum)}</Item> <Item label={'Длительность НПВ (ч)'}>{numericRender(nptSum)}</Item>
</Descriptions> </Descriptions>
</div> </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' }}> <Descriptions bordered column={1} size={'small'} style={{ backgroundColor: 'white' }}>
<Item label={'Отставание (сут)'}>{numericRender(additionalData.lag)}</Item> <Item label={'Отставание (сут)'}>{numericRender(additionalData.lag)}</Item>
<Item label={'Начало цикла (план)'}>{printDate(additionalData.planStartDate)}</Item> <Item label={'Начало цикла (план)'}>{printDate(additionalData.planStartDate)}</Item>

View File

@ -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 { 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 { Switch, Button } from 'antd'
import { useIdWell } from '@asb/context' import { useIdWell } from '@asb/context'
@ -8,7 +8,6 @@ import { D3Chart } from '@components/d3'
import LoaderPortal from '@components/LoaderPortal' import LoaderPortal from '@components/LoaderPortal'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { formatDate, fractionalSum, wrapPrivateComponent, getOperations } from '@utils' import { formatDate, fractionalSum, wrapPrivateComponent, getOperations } from '@utils'
import { unique } from '@utils/filters'
import NptTable from './NptTable' import NptTable from './NptTable'
import NetGraphExport from './NetGraphExport' import NetGraphExport from './NetGraphExport'
@ -17,57 +16,53 @@ import AdditionalTables from './AdditionalTables'
import '@styles/index.css' import '@styles/index.css'
import '@styles/tvd.less' import '@styles/tvd.less'
const datasets = [{ const numericRender = (d) => d && Number.isFinite(+d) ? (+d).toFixed(2) : '-'
key: 'fact',
label: 'Факт', const tooltipRender = (data) => {
type: 'line', if (!data || data.length <= 0) return (
color: '#0A0', <span>Данных нет</span>
width: 3, )
yAxis: {
type: 'linear', return data.map(({ chart, data }) => {
accessor: (row) => row.depth, const xFormat = (d) => chart.xAxis.format?.(d) ?? `${numericRender(d)} ${chart.xAxis.unit ?? ''}`
unit: 'м', const yFormat = (d) => chart.yAxis.format?.(d) ?? `${numericRender(d)} ${chart.yAxis.unit ?? ''}`
},
}, { return (
key: 'plan', <div className={'tooltip-group'} key={chart.key}>
label: 'План', <div className={'group-label'}>
type: 'line', <LineChartOutlined style={{ color: chart.color }} />
color: '#F00', <span>{chart.label}:</span>
width: 3, </div>
yAxis: { {data.map((d, i) => {
type: 'linear', const text = `${xFormat(chart.x(d))} :: ${yFormat(chart.y(d))}`
accessor: (row) => row.depth,
unit: 'м', const href = ['plan', 'fact'].includes(chart.key) && `/well/${d.idWell}/operations/${chart.key}/?selectedId=${d.id}`
},
}, { return (
key: 'predict', <div key={`${i}`}>
label: 'Прогноз', {href ? (
type: 'line', <Link style={{ color: 'inherit', textDecoration: 'underline' }} to={href} title={'Перейти к таблице операций'}>
color: 'purple', <span style={{ marginRight: '5px' }}>{text}</span>
width: 1, <LinkOutlined />
afterDraw: (d) => d().selectAll('path').attr('stroke-dasharray', [7, 3]), </Link>
yAxis: { ) : (
type: 'linear', <span title={'Нельзя осуществить переход к этой операции'}>
accessor: 'depth', {text}
unit: 'м', </span>
}, )}
}, { </div>
key: 'withoutNpt', )
label: '', })}
type: 'line', </div>
color: '#00F', )
width: 3, })
yAxis: { }
type: 'linear',
accessor: 'depth',
unit: 'м',
},
}]
const xAxis = { const xAxis = {
date: { date: {
type: 'time', type: 'time',
accessor: (row) => new Date(row.dateStart), accessor: (row) => new Date(row.dateStart),
format: (d) => formatDate(d),
}, },
day: { day: {
type: 'linear', type: 'linear',
@ -80,6 +75,7 @@ const ticks = {
day: { day: {
x: { x: {
visible: true, visible: true,
count: 20,
format: (d) => d, format: (d) => d,
}, },
y: { visible: true }, y: { visible: true },
@ -87,6 +83,7 @@ const ticks = {
date: { date: {
x: { x: {
visible: true, visible: true,
count: 20,
format: (d) => formatDate(d, undefined, 'YYYY-MM-DD'), format: (d) => formatDate(d, undefined, 'YYYY-MM-DD'),
}, },
y: { visible: true }, y: { visible: true },
@ -103,30 +100,43 @@ 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 Tvd = memo(({ idWell: wellId, title, ...other }) => {
const [xLabel, setXLabel] = useState('day') const [xLabel, setXLabel] = useState('day')
const [operations, setOperations] = useState({}) const [operations, setOperations] = useState({})
const [tableVisible, setTableVisible] = useState(false) const [tableVisible, setTableVisible] = useState(false)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [pointsEnabled, setPointsEnabled] = useState(true)
const idWellContext = useIdWell() const idWellContext = useIdWell()
const idWell = useMemo(() => wellId ?? idWellContext, [wellId, idWellContext]) 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(() => { const toogleTable = useCallback(() => {
setOperations(pre => ({ ...pre })) setOperations(pre => ({ ...pre }))
@ -139,9 +149,9 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
if (row?.isNPT !== false) return if (row?.isNPT !== false) return
const nptH = +(row.nptHours ?? 0) const nptH = +(row.nptHours ?? 0)
withoutNpt.push({ withoutNpt.push({
...row, depth: row.depth,
day: row.day - nptH / 24, day: row.day - nptH / 24,
date: fractionalSum(row.date, -nptH, 'hour'), dateStart: fractionalSum(row.date, -nptH, 'hour'),
}) })
}) })
@ -156,18 +166,38 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
'Получение списка опервций по скважине' 'Получение списка опервций по скважине'
) )
}, [idWell]) }, [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 ( return (
<div className={'container tvd-page'} {...other}> <div className={'container tvd-page'} {...other}>
<div className={'tvd-top'}> <div className={'tvd-top'}>
<h2>{title || 'График Глубина-день'}</h2> <h2>{title || 'График Глубина-день'}</h2>
<div> <div>
<Switch
defaultChecked
checkedChildren={'С рисками'}
unCheckedChildren={'Без рисок'}
onChange={(checked) => setPointsEnabled(checked)}
style={{ marginRight: 20 }}
title={'Нажмите для переключения видимости засечек на графиках'}
/>
<Switch <Switch
checkedChildren={'Дата'} checkedChildren={'Дата'}
unCheckedChildren={'Дни со старта'} unCheckedChildren={'Дни со старта'}
loading={isLoading} loading={isLoading}
onChange={(checked) => setXLabel(checked ? 'date' : 'day')} onChange={(checked) => setXLabel(checked ? 'date' : 'day')}
style={{ marginRight: '20px' }} style={{ marginRight: '20px' }}
title={'Нажмите для переключения горизонтальной оси'}
/> />
<NetGraphExport idWell={idWell} /> <NetGraphExport idWell={idWell} />
<Button icon={tableVisible ? <DoubleRightOutlined /> : <DoubleLeftOutlined />} onClick={toogleTable}>НПВ</Button> <Button icon={tableVisible ? <DoubleRightOutlined /> : <DoubleLeftOutlined />} onClick={toogleTable}>НПВ</Button>
@ -184,11 +214,8 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
datasets={datasets} datasets={datasets}
loading={isLoading} loading={isLoading}
ticks={ticks[xLabel]} ticks={ticks[xLabel]}
plugins={{ plugins={plugins}
tooltip: { animDurationMs={0}
enabled: true
}
}}
/> />
</div> </div>
{tableVisible && <NptTable operations={operations?.fact} />} {tableVisible && <NptTable operations={operations?.fact} />}

View File

@ -17,7 +17,7 @@
border: none; border: none;
border-radius: 2px; border-radius: 2px;
background-color: @bg-color; background-color: @bg-color;
transition: opacity .1s ease-out; transition: opacity .1s ease-out;
&::after { &::after {
@ -27,28 +27,50 @@
height: 0; height: 0;
border: @arrow-size solid transparent; border: @arrow-size solid transparent;
} }
&.bottom::after { &.top {
border-top-color: @bg-color; margin-top: @arrow-size;
top: 100%;
left: 50%; &::after {
margin-left: -@arrow-size; border-bottom-color: @bg-color;
top: -@arrow-size*2;
left: 50%;
margin-left: -@arrow-size;
}
} }
& .tooltip-group {
display: flex; &.bottom {
flex-direction: column; margin-top: 0;
justify-content: stretch;
align-items: flex-start;
& .group-label { &::after {
width: 200%; border-top-color: @bg-color;
overflow: hidden; top: 100%;
left: 50%;
& span { margin-left: -@arrow-size;
font-weight: 600; }
margin-left: 5px; }
white-space: nowrap;
& .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;
}
} }
} }
} }

View File

@ -33,13 +33,13 @@
} }
&.tvd-tr-table { &.tvd-tr-table {
right: 15px; right: 10px;
top: 38px; top: 0;
} }
&.tvd-bl-table { &.tvd-bl-table {
bottom: 35px; bottom: 35px;
left: 50px; left: 55px;
} }
} }

View File

@ -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 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++) for (let i = 1; i < points.length - 1; i++)
if (!isEquals(points[i - 1], points[i]) || !isEquals(points[i], points[i + 1])) if (!isEquals(points[i - 1], points[i]) || !isEquals(points[i], points[i + 1]))
out.push(points[i]) out.push(points[i])

View File

@ -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(() => { const result: T = useMemo(() => {
if (!prop || typeof prop !== 'object') return defaultValue if (!prop || typeof prop !== 'object') return def()
return { ...defaultValue, ...prop } return { ...def(), ...prop }
}, [prop, defaultValue]) }, [prop, def])
return result return result
} }