diff --git a/src/components/d3/D3Chart.tsx b/src/components/d3/D3Chart.tsx index 4ed1f24..968c9fc 100644 --- a/src/components/d3/D3Chart.tsx +++ b/src/components/d3/D3Chart.tsx @@ -57,21 +57,37 @@ const createAxis = (config: ChartAxis) => { } export type D3ChartProps = React.DetailedHTMLProps, HTMLDivElement> & { + /** Параметры общей горизонтальной оси */ xAxis: ChartAxis + /** Параметры графиков */ datasets: ChartDataset[] + /** Массив отображаемых данных или объект с парами ключ графика-данные */ data?: DataType[] | Record + /** Диапозон отображаемых значений */ domain?: Partial - width?: number | string - height?: number | string + /** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */ + width?: string | number + /** Высота графика числом пикселей или CSS-значением (px/%/em/rem) */ + height?: string | number + /** Задать статус "заргужается" графику */ loading?: boolean + /** Отступы графика от края SVG */ offset?: Partial + /** Длительность анимации в миллисекундах */ animDurationMs?: number + /** Цвет фона в формате CSS-значения */ backgroundColor?: Property.Color + /** Настройки рисок и цен деления графика */ ticks?: ChartTicks + /** Параметры плагинов */ plugins?: { + /** Параметры контекстного меню */ menu?: BasePluginSettings & D3ContextMenuSettings + /** Параметры всплывающей подсказки */ tooltip?: BasePluginSettings & D3TooltipSettings + /** Параметры курсора */ cursor?: BasePluginSettings & D3CursorSettings + /** Параметры блока легенды */ legend?: BasePluginSettings & D3LegendSettings } } diff --git a/src/components/d3/D3MonitoringCharts.tsx b/src/components/d3/D3MonitoringCharts.tsx index 2f2ae1e..f92c3a3 100644 --- a/src/components/d3/D3MonitoringCharts.tsx +++ b/src/components/d3/D3MonitoringCharts.tsx @@ -20,12 +20,11 @@ import { D3ContextMenu, D3ContextMenuSettings, D3HorizontalCursor, - D3HorizontalCursorSettings, - D3TooltipSettings + D3HorizontalCursorSettings } from './plugins' import D3MouseZone from './D3MouseZone' import { getByAccessor, getChartClass, getGroupClass, getTicks } from './functions' -import { renderArea, renderLine, renderNeedle, renderPoint } from './renders' +import { renderArea, renderLine, renderNeedle, renderPoint, renderRectArea } from './renders' const roundTo = (v: number, to: number = 50) => { if (to == 0) return v @@ -44,22 +43,21 @@ const calculateDomain = (mm: MinMax, round: number = 100): Required => { return { min, max } } -type AxisScale = d3.ScaleTime | d3.ScaleLinear - type ExtendedChartDataset = ChartDataset & { + /** Диапазон отображаемых значений по горизонтальной оси */ xDomain: MinMax - hideXAxis?: boolean + /** Скрыть отображение шкалы графика */ + hideLabel?: boolean } -type ExtendedChartRegistry = ExtendedChartDataset & { - (): d3.Selection - y: (value: any) => number - x: (value: any) => number -} +type ExtendedChartRegistry = ChartRegistry & ExtendedChartDataset export type ChartGroup = { + /** Получить D3 выборку, содержащую корневой G-элемент группы */ (): d3.Selection + /** Уникальный ключ группы (индекс) */ key: number + /** Массив содержащихся в группе графиков */ charts: ExtendedChartRegistry[] } @@ -82,51 +80,72 @@ const getDefaultYTicks = (): Required> => ({ count: 10, }) -const findChartsByKey = (groups: ChartGroup[], key: string) => { - const out: ChartRegistry[] = [] - groups.forEach((group) => { - const res = group.charts.find((chart) => chart.key === key) - if (res) out.push(res) - }) - return out -} - -export type D3MonitoringChartsProps = React.DetailedHTMLProps, HTMLDivElement> & { +/** + * @template DataType тип данных отображаемых записей + */ +export type D3MonitoringChartsProps> = React.DetailedHTMLProps, HTMLDivElement> & { + /** Двумерный массив датасетов (группа-график) */ datasetGroups: ExtendedChartDataset[][] + /** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */ width?: string | number + /** Высота графика числом пикселей или CSS-значением (px/%/em/rem) */ height?: string | number + /** Длительность анимации в миллисекундах */ animDurationMs?: number + /** Задать статус "заргужается" графику */ loading?: boolean + /** Массив отображаемых данных */ data?: DataType[] + /** Отступы графика от края SVG */ offset?: Partial + /** Цвет фона в формате CSS-значения */ backgroundColor?: Property.Color + /** Параметры общей вертикальной оси */ yAxis?: ChartAxis + /** Параметры плагинов */ plugins?: { + /** Параметры горизонтального курсора */ cursor?: BasePluginSettings & D3HorizontalCursorSettings + /** Параметры контекстного меню */ menu?: BasePluginSettings & D3ContextMenuSettings - tooltip?: BasePluginSettings & D3TooltipSettings } + /** Настройки рисок и цен деления вертикальной шкалы */ yTicks?: ChartTick - yDomain?: { - min?: number - max?: number - } + /** Диапозон отображаемых значений по вертикальной оси (сверху вниз) */ + yDomain?: MinMax + /** Событие, вызываемое при прокрутке колёсика мышки над графиком */ onWheel: (e: WheelEvent) => void + /** Высота шкал графиков в пикселях (20 по умолчанию) */ + axisHeight?: number + /** Отступ между группами графиков в пикселях (30 по умолчанию) */ + spaceBetweenGroups?: number } export type ChartSizes = ChartOffset & { + /** Ширина зоны графика */ inlineWidth: number + /** Высота зоны графика */ inlineHeight: number + /** Ширина группы на графике */ groupWidth: number + /** Высота блока осей */ axesHeight: number + /** Отступ сверху до активной зоны графиков */ chartsTop: number + /** Высота активной зоны графиков */ chartsHeight: number + /** Отступ слева для `i`-ой группы */ groupLeft: (i: number) => number + /** Отступ сверху для `i`-ой оси в группе размером `count` */ axisTop: (i: number, count: number) => number } -const axisHeight = 20 -const space = 30 +type ChartDomain = { + /** Шкала графика */ + scale: d3.ScaleLinear, + /** Диапазон отображаемых на графике занчений */ + domain: Required, +} const _D3MonitoringCharts = >({ width: givenWidth = '100%', @@ -141,12 +160,13 @@ const _D3MonitoringCharts = >({ backgroundColor = 'transparent', yDomain, yTicks: _yTicks, + axisHeight = 20, + spaceBetweenGroups = 30, className = '', ...other }: D3MonitoringChartsProps) => { const [groups, setGroups] = useState[]>([]) - const [groupScales, setGroupScales] = useState[]>([]) const [svgRef, setSvgRef] = useState(null) const [yAxisRef, setYAxisRef] = useState(null) const [chartAreaRef, setChartAreaRef] = useState(null) @@ -167,10 +187,9 @@ const _D3MonitoringCharts = >({ const inlineHeight = Math.max(height - offset.top - offset.bottom, 0) const groupsCount = groups.length - const groupWidth = groupsCount ? (inlineWidth - space * (groupsCount - 1)) / groupsCount : 0 + const groupWidth = groupsCount ? (inlineWidth - spaceBetweenGroups * (groupsCount - 1)) / groupsCount : 0 - let maxChartCount = Math.max(...groups.map((group) => group.charts.length)) - if (!Number.isFinite(maxChartCount)) maxChartCount = 0 + const maxChartCount = Math.max(0, ...groups.map((g) => g.charts.filter((c) => !c.hideLabel).length)) const axesHeight = (axisHeight * maxChartCount) return ({ @@ -181,7 +200,7 @@ const _D3MonitoringCharts = >({ axesHeight, chartsTop: offset.top + axesHeight, chartsHeight: inlineHeight - axesHeight, - groupLeft: (i: number) => (groupWidth + space) * i, + groupLeft: (i: number) => (groupWidth + spaceBetweenGroups) * i, axisTop: (i: number, count: number) => axisHeight * (maxChartCount - count + i + 1) }) }, [groups, height, offset]) @@ -204,29 +223,33 @@ const _D3MonitoringCharts = >({ } ), [chartArea, axesArea]) - const chartDomains: Record, - domain: Required, - }>[] = useMemo(() => { - return groups.map((group) => { - const out = group.charts.map((chart) => { - const mm = { ...chart.xDomain } - let domain: Required = { min: 0, max: 100 } - if (mm.min && mm.max) { - domain = mm as Required - } else if (data) { - const [min, max] = d3.extent(data, chart.x) - domain = calculateDomain({ min, max, ...mm }, 100) - } - return [chart.key, { - scale: d3.scaleLinear().domain([domain.min, domain.max]), - domain, - }] - }) - - return Object.fromEntries(out) + const chartDomains = useMemo(() => groups.map((group) => { + const out: [string | number, ChartDomain][] = group.charts.map((chart) => { + const mm = { ...chart.xDomain } + let domain: Required = { min: 0, max: 100 } + if (mm.min && mm.max) { + domain = mm as Required + } else if (data) { + const [min, max] = d3.extent(data, chart.x) + domain = calculateDomain({ min, max, ...mm }, 100) + } + return [chart.key, { + scale: d3.scaleLinear().domain([domain.min, domain.max]), + domain, + }] }) - }, [groups, data]) + + out.forEach(([key], i) => { + const chart = group.charts.find((chart) => chart.key === key) + const bind = chart?.bindDomainFrom + if (!bind) return + const bindDomain = out.find(([key]) => key === bind) + if (bindDomain) + out[i][1] = bindDomain[1] + }) + + return Object.fromEntries(out) + }), [groups, data]) useEffect(() => { if (isDev()) { @@ -319,7 +342,7 @@ const _D3MonitoringCharts = >({ actualAxesGroups.each(function(group, i) { const groupAxes = d3.select(this) - const chartsData = group.charts.filter((chart) => !chart.hideXAxis) + const chartsData = group.charts.filter((chart) => !chart.hideLabel) const charts = groupAxes.selectChildren().data(chartsData) charts.exit().remove() @@ -365,7 +388,7 @@ const _D3MonitoringCharts = >({ .attr('stroke', j === chartsData.length - 1 ? 'gray' : 'currentColor') }) }) - }, [groups, groupScales, sizes, space, chartDomains]) + }, [groups, sizes, spaceBetweenGroups, chartDomains]) useEffect(() => { // Рисуем ось Y if (!yAxis) return @@ -422,6 +445,9 @@ const _D3MonitoringCharts = >({ case 'area': chartData = renderArea(xAxis, yAxis, chart, chartData) break + case 'rect_area': + renderRectArea(xAxis, yAxis, chart) + break default: break } @@ -432,7 +458,7 @@ const _D3MonitoringCharts = >({ chart.afterDraw?.(chart) }) }) - }, [data, groups, groupScales, height, offset, sizes, chartDomains]) + }, [data, groups, height, offset, sizes, chartDomains]) return ( >({ {d3.range(1, groups.length).map((i) => { - const x = offset.left + (sizes.groupWidth + space) * i - space / 2 + const x = offset.left + (sizes.groupWidth + spaceBetweenGroups) * i - spaceBetweenGroups / 2 return })} diff --git a/src/components/d3/D3MouseZone.tsx b/src/components/d3/D3MouseZone.tsx index 3efb8e7..fb85a4a 100644 --- a/src/components/d3/D3MouseZone.tsx +++ b/src/components/d3/D3MouseZone.tsx @@ -1,27 +1,39 @@ -import { createContext, memo, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react' +import { createContext, memo, ReactNode, useCallback, useContext, useEffect, useState } from 'react' import * as d3 from 'd3' import '@styles/d3.less' +import { ChartOffset } from './types' export type D3MouseState = { + /** Позиция мыши по оси X */ x: number + /** Позиция мыши по оси Y */ y: number + /** Находится ли мышь над активной зоной графика */ visible: boolean } type SubscribeFunction = (name: keyof SVGElementEventMap, handler: EventListenerOrEventListenerObject) => null | (() => boolean) export type D3MouseZoneContext = { + /** Состояние мыши */ mouseState: D3MouseState, - zone: (() => d3.Selection) | null + /** Получение D3 выборки, содержащей RECT-элемент обрабатывающий события мыши */ + zone: (() => d3.Selection) | null + /** Параметры позиционирования и размера зоны обработки событий */ zoneRect: DOMRect | null + /** Подписка на событие */ subscribe: SubscribeFunction } export type D3MouseZoneProps = { + /** Ширина активной зоны в пикселях */ width: number + /** Высота активной зоны в пикселях */ height: number - offset: Record + /** Отступы от края svg */ + offset: ChartOffset + /** Контекстные плагины */ children: ReactNode } @@ -41,31 +53,31 @@ export const D3MouseZoneContext = createContext(defaultMouse export const useD3MouseZone = () => useContext(D3MouseZoneContext) export const D3MouseZone = memo(({ width, height, offset, children }) => { - const rectRef = useRef(null) + const [rectRef, setRectRef] = useState(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 - rectRef.current.addEventListener(name, handler) + if (!rectRef) return null + rectRef.addEventListener(name, handler) return () => { - if (!rectRef.current) return false - rectRef.current.removeEventListener(name, handler) + if (!rectRef) return false + rectRef.removeEventListener(name, handler) return true } - }, [rectRef.current]) + }, [rectRef]) const updateContext = useCallback(() => { - const zone = rectRef.current ? (() => d3.select(rectRef.current)) : null + const zone = rectRef ? (() => d3.select(rectRef)) : null setChildContext({ mouseState: state, zone, - zoneRect: rectRef.current?.getBoundingClientRect() || null, + zoneRect: rectRef?.getBoundingClientRect() || null, subscribe: subscribeEvent, }) - }, [rectRef.current, state, subscribeEvent]) + }, [rectRef, state, subscribeEvent]) const onMouse = useCallback((e: any) => { const rect = e.target.getBoundingClientRect() @@ -91,7 +103,7 @@ export const D3MouseZone = memo(({ width, height, offset, chil return ( + /** Название графика для загрузки */ downloadFilename?: string + /** Событие, вызываемое при нажатий кнопки "Обновить" */ onUpdate?: () => void + /** Дополнительные пункты меню */ additionalMenuItems?: ItemType[] + /** Условия вызова контекстного меню */ trigger?: ('click' | 'hover' | 'contextMenu')[] } export type D3ContextMenuProps = BasePluginSettings & D3ContextMenuSettings & { children: any + /** SVG-элемент */ svg: SVGSVGElement | null } diff --git a/src/components/d3/plugins/D3Cursor.tsx b/src/components/d3/plugins/D3Cursor.tsx index c35a580..2b2adab 100644 --- a/src/components/d3/plugins/D3Cursor.tsx +++ b/src/components/d3/plugins/D3Cursor.tsx @@ -9,6 +9,7 @@ import { wrapPlugin } from './base' import '@styles/d3.less' export type D3CursorSettings = { + /** Параметры стиля линии */ lineStyle?: SVGProps } diff --git a/src/components/d3/plugins/D3HorizontalCursor.tsx b/src/components/d3/plugins/D3HorizontalCursor.tsx index 636a600..2b0c687 100644 --- a/src/components/d3/plugins/D3HorizontalCursor.tsx +++ b/src/components/d3/plugins/D3HorizontalCursor.tsx @@ -1,10 +1,9 @@ -import { AreaChartOutlined, BarChartOutlined, BorderOuterOutlined, DotChartOutlined, LineChartOutlined } from '@ant-design/icons' import { CSSProperties, ReactNode, SVGProps, useEffect, useMemo, useRef, useState } from 'react' import * as d3 from 'd3' import { useD3MouseZone } from '@components/d3/D3MouseZone' import { ChartGroup, ChartSizes } from '@components/d3/D3MonitoringCharts' -import { isDev, usePartialProps } from '@utils' +import { getChartIcon, isDev, usePartialProps } from '@utils' import { wrapPlugin } from './base' import { D3TooltipPosition } from './D3Tooltip' @@ -40,22 +39,13 @@ const offsetY = 5 const makeDefaultRender = (): D3GroupRenderFunction => (group, data) => ( <> {data.length > 0 ? group.charts.map((chart) => { - 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 'rect_area': Icon = BorderOuterOutlined; 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 (
- + {getChartIcon(chart)} {chart.shortLabel || chart.label}:
{data.map((d, i) => ( @@ -89,10 +79,12 @@ const _D3HorizontalCursor = ({ const zoneRef = useRef(null) const { mouseState, zoneRect, subscribe } = useD3MouseZone() + const [position, setPosition] = useState(_position ?? 'bottom') const [tooltipBodies, setTooltipBodies] = useState([]) const [tooltipY, setTooltipY] = useState(0) const [fixed, setFixed] = useState(false) + const [lineY, setLineY] = useState(0) const lineStyle = usePartialProps(_lineStyle, defaultLineStyle) @@ -157,8 +149,6 @@ const _D3HorizontalCursor = ({ setTooltipY(top) }, [sizes.chartsHeight, height, mouseState, fixed]) - const [lineY, setLineY] = useState(0) - useEffect(() => { if (fixed || !mouseState.visible) return setLineY(mouseState.y) diff --git a/src/components/d3/plugins/D3Legend.tsx b/src/components/d3/plugins/D3Legend.tsx index 715c9d7..71355e3 100644 --- a/src/components/d3/plugins/D3Legend.tsx +++ b/src/components/d3/plugins/D3Legend.tsx @@ -11,25 +11,33 @@ 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 } + /** Положение блока легенды на SVG */ position?: LegendPosition + /** Цвет текста в блоке легенды */ color?: Property.Color + /** Цвет фона блока легенды */ backgroundColor?: Property.Color + /** Тип позиционирования записей в блоке легенды */ type?: 'horizontal' | 'vertical' } const defaultOffset = { x: 10, y: 10 } export type D3LegendProps = D3LegendSettings & { + /** Массив графиков */ charts: ChartRegistry[] } -const _D3Legend = ({ +const _D3Legend = ({ charts, width, height, diff --git a/src/components/d3/plugins/D3Tooltip.tsx b/src/components/d3/plugins/D3Tooltip.tsx index 65bbfe8..9440e42 100644 --- a/src/components/d3/plugins/D3Tooltip.tsx +++ b/src/components/d3/plugins/D3Tooltip.tsx @@ -1,11 +1,11 @@ import { AreaChartOutlined, BarChartOutlined, BorderOuterOutlined, DotChartOutlined, LineChartOutlined } from '@ant-design/icons' -import { CSSProperties, ReactNode, useCallback, useEffect, useRef, useState } from 'react' +import { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react' import * as d3 from 'd3' import { isDev } from '@utils' -import { D3MouseState, useD3MouseZone } from '../D3MouseZone' -import { ChartRegistry } from '../types' +import { ChartRegistry } from '@components/d3/types' +import { D3MouseState, useD3MouseZone } from '@components/d3/D3MouseZone' import { getTouchedElements, wrapPlugin } from './base' import '@styles/d3.less' @@ -13,20 +13,29 @@ import '@styles/d3.less' export type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none' export type D3RenderData = { + /** Параметры графика */ chart: ChartRegistry + /** Данные графика */ data: DataType[] + /** D3 выборка элементов графика */ selection?: d3.Selection } export type D3RenderFunction = (data: D3RenderData[], mouseState: D3MouseState) => ReactNode export type D3TooltipSettings = { + /** Функция отрисоки тултипа */ render?: D3RenderFunction + /** Ширина тултипа */ width?: number | string + /** Высота тултипа */ height?: number | string + /** CSS-стиль тултипа */ style?: CSSProperties + /** Положение тултипа */ position?: D3TooltipPosition className?: string + /** Допуск к точке до срабатывания тултипа */ limit?: number } diff --git a/src/components/d3/renders/area.ts b/src/components/d3/renders/area.ts index 936e2f5..9de5819 100644 --- a/src/components/d3/renders/area.ts +++ b/src/components/d3/renders/area.ts @@ -11,7 +11,7 @@ export const renderArea = >( ): DataType[] => { if (chart.type !== 'area') return data - let area = d3.area() + let area = d3.area() if ('y0' in chart) { area = area.y0(yAxis(chart.y0 ?? 0)) diff --git a/src/components/d3/renders/line.ts b/src/components/d3/renders/line.ts index d2a2d13..abc9307 100644 --- a/src/components/d3/renders/line.ts +++ b/src/components/d3/renders/line.ts @@ -11,7 +11,7 @@ export const renderLine = >( ): DataType[] => { if (chart.type !== 'line') return data - let line = d3.line() + let line = d3.line() .x(d => xAxis(chart.x(d))) .y(d => yAxis(chart.y(d))) @@ -38,7 +38,7 @@ export const renderLine = >( chart().selectAll('path') .transition() .duration(chart.animDurationMs ?? 0) - .attr('d', line(data as any)) + .attr('d', line(data)) .attr('stroke-dasharray', String(chart.dash ?? '')) return data diff --git a/src/components/d3/renders/needle.ts b/src/components/d3/renders/needle.ts index 8af270e..b3944bb 100644 --- a/src/components/d3/renders/needle.ts +++ b/src/components/d3/renders/needle.ts @@ -20,13 +20,13 @@ export const renderNeedle = >( currentNeedles.enter().append('line') chart() - .selectAll('line') + .selectAll('line') .transition() .duration(chart.animDurationMs ?? 0) - .attr('x1', (d: any) => xAxis(chart.x(d))) - .attr('x2', (d: any) => xAxis(chart.x(d))) + .attr('x1', (d) => xAxis(chart.x(d))) + .attr('x2', (d) => xAxis(chart.x(d))) .attr('y1', height - offset.bottom - offset.top) - .attr('y2', (d: any) => yAxis(chart.y(d))) + .attr('y2', (d) => yAxis(chart.y(d))) .attr('stroke-dasharray', String(chart.dash ?? '')) return data diff --git a/src/components/d3/renders/rect_area.ts b/src/components/d3/renders/rect_area.ts index 8e25e94..762b86d 100644 --- a/src/components/d3/renders/rect_area.ts +++ b/src/components/d3/renders/rect_area.ts @@ -1,5 +1,5 @@ -import { getByAccessor } from '../functions' -import { ChartRegistry } from '../types' +import { getByAccessor } from '@components/d3/functions' +import { ChartRegistry } from '@components/d3/types' export const renderRectArea = >( xAxis: (value: d3.NumberValue) => number, diff --git a/src/components/d3/types.ts b/src/components/d3/types.ts index 647f9c3..228e107 100644 --- a/src/components/d3/types.ts +++ b/src/components/d3/types.ts @@ -1,70 +1,102 @@ import { ReactNode } from 'react' import { Property } from 'csstype' -import { - D3TooltipSettings -} from './plugins' +import { D3TooltipSettings } from './plugins' export type AxisAccessor> = keyof DataType | ((d: DataType) => any) export type ChartAxis = { + /** Тип шкалы */ type: 'linear' | 'time', + /** Ключ записи или метод по которому будет извлекаться значение оси из массива данных */ accessor: AxisAccessor + /** Единица измерения, отображаемая рядом со значением */ 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 + /** Параметры вертикальной оси */ yAxis: ChartAxis + /** Параметры горизонтальной оси */ xAxis: ChartAxis + + /** Название графика */ label?: ReactNode + /** Короткое название графика */ shortLabel?: ReactNode + /** Цвет графика */ color?: Property.Color + /** Прозрачность */ opacity?: number + /** Ширина */ width?: number | string tooltip?: D3TooltipSettings + /** Длительность анимаций для графики */ animDurationMs?: number + /** Парамаетры точек графика */ point?: Omit - afterDraw?: (d: any) => void + /** Событие, вызываемое после отрисовки графика */ + afterDraw?: (d: ChartRegistry) => void + /** Параметры штриховки графика */ dash?: string | number | [string | number, string | number] -} - -export type AreaChartDataset = { - type: 'area' - x0?: number - y0?: number - areaColor?: Property.Color - nullValues?: 'skip' | 'gap' | 'none' - optimization?: boolean -} - -export type RectArea> = { - type: 'rect_area' - minXAccessor?: AxisAccessor - maxXAccessor?: AxisAccessor - minYAccessor?: AxisAccessor - maxYAccessor?: AxisAccessor - data?: DataType[] + /** Привязка домена к домену другого графика */ + bindDomainFrom?: string | number } export type LineChartDataset = { type: 'line' + /** Обработка NULL значений */ nullValues?: 'skip' | 'gap' | 'none' + /** Оптимизация одинаковых точек */ optimization?: boolean } +export type AreaChartDataset = Omit & { + type: 'area' + /** Константная граница по горизонтальной оси (если не задана не отображается) */ + x0?: number + /** Константная граница по вертикальной оси (если не задана не отображается) */ + y0?: number + /** Цвет заполнения */ + areaColor?: Property.Color +} + +export type RectArea> = { + type: 'rect_area' + /** Акцессор минимального значения по горизонтальной оси */ + minXAccessor?: AxisAccessor + /** Акцессор максимального значения по горизонтальной оси */ + maxXAccessor?: AxisAccessor + /** Акцессор минимального значения по вертикальной оси */ + minYAccessor?: AxisAccessor + /** Акцессор максимального значения по вертикальной оси */ + maxYAccessor?: AxisAccessor + /** Дополнительные данные */ + data?: DataType[] +} + export type NeedleChartDataset = { type: 'needle' } @@ -77,35 +109,56 @@ export type ChartDataset = BaseChartDataset & ( RectArea> ) -export type MinMax = { min?: number, max?: number } +export type MinMax = { + /** Минимальное значение */ + min?: number + /** Максимальное значение */ + max?: number +} export type ChartDomain = { + /** Отображаемый диапозон по горизонтальной оси */ x?: MinMax + /** Отображаемый диапозон по вертикальной оси */ y?: MinMax } export type ChartOffset = { + /** Отступ сверху */ top: number + /** Отступ снизу */ bottom: number + /** Отступ слева */ left: number + /** Отступ справа */ right: number } export type ChartTick = { + /** Отображать ли риски на шкале */ visible?: boolean, + /** Колличество рисок */ count?: number | d3.TimeInterval, + /** Формат значений на рисках */ format?: (d: d3.NumberValue, idx: number, data?: DataType) => string, + /** Цвет шкалы */ color?: Property.Color } export type ChartTicks = { + /** Цвет шкал графика */ color?: Property.Color + /** Параметры шкалы горизонтальной оси */ x?: ChartTick + /** Параметры шкалы вертикальной оси */ y?: ChartTick } export type ChartRegistry = ChartDataset & { + /** Получить D3 выборку, содержащую корневой G-элемент графика */ (): d3.Selection - y: (value: any) => number - x: (value: any) => number + /** Получить значение по вертикальной оси из предоставленой записи */ + y: (value: DataType) => number + /** Получить значение по горизонтальной оси из предоставленой записи */ + x: (value: DataType) => number } diff --git a/src/pages/Telemetry/Archive/index.jsx b/src/pages/Telemetry/Archive/index.jsx index 10cc15d..4f0d2d3 100755 --- a/src/pages/Telemetry/Archive/index.jsx +++ b/src/pages/Telemetry/Archive/index.jsx @@ -14,7 +14,7 @@ import { PeriodPicker, defaultPeriod } from '@components/selectors/PeriodPicker' import { formatDate, range, wrapPrivateComponent } from '@utils' import { TelemetryDataSaubService } from '@api' -import { chartGroups, normalizeData } from '../TelemetryView' +import { makeChartGroups, normalizeData } from '../TelemetryView' import cursorRender from '../TelemetryView/cursorRender' const DATA_COUNT = 2048 // Колличество точек на подгрузку графика @@ -101,6 +101,8 @@ export const cutData = (data, beginDate, endDate) => { return data } +const chartGroups = makeChartGroups([]) + const Archive = memo(() => { const [dataSaub, setDataSaub] = useState([]) const [dateLimit, setDateLimit] = useState({ from: 0, to: new Date() }) diff --git a/src/pages/Telemetry/Messages.jsx b/src/pages/Telemetry/Messages.jsx index 55e96ef..ee024d9 100755 --- a/src/pages/Telemetry/Messages.jsx +++ b/src/pages/Telemetry/Messages.jsx @@ -1,5 +1,6 @@ -import { Select, DatePicker, Input, Tooltip } from 'antd' import { useState, useEffect, memo, useCallback, useMemo } from 'react' +import { Select, DatePicker, Input, Tooltip } from 'antd' +import { LinkOutlined } from '@ant-design/icons' import { Link } from 'react-router-dom' import moment from 'moment' @@ -10,7 +11,6 @@ import { makeColumn, makeDateColumn, makeNumericColumn, makeNumericSorter, makeT import { wrapPrivateComponent } from '@utils' import { MessageService } from '@api' -import {LinkOutlined} from '@ant-design/icons' import '@styles/message.css' const pageSize = 26 diff --git a/src/pages/Telemetry/TelemetryView/index.jsx b/src/pages/Telemetry/TelemetryView/index.jsx index 9739a77..67967f5 100755 --- a/src/pages/Telemetry/TelemetryView/index.jsx +++ b/src/pages/Telemetry/TelemetryView/index.jsx @@ -58,37 +58,59 @@ const makeDataset = (label, shortLabel, color, key, unit, other) => ({ ...other, }) -export const chartGroups = [ - [ - makeDataset("Высота блока", "Высота ТБ","#303030", "blockPosition", "м"), - makeDataset("Глубина скважины", "Глубина скв","#7789A1", "wellDepth", "м", { dash }), - makeDataset("Расход", "Расход","#007070", "flow", "л/с"), - makeDataset("Положение долота", "Долото","#B39D59", "bitPosition", "м"), - ], [ - makeDataset("Скорость блока", "Скорость ТБ","#59B359", "blockSpeed", "м/ч"), - makeDataset("Скорость заданная", "Скор зад-я","#95B359", "blockSpeedSp", "м/ч", { dash }), - ], [ - makeDataset("Давление", "Давл","#FF0000", "pressure", "атм"), - makeDataset("Давление заданное", "Давл зад-е","#CC0000", "pressureSp", "атм"), - makeDataset("Давление ХХ", "Давл ХХ","#CC4429", "pressureIdle", "атм", { dash }), - makeDataset("Перепад давления максимальный", "ΔР макс","#B34A36", "pressureDeltaLimitMax", "атм", { dash }), - ], [ - makeDataset("Осевая нагрузка", "Нагр","#0000CC", "axialLoad", "т"), - makeDataset("Осевая нагрузка заданная", "Нагр зад-я","#3D6DCC", "axialLoadSp", "т", { dash }), - makeDataset("Осевая нагрузка максимальная", "Нагр макс","#3D3DCC", "axialLoadLimitMax", "т", { dash }), - ], [ - makeDataset("Вес на крюке", "Вес на крюке","#00B3B3", "hookWeight", "т"), - makeDataset("Вес инструмента ХХ", "Вес инст ХХ","#29CCB1", "hookWeightIdle", "т", { dash }), - makeDataset("Вес инструмента минимальный", "Вес инст мин","#47A1B3", "hookWeightLimitMin", "т", { dash }), - makeDataset("Вес инструмента максимальный", "Вес инст мах","#2D7280", "hookWeightLimitMax", "т", { dash }), - makeDataset("Обороты ротора", "Об ротора","#11B32F", "rotorSpeed", "об/мин"), - ], [ - makeDataset("Момент на роторе", "Момент","#990099", "rotorTorque", "кН·м"), - makeDataset("План. Момент на роторе", "Момент зад-й","#9629CC", "rotorTorqueSp", "кН·м", { dash }), - makeDataset("Момент на роторе х.х.", "Момень ХХ","#CC2996", "rotorTorqueIdle", "кН·м", { dash }), - makeDataset("Момент максимальный", "Момент макс","#FF00FF", "rotorTorqueLimitMax", "кН·м", { dash }), +export const makeChartGroups = (flowChart) => { + const makeAreaOptions = (accessor) => ({ + type: 'rect_area', + data: flowChart, + hideLabel: true, + yAxis: { + type: 'linear', + accessor: 'depth', + }, + minXAccessor: 'depthStart', + maxXAccessor: 'depthEnd', + minYAccessor: accessor + 'Min', + maxYAccessor: accessor + 'Max', + bindDomainFrom: accessor, + }) + console.log(flowChart) + return [ + [ + makeDataset('Высота блока', 'Высота ТБ','#303030', 'blockPosition', 'м'), + makeDataset('Глубина скважины', 'Глубина скв','#7789A1', 'wellDepth', 'м', { dash }), + makeDataset('Расход', 'Расход','#007070', 'flow', 'л/с'), + makeDataset('Положение долота', 'Долото','#B39D59', 'bitPosition', 'м'), + makeDataset('Расход', 'Расход','#007070', 'flowMM', 'л/с', makeAreaOptions('flow')), + ], [ + makeDataset('Скорость блока', 'Скорость ТБ','#59B359', 'blockSpeed', 'м/ч'), + makeDataset('Скорость заданная', 'Скор зад-я','#95B359', 'blockSpeedSp', 'м/ч', { dash }), + ], [ + makeDataset('Давление', 'Давл','#FF0000', 'pressure', 'атм'), + makeDataset('Давление заданное', 'Давл зад-е','#CC0000', 'pressureSp', 'атм'), + makeDataset('Давление ХХ', 'Давл ХХ','#CC4429', 'pressureIdle', 'атм', { dash }), + makeDataset('Перепад давления максимальный', 'ΔР макс','#B34A36', 'pressureDeltaLimitMax', 'атм', { dash }), + makeDataset('Давление', 'Давл','#FF0000', 'pressureMM', 'атм', makeAreaOptions('pressure')), + ], [ + makeDataset('Осевая нагрузка', 'Нагр','#0000CC', 'axialLoad', 'т'), + makeDataset('Осевая нагрузка заданная', 'Нагр зад-я','#3D6DCC', 'axialLoadSp', 'т', { dash }), + makeDataset('Осевая нагрузка максимальная', 'Нагр макс','#3D3DCC', 'axialLoadLimitMax', 'т', { dash }), + makeDataset('Осевая нагрузка', 'Нагр','#0000CC', 'axialLoadMM', 'т', makeAreaOptions('axialLoad')), + ], [ + makeDataset('Вес на крюке', 'Вес на крюке','#00B3B3', 'hookWeight', 'т'), + makeDataset('Вес инструмента ХХ', 'Вес инст ХХ','#29CCB1', 'hookWeightIdle', 'т', { dash }), + makeDataset('Вес инструмента минимальный', 'Вес инст мин','#47A1B3', 'hookWeightLimitMin', 'т', { dash }), + makeDataset('Вес инструмента максимальный', 'Вес инст мах','#2D7280', 'hookWeightLimitMax', 'т', { dash }), + makeDataset('Обороты ротора', 'Об ротора','#11B32F', 'rotorSpeed', 'об/мин'), + makeDataset('Обороты ротора', 'Об ротора','#11B32F', 'rotorSpeedMM', 'об/мин', makeAreaOptions('rotorSpeed')), + ], [ + makeDataset('Момент на роторе', 'Момент','#990099', 'rotorTorque', 'кН·м'), + makeDataset('План. Момент на роторе', 'Момент зад-й','#9629CC', 'rotorTorqueSp', 'кН·м', { dash }), + makeDataset('Момент на роторе х.х.', 'Момень ХХ','#CC2996', 'rotorTorqueIdle', 'кН·м', { dash }), + makeDataset('Момент максимальный', 'Момент макс','#FF00FF', 'rotorTorqueLimitMax', 'кН·м', { dash }), + makeDataset('Момент на роторе', 'Момент','#990099', 'rotorTorqueMM', 'кН·м', makeAreaOptions('rotorTorque')), + ] ] -] +} const getLast = (data) => Array.isArray(data) ? data.at(-1) : data @@ -214,6 +236,8 @@ const TelemetryView = memo(() => { return dataSaub.slice(i, j) }, [dataSaub, domain]) + const chartGroups = useMemo(() => makeChartGroups(flowChartData), [flowChartData]) + return ( diff --git a/src/utils/functions/chart.tsx b/src/utils/functions/chart.tsx index 2c1e790..6b0ebf2 100644 --- a/src/utils/functions/chart.tsx +++ b/src/utils/functions/chart.tsx @@ -23,7 +23,7 @@ export const getDistance = (x1: number, y1: number, x2: number, y2: number, type return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)) } -export const getChartIcon = (chart: ChartDataset, options: Omit) => { +export const getChartIcon = (chart: ChartDataset, options?: Omit) => { let Icon switch (chart.type) { case 'needle': Icon = BarChartOutlined; break