diff --git a/src/components/d3/D3Chart.tsx b/src/components/d3/D3Chart.tsx index 31dc4a5..b175b0d 100644 --- a/src/components/d3/D3Chart.tsx +++ b/src/components/d3/D3Chart.tsx @@ -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 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 = { - type: 'time', - accessor: (d: any) => new Date(d.date) -} - const getGroupClass = (key: string | number) => `chart-id-${key}` -const getByAccessor = >(accessor: string | ((d: T) => any)) => { +const getByAccessor = , 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 = (config: ChartAxis) => { @@ -47,7 +56,7 @@ const createAxis = (config: ChartAxis) => { return d3.scaleLinear() } -export type D3ChartProps = React.DetailedHTMLProps, HTMLDivElement> & { +export type D3ChartProps = React.DetailedHTMLProps, HTMLDivElement> & { xAxis: ChartAxis datasets: ChartDataset[] data?: DataType[] | Record @@ -63,10 +72,16 @@ export type D3ChartProps = React.DetailedHTMLProps cursor?: BasePluginSettings & D3CursorSettings + legend?: BasePluginSettings & D3LegendSettings } } -export const D3Chart = memo>(({ +const getDefaultXAxisConfig = (): ChartAxis => ({ + type: 'time', + accessor: (d: any) => new Date(d.date) +}) + +const _D3Chart = >({ className = '', xAxis: _xAxisConfig, datasets, @@ -81,8 +96,9 @@ export const D3Chart = memo>(({ ticks, plugins, ...other -}) => { - const xAxisConfig = usePartialProps(_xAxisConfig, defaultXAxisConfig) +}: D3ChartProps) => { + const xAxisConfig = usePartialProps>(_xAxisConfig, getDefaultXAxisConfig) + const offset = usePartialProps(_offset, defaultOffsets) const [svgRef, setSvgRef] = useState(null) @@ -94,15 +110,15 @@ export const D3Chart = memo>(({ 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[]>([]) + const [charts, setCharts] = useState[]>([]) 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>(({ } 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>(({ console.warn(`Ключ датасета "${datasets[i].key}" неуникален (индексы ${i} и ${j})!`) setCharts((oldCharts) => { - const charts: ChartRegistry[] = [] + const charts: ChartRegistry[] = [] for (const chart of oldCharts) { // Удаляем ненужные графики if (datasets.find(({ key }) => key === chart.key)) @@ -215,9 +231,14 @@ export const D3Chart = memo>(({ if (chartIdx < 0) chartIdx = charts.length - const newChart: ChartRegistry = Object.assign( - () => chartArea().select('.' + getGroupClass(dataset.key)), + const newChart: ChartRegistry = Object.assign( + () => chartArea().select('.' + getGroupClass(dataset.key)) as d3.Selection, { + 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>(({ } ) + if (newChart.type === 'line') + newChart.optimization = false + if (!newChart().node()) chartArea() .append('g') @@ -235,100 +259,42 @@ export const D3Chart = memo>(({ 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(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(xAxis, yAxis, chart, chartData) + break + case 'point': + chartData = renderPoint(xAxis, yAxis, chart, chartData) break - } default: break } + if (chart.point) + renderPoint(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>(({ - + - {(plugins?.cursor?.enabled ?? true) && ( )} - {(plugins?.tooltip?.enabled ?? true) && ( )} + + charts={charts} {...plugins?.legend} /> + charts={charts} {...plugins?.tooltip} /> @@ -369,6 +340,8 @@ export const D3Chart = memo>(({ ) -}) +} + +export const D3Chart = memo(_D3Chart) as typeof _D3Chart export default D3Chart diff --git a/src/components/d3/D3MouseZone.tsx b/src/components/d3/D3MouseZone.tsx index 1a9dd32..3efb8e7 100644 --- a/src/components/d3/D3MouseZone.tsx +++ b/src/components/d3/D3MouseZone.tsx @@ -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({ +export const defaultMouseZoneContext: D3MouseZoneContext = { mouseState: { x: NaN, y: NaN, @@ -34,15 +34,17 @@ export const D3MouseZoneContext = createContext({ zone: null, zoneRect: null, subscribe: () => null -}) +} + +export const D3MouseZoneContext = createContext(defaultMouseZoneContext) export const useD3MouseZone = () => useContext(D3MouseZoneContext) export const D3MouseZone = memo(({ width, height, offset, children }) => { - const zoneRef = useRef(null) const rectRef = useRef(null) const [state, setState] = useState({ x: 0, y: 0, visible: false }) + const [childContext, setChildContext] = useState(defaultMouseZoneContext) const subscribeEvent: SubscribeFunction = useCallback((name, handler) => { if (!rectRef.current) return null @@ -54,6 +56,17 @@ export const D3MouseZone = memo(({ 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(({ 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 ( - + = D3LegendSettings & { + charts: ChartRegistry[] +} + +const _D3Legend = ({ + charts, + width, + height, + offset: _offset, + position = 'top-center', + backgroundColor = 'transparent', + color = 'black', + type = 'vertical', +}: D3LegendProps) => { + const legendRef = useRef(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 ( + + + + ) +} + +export const D3Legend = wrapPlugin(_D3Legend) as typeof _D3Legend + +export default D3Legend diff --git a/src/components/d3/plugins/D3Tooltip.tsx b/src/components/d3/plugins/D3Tooltip.tsx index 4f8e4bf..6539d0e 100644 --- a/src/components/d3/plugins/D3Tooltip.tsx +++ b/src/components/d3/plugins/D3Tooltip.tsx @@ -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 = { export type D3RenderFunction = (data: D3RenderData, mouseState: D3MouseState) => ReactNode -export type BaseTooltip = { +export type D3TooltipSettings = { render?: D3RenderFunction width?: number | string height?: number | string @@ -29,27 +29,24 @@ export type BaseTooltip = { position?: D3TooltipPosition className?: string touchType?: D3TooltipTouchType -} - -export type AccurateTooltip = { type: 'accurate' } -export type NearestTooltip = { - type: 'nearest' limit?: number } -export type D3TooltipSettings = BaseTooltip & (AccurateTooltip | NearestTooltip) - -const defaultRender: D3RenderFunction> = (data, mouseState) => ( +const makeDefaultRender = (): D3RenderFunction => (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 (
@@ -58,7 +55,7 @@ const defaultRender: D3RenderFunction> = (data, mouseState)
{data.map((d, i) => ( - {formatDate(chart.x(d))} {chart.xAxis.unit} :: {chart.y(d).toFixed(2)} {chart.yAxis.unit} + {xFormat(chart.x(d))} :: {yFormat(chart.y(d))} ))}
@@ -69,7 +66,7 @@ const defaultRender: D3RenderFunction> = (data, mouseState) ) -export type D3TooltipProps = Partial> & { +export type D3TooltipProps = Partial> & { charts: ChartRegistry[], } @@ -114,31 +111,41 @@ const getTouchedElements = ( let nodes: d3.Selection 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(function D3Tooltip({ - type = 'accurate', +function _D3Tooltip>({ width = 200, height = 120, - render = defaultRender, + render = makeDefaultRender(), charts, position: _position = 'bottom', - className, + className = '', style: _style = {}, touchType = 'all', - ...other -}) { + limit = 2 +}: D3TooltipProps) { const { mouseState, zoneRect, subscribe } = useD3MouseZone() const [tooltipBody, setTooltipBody] = useState() const [style, setStyle] = useState(_style) @@ -179,8 +186,10 @@ export const D3Tooltip = wrapPlugin(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(function D3Tooltip({ if (!mouseState.visible) return setVisible(false) - const data: D3RenderData = [] + const data: D3RenderData = [] 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(function D3Tooltip({ setVisible(data.length > 0) if (data.length > 0) setTooltipBody(render(data, mouseState)) - }, [charts, touchType, mouseState, fixed]) + }, [charts, touchType, mouseState, fixed, limit]) return ( (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' }} >
- {tooltipBody} +
+ {tooltipBody} +
) -}) +} + +export const D3Tooltip = wrapPlugin(_D3Tooltip) as typeof _D3Tooltip export default D3Tooltip diff --git a/src/components/d3/plugins/base.tsx b/src/components/d3/plugins/base.tsx index c011c52..06e91a4 100644 --- a/src/components/d3/plugins/base.tsx +++ b/src/components/d3/plugins/base.tsx @@ -4,12 +4,18 @@ export type BasePluginSettings = { enabled?: boolean } -export const wrapPlugin = (Component: FC, defaultEnabled?: boolean) => { - const wrappedComponent = memo(({ enabled, ...props }) => { +type InferArgs = T extends (...t: [...infer Arg]) => any ? Arg : never +type InferReturn = T extends (...t: [...infer Arg]) => infer Res ? Res : never + +export const wrapPlugin = ( + Component: FC, + defaultEnabled?: boolean +): FC => { + const wrappedComponent = ({ enabled, ...props }: TProps & BasePluginSettings) => { if (!(enabled ?? defaultEnabled)) return <> - return - }) + return + } return wrappedComponent } diff --git a/src/components/d3/plugins/index.ts b/src/components/d3/plugins/index.ts index 5ca80dd..40c5d84 100644 --- a/src/components/d3/plugins/index.ts +++ b/src/components/d3/plugins/index.ts @@ -1,4 +1,5 @@ export * from './base' export * from './D3ContextMenu' export * from './D3Cursor' +export * from './D3Legend' export * from './D3Tooltip' diff --git a/src/components/d3/renders/index.ts b/src/components/d3/renders/index.ts new file mode 100644 index 0000000..0cddad8 --- /dev/null +++ b/src/components/d3/renders/index.ts @@ -0,0 +1,3 @@ +export * from './line' +export * from './needle' +export * from './points' diff --git a/src/components/d3/renders/line.ts b/src/components/d3/renders/line.ts new file mode 100644 index 0000000..6ac13af --- /dev/null +++ b/src/components/d3/renders/line.ts @@ -0,0 +1,44 @@ +import * as d3 from 'd3' + +import { ChartRegistry } from '@components/d3/types' +import { makePointsOptimizator } from '@utils' + +export const renderLine = >( + xAxis: (value: any) => number, + yAxis: (value: any) => number, + chart: ChartRegistry, + 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((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 +} diff --git a/src/components/d3/renders/needle.ts b/src/components/d3/renders/needle.ts new file mode 100644 index 0000000..0532143 --- /dev/null +++ b/src/components/d3/renders/needle.ts @@ -0,0 +1,32 @@ +import { ChartOffset, ChartRegistry } from '@components/d3/types' + +export const renderNeedle = >( + xAxis: (value: d3.NumberValue) => number, + yAxis: (value: d3.NumberValue) => number, + chart: ChartRegistry, + 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 +} diff --git a/src/components/d3/renders/points.ts b/src/components/d3/renders/points.ts new file mode 100644 index 0000000..c7f2dad --- /dev/null +++ b/src/components/d3/renders/points.ts @@ -0,0 +1,55 @@ +import { ChartRegistry, PointChartDataset } from '@components/d3/types' + +export const renderPoint = >( + xAxis: (value: any) => number, + yAxis: (value: any) => number, + chart: ChartRegistry, + data: DataType[] +): DataType[] => { + let config: Required> = { + 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 +} diff --git a/src/components/d3/types.ts b/src/components/d3/types.ts index 6070bce..f186338 100644 --- a/src/components/d3/types.ts +++ b/src/components/d3/types.ts @@ -5,36 +5,40 @@ import { D3TooltipSettings } from './plugins' -export type DefaultDataType = Record -type Selection = d3.Selection - export type ChartAxis = { 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 = { key: string | number - label?: ReactNode yAxis: ChartAxis xAxis: ChartAxis + label?: ReactNode color?: Property.Color opacity?: number - width?: Property.StrokeWidth + width?: number | string tooltip?: D3TooltipSettings animDurationMs?: number + point?: Omit 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 = BaseChartDataset & ( LineChartDataset | - NeedleChartDataset + NeedleChartDataset | + PointChartDataset ) export type ChartDomain = { @@ -70,8 +75,8 @@ export type ChartTicks = { y?: { visible?: boolean, count?: number } } -export type ChartRegistry = ChartDataset & { - (): Selection +export type ChartRegistry = ChartDataset & { + (): d3.Selection y: (value: any) => number x: (value: any) => number } diff --git a/src/pages/Telemetry/Operations/OperationsChart.jsx b/src/pages/Telemetry/Operations/OperationsChart.jsx index aac8172..6d6b655 100644 --- a/src/pages/Telemetry/Operations/OperationsChart.jsx +++ b/src/pages/Telemetry/Operations/OperationsChart.jsx @@ -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, diff --git a/src/pages/WellOperations/Tvd/AdditionalTables.jsx b/src/pages/WellOperations/Tvd/AdditionalTables.jsx index 3880b21..35fb51c 100755 --- a/src/pages/WellOperations/Tvd/AdditionalTables.jsx +++ b/src/pages/WellOperations/Tvd/AdditionalTables.jsx @@ -59,7 +59,7 @@ export const AdditionalTables = memo(({ operations, xLabel, setIsLoading }) => { {numericRender(nptSum)} -
+
{numericRender(additionalData.lag)} {printDate(additionalData.planStartDate)} diff --git a/src/pages/WellOperations/Tvd/index.jsx b/src/pages/WellOperations/Tvd/index.jsx index ac28332..2d70ce2 100755 --- a/src/pages/WellOperations/Tvd/index.jsx +++ b/src/pages/WellOperations/Tvd/index.jsx @@ -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 ( + Данных нет + ) + + 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 ( +
+
+ + {chart.label}: +
+ {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 ( +
+ {href ? ( + + {text} + + + ) : ( + + {text} + + )} +
+ ) + })} +
+ ) + }) +} 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,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 [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 })) @@ -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'), }) }) @@ -156,18 +166,38 @@ 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 (

{title || 'График Глубина-день'}

+ setPointsEnabled(checked)} + style={{ marginRight: 20 }} + title={'Нажмите для переключения видимости засечек на графиках'} + /> setXLabel(checked ? 'date' : 'day')} style={{ marginRight: '20px' }} + title={'Нажмите для переключения горизонтальной оси'} /> @@ -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} />
{tableVisible && } diff --git a/src/styles/d3.less b/src/styles/d3.less index dcef26e..5701d12 100644 --- a/src/styles/d3.less +++ b/src/styles/d3.less @@ -17,7 +17,7 @@ border: none; border-radius: 2px; background-color: @bg-color; - + transition: opacity .1s ease-out; &::after { @@ -27,28 +27,50 @@ height: 0; 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; + + &.bottom { + margin-top: 0; - & .group-label { - width: 200%; - overflow: hidden; - - & 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; + } } } } diff --git a/src/styles/tvd.less b/src/styles/tvd.less index 2020071..c5c1356 100755 --- a/src/styles/tvd.less +++ b/src/styles/tvd.less @@ -33,13 +33,13 @@ } &.tvd-tr-table { - right: 15px; - top: 38px; + right: 10px; + top: 0; } &.tvd-bl-table { bottom: 35px; - left: 50px; + left: 55px; } } diff --git a/src/utils/functions/chart.ts b/src/utils/functions/chart.ts index 4d76019..e178a73 100644 --- a/src/utils/functions/chart.ts +++ b/src/utils/functions/chart.ts @@ -1,7 +1,7 @@ -export const makePointsOptimizator = (isEquals: (a: Record, b: Record) => boolean) => (points: Record[]) => { +export const makePointsOptimizator = >(isEquals: (a: DataType, b: DataType) => boolean) => (points: DataType[]) => { if (!Array.isArray(points) || points.length < 3) return points - const out: Record[] = [] + 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]) diff --git a/src/utils/hooks/usePartialProps.ts b/src/utils/hooks/usePartialProps.ts index edaebaf..c24fca1 100644 --- a/src/utils/hooks/usePartialProps.ts +++ b/src/utils/hooks/usePartialProps.ts @@ -1,10 +1,13 @@ -import { useMemo } from "react" +import { useMemo } from 'react' +import { FunctionalValue, useFunctionalValue } from './functionalValue' + +export const usePartialProps = (prop: Partial | null | undefined, defaultValue: FunctionalValue): T => { + const def = useFunctionalValue(defaultValue) -export const usePartialProps = (prop: Partial | 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 }