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 * 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
|
||||||
|
@ -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}
|
||||||
|
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 { 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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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'
|
||||||
|
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
|
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
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
||||||
|
@ -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} />}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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])
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user