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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -28,27 +28,49 @@
border: @arrow-size solid transparent;
}
&.bottom::after {
border-top-color: @bg-color;
top: 100%;
left: 50%;
margin-left: -@arrow-size;
&.top {
margin-top: @arrow-size;
&::after {
border-bottom-color: @bg-color;
top: -@arrow-size*2;
left: 50%;
margin-left: -@arrow-size;
}
}
& .tooltip-group {
display: flex;
flex-direction: column;
justify-content: stretch;
align-items: flex-start;
& .group-label {
width: 200%;
overflow: hidden;
&.bottom {
margin-top: 0;
& span {
font-weight: 600;
margin-left: 5px;
white-space: nowrap;
&::after {
border-top-color: @bg-color;
top: 100%;
left: 50%;
margin-left: -@arrow-size;
}
}
& .tooltip-content {
overflow: hidden;
width: 100%;
height: 100%;
& .tooltip-group {
display: flex;
flex-direction: column;
justify-content: stretch;
align-items: flex-start;
& .group-label {
width: 200%;
overflow: hidden;
& span {
font-weight: 600;
margin-left: 5px;
white-space: nowrap;
}
}
}
}

View File

@ -33,13 +33,13 @@
}
&.tvd-tr-table {
right: 15px;
top: 38px;
right: 10px;
top: 0;
}
&.tvd-bl-table {
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
const out: Record<string, T>[] = []
const out: DataType[] = []
for (let i = 1; i < points.length - 1; i++)
if (!isEquals(points[i - 1], points[i]) || !isEquals(points[i], points[i + 1]))
out.push(points[i])

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