diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx new file mode 100644 index 0000000..8e4cbef --- /dev/null +++ b/src/components/ColorPicker.tsx @@ -0,0 +1,129 @@ +import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { Input, Popover, Slider } from 'antd' +import { CopyOutlined } from '@ant-design/icons' + +import { copyToClipboard } from './factory' + +import '@styles/components/color_picker.less' + +export class Color { + public r: number + public g: number + public b: number + public a: number = 1 + + public constructor(color: Color | string) + public constructor(r: number, g: number, b: number, a?: number) + + constructor(...args: any[]) { + let out + if (args[0] instanceof Color) { + out = args[0] + } else if (typeof args[0] === 'string') { + out = Color.parseToObject(args[0]) + } else if (typeof args[0] === 'number') { + out = { r: args[0], g: args[1], b: args[2], a: args[3] ?? 1 } + } else throw new Error('Некорректные аргументы') + this.r = out.r + this.g = out.g + this.b = out.b + this.a = out.a + } + + public static parse(str: string): Color { + const out = Color.parseToObject(str) + return new Color(out.r, out.g, out.b, out.a) + } + + private static parseToObject(str: string) { + let rgb: number[] = [] + let a: number = 1 + if (str.startsWith('rgb')) { + const parts = str.replaceAll(/\s/g, '').match(/rgba?\((\d+),(\d+),(\d)+(?:,([\d.]+))?\)/) + if (parts) { + rgb = parts.slice(1, 4).map((v) => Math.min(0, Math.max(parseInt(v), 255))) + if (parts[4]) a = parseFloat(`0${parts[4]}`) + } + } else if (str.startsWith('#')) { + const parts = str.slice(1) + let rgba: string[] | null = parts.length > 5 ? parts.match(/.{1,2}/g) : [...parts] + if (rgba) { + rgb = rgba.slice(0, 3).map((v) => parseInt(v, 16)) + if (rgba[3]) a = parseInt(rgba[3], 16) / 255 + } + } + if (rgb.length < 3) + throw new Error('Некорректная строка') + return { r: rgb[0], g: rgb[1], b: rgb[2], a } + } + + public toString = () => this.toHexString() + public toCssString = () => `rgba(${this.r},${this.g},${this.b},${this.a})` + public toHexString() { + const a = Math.floor(this.a * 255) + let out = '#' + [this.r, this.g, this.b].map((v) => v.toString(16).padStart(2, '0')).join('') + if (a < 255) out += a.toString(16).padStart(2, '0') + return out + } +} + +export type ColorPickerProps = { + value?: string | Color + onChange?: (value: Color) => void + size?: number | string + id?: string +} + +const makeChangeColor = (set: React.Dispatch>, accessor: 'r' | 'g' | 'b' | 'a') => (value: number) => set((prev: Color) => { + const out = new Color(prev) + out[accessor] = value + return out +}) + +export const ColorPicker = memo(({ value = '#AA33BB', onChange, size, ...other }) => { + const [color, setColor] = useState(new Color(255, 255, 255)) + + useEffect(() => setColor(new Color(value)), [value]) + + const divStyle = useMemo(() => ({ + width: size, + height: size, + backgroundColor: color.toCssString(), + }), [size, color]) + + const changeR = useMemo(() => makeChangeColor(setColor, 'r'), []) + const changeG = useMemo(() => makeChangeColor(setColor, 'g'), []) + const changeB = useMemo(() => makeChangeColor(setColor, 'b'), []) + const changeA = useMemo(() => makeChangeColor(setColor, 'a'), []) + + const onClose = useCallback((visible: boolean) => { + if (!visible) + onChange?.(color) + }, [color, onChange]) + + const onCopyClick = useCallback(() => copyToClipboard(color.toHexString()), [color]) + + return ( + +
+ + + + +
+ + )} /> + + )} + > +
+ + ) +}) + +export default ColorPicker diff --git a/src/components/d3/D3MonitoringChartEditor.tsx b/src/components/d3/D3MonitoringChartEditor.tsx new file mode 100644 index 0000000..462e9ff --- /dev/null +++ b/src/components/d3/D3MonitoringChartEditor.tsx @@ -0,0 +1,108 @@ +import { Button, Form, FormItemProps, Input, InputNumber, Select, Tooltip } from 'antd' +import { memo, useCallback, useEffect, useMemo } from 'react' + +import { ColorPicker, Color } from '../ColorPicker' +import { ExtendedChartDataset } from './D3MonitoringCharts' +import { MinMax } from './types' + +const { Item: RawItem } = Form + +const Item = ({ style, ...other }: FormItemProps) => style={{ margin: 0, marginBottom: 5, ...style }} {...other} /> + +const lineTypes = [ + { value: 'line', label: 'Линия' }, + { value: 'rect_area', label: 'Прямоугольная зона' }, + { value: 'point', label: 'Точки' }, + { value: 'area', label: 'Зона' }, + { value: 'needle', label: 'Иглы' }, +] + +export type D3MonitoringChartEditorProps = { + group: ExtendedChartDataset[] + chart: ExtendedChartDataset + onChange: (value: ExtendedChartDataset) => boolean +} + +const _D3MonitoringChartEditor = ({ + group, + chart: value, + onChange, +}: D3MonitoringChartEditorProps) => { + const [form] = Form.useForm() + + const onSave = useCallback((props: Partial>) => { + const values = form.getFieldsValue() + if (!values['label']) return + const newValue = { + ...value, + ...values, + ...props + } + onChange(newValue) + }, [value]) + + const onDomainChange = useCallback((mm: MinMax) => { + onSave({ xDomain: { + min: ('min' in mm) ? mm.min : value.xDomain?.min, + max: ('max' in mm) ? mm.max : value.xDomain?.max, + }}) + }, [value]) + + const onColorChange = useCallback((color: Color) => { + onSave({ color: color.toHexString() }) + }, [value]) + + useEffect(() => { + if (value.type) + form.setFieldsValue({ + linkedTo: null, + label: null, + shortLabel: null, + ...value, + }) + else + form.resetFields() + }, [value, form]) + + const options = useMemo(() => group.filter((chart) => chart.key !== value.key).map((chart) => ({ + value: chart.key, + label: chart.label, + })), [group, value]) + + return ( +
+ + onSave({ linkedTo: value })} + /> + + + + onSave({ label: e.target.value })} /> + onSave({ shortLabel: e.target.value })} /> + + + + + onDomainChange({ min })} placeholder={'Мин'} /> + onDomainChange({ max })} placeholder={'Макс'} /> + + + + + + ) +} + +export const D3MonitoringChartEditor = memo(_D3MonitoringChartEditor) as typeof _D3MonitoringChartEditor + +export default D3MonitoringChartEditor diff --git a/src/components/d3/D3MonitoringCharts.tsx b/src/components/d3/D3MonitoringCharts.tsx index 2ff5abf..86ee9c0 100644 --- a/src/components/d3/D3MonitoringCharts.tsx +++ b/src/components/d3/D3MonitoringCharts.tsx @@ -5,7 +5,9 @@ import { Empty } from 'antd' import * as d3 from 'd3' import LoaderPortal from '@components/LoaderPortal' +import { invokeWebApiWrapperAsync } from '@components/factory' import { isDev, usePartialProps } from '@utils' +import { UserSettingsService } from '@api' import { ChartAxis, @@ -23,19 +25,25 @@ import { D3HorizontalCursorSettings } from './plugins' import D3MouseZone from './D3MouseZone' +import D3MonitoringGroupsEditor from './D3MonitoringGroupsEditor' import { getByAccessor, getChartClass, getGroupClass, getTicks } from './functions' import { renderArea, renderLine, renderNeedle, renderPoint, renderRectArea } from './renders' const roundTo = (v: number, to: number = 50) => { - if (to == 0) return v - if (v < 0) return Math.round(v / to) * to - return Math.ceil(v / to) * to + if (v === 0) return v + return (v > 0 ? Math.ceil : Math.round)(v / to) * to } -const calculateDomain = (mm: MinMax, round: number = 100): Required => { +const calculateDomain = (mm: MinMax): Required => { + let round = Math.abs((mm.max ?? 0) - (mm.min ?? 0)) + if (round < 10) round = 10 + else if (round < 100) round = roundTo(round, 10) + else if (round < 1000) round = roundTo(round, 100) + else if (round < 10000) round = roundTo(round, 1000) + else round = 0 let min = roundTo(mm.min ?? 0, round) let max = roundTo(mm.max ?? round, round) - if (min - max < round) { + if (round && Math.abs(min - max) < round) { const mid = (min + max) / 2 min = mid - round max = mid + round @@ -43,14 +51,14 @@ const calculateDomain = (mm: MinMax, round: number = 100): Required => { return { min, max } } -type ExtendedChartDataset = ChartDataset & { +export type ExtendedChartDataset = ChartDataset & { /** Диапазон отображаемых значений по горизонтальной оси */ xDomain: MinMax /** Скрыть отображение шкалы графика */ hideLabel?: boolean } -type ExtendedChartRegistry = ChartRegistry & ExtendedChartDataset +export type ExtendedChartRegistry = ChartRegistry & ExtendedChartDataset export type ChartGroup = { /** Получить D3 выборку, содержащую корневой G-элемент группы */ @@ -83,7 +91,7 @@ const getDefaultYTicks = (): Required> => ({ /** * @template DataType тип данных отображаемых записей */ -export type D3MonitoringChartsProps> = React.DetailedHTMLProps, HTMLDivElement> & { +export type D3MonitoringChartsProps> = Omit, HTMLDivElement>, 'ref'> & { /** Двумерный массив датасетов (группа-график) */ datasetGroups: ExtendedChartDataset[][] /** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */ @@ -121,6 +129,11 @@ export type D3MonitoringChartsProps> = Reac axisHeight?: number /** Отступ между группами графиков в пикселях (30 по умолчанию) */ spaceBetweenGroups?: number + /** Название графика для сохранения в базе */ + chartName?: string + methods?: (value: { + setSettingsVisible: (visible: boolean) => void + }) => void } export type ChartSizes = ChartOffset & { @@ -165,16 +178,20 @@ const _D3MonitoringCharts = >({ axisHeight = 20, spaceBetweenGroups = 30, dash, + chartName, + methods, className = '', ...other }: D3MonitoringChartsProps) => { + const [datasets, setDatasets] = useState[][]>([]) const [groups, setGroups] = useState[]>([]) const [svgRef, setSvgRef] = useState(null) const [yAxisRef, setYAxisRef] = useState(null) const [chartAreaRef, setChartAreaRef] = useState(null) const [axesAreaRef, setAxesAreaRef] = useState(null) - + const [settingsVisible, setSettingsVisible] = useState(false) + const offset = usePartialProps(_offset, defaultOffsets) const yTicks = usePartialProps>>(_yTicks, getDefaultYTicks) const yAxisConfig = usePartialProps>(_yAxisConfig, getDefaultYAxisConfig) @@ -206,7 +223,7 @@ const _D3MonitoringCharts = >({ groupLeft: (i: number) => (groupWidth + spaceBetweenGroups) * i, axisTop: (i: number, count: number) => axisHeight * (maxChartCount - count + i + 1) }) - }, [groups, height, offset]) + }, [groups, width, height, offset, axisHeight, spaceBetweenGroups]) const yAxis = useMemo(() => { if (!data) return @@ -218,23 +235,15 @@ const _D3MonitoringCharts = >({ return yAxis }, [groups, data, yDomain, sizes.chartsHeight]) - const createAxesGroup = useCallback((i: number): ChartGroup => Object.assign( - () => chartArea().select('.' + getGroupClass(i)) as d3.Selection, - { - key: i, - charts: [], - } - ), [chartArea, axesArea]) - 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) { + if (!Number.isNaN((mm.min ?? NaN) + (mm.max ?? NaN))) { domain = mm as Required } else if (data) { const [min, max] = d3.extent(data, chart.x) - domain = calculateDomain({ min, max, ...mm }, 100) + domain = calculateDomain({ min, max, ...mm }) } return [chart.key, { scale: d3.scaleLinear().domain([domain.min, domain.max]), @@ -244,7 +253,7 @@ const _D3MonitoringCharts = >({ out.forEach(([key], i) => { const chart = group.charts.find((chart) => chart.key === key) - const bind = chart?.bindDomainFrom + const bind = chart?.linkedTo if (!bind) return const bindDomain = out.find(([key]) => key === bind) if (bindDomain) @@ -254,14 +263,73 @@ const _D3MonitoringCharts = >({ return Object.fromEntries(out) }), [groups, data]) + const createAxesGroup = useCallback((i: number): ChartGroup => Object.assign( + () => chartArea().select('.' + getGroupClass(i)) as d3.Selection, + { + key: i, + charts: [], + } + ), [chartArea]) + + const onGroupsChange = useCallback((sets: ExtendedChartDataset[][]) => { + if (chartName) { + invokeWebApiWrapperAsync( + async () => { + await UserSettingsService.update(chartName, sets) + }, + undefined, + 'Не удалось сохранить параметры графиков' + ) + } + setDatasets(sets) + setSettingsVisible(false) + }, [chartName]) + + const onGroupsReset = useCallback(() => { + setSettingsVisible(false) + setDatasets(datasetGroups) + if (chartName) { + invokeWebApiWrapperAsync( + async () => await UserSettingsService.delete(chartName), + undefined, + 'Не удалось удалить настройки графиков' + ) + } + }, [datasetGroups, chartName]) + + useEffect(() => methods?.({ setSettingsVisible }), [methods]) + + useEffect(() => { + invokeWebApiWrapperAsync( + async () => { + let sets = chartName ? await UserSettingsService.get(chartName) : null + if (typeof sets === 'string') + sets = JSON.parse(sets) + if (Array.isArray(sets)) { + setDatasets(sets) + } else if (Array.isArray(datasetGroups)) { + setDatasets(datasetGroups) + if (chartName) { + invokeWebApiWrapperAsync( + async () => await UserSettingsService.insert(chartName, datasetGroups), + undefined, + 'Не удалось сохранить настройки графиков' + ) + } + } + }, + undefined, + 'Не удалось загрузить настройки графиков' + ) + }, [datasetGroups, chartName]) + useEffect(() => { if (isDev()) { - datasetGroups.forEach((sets, i) => { + datasets.forEach((sets, i) => { sets.forEach((set, j) => { - for (let k = j + 1; k < sets.length; k++) { + for (let k = j + 1; k < sets.length; k++) if (set.key === sets[k].key) console.warn(`Ключ датасета "${set.key}" неуникален (группа ${i}, индексы ${j} и ${k})!`) - } }) }) } @@ -269,15 +337,15 @@ const _D3MonitoringCharts = >({ setGroups((oldGroups) => { const groups: ChartGroup[] = [] - if (datasetGroups.length < oldGroups.length) { + if (datasets.length < oldGroups.length) { // Удаляем неактуальные группы - oldGroups.slice(datasetGroups.length).forEach((group) => group().remove()) - groups.push(...oldGroups.slice(0, datasetGroups.length)) + oldGroups.slice(datasets.length).forEach((group) => group().remove()) + groups.push(...oldGroups.slice(0, datasets.length)) } else { groups.push(...oldGroups) } - datasetGroups.forEach((datasets, i) => { + datasets.forEach((datasets, i) => { let group: ChartGroup = createAxesGroup(i) if (group().empty()) @@ -305,7 +373,7 @@ const _D3MonitoringCharts = >({ animDurationMs, ...dataset, yAxis: dataset.yAxis ?? yAxisConfig, - y: getByAccessor(dataset.yAxis.accessor ?? yAxisConfig.accessor), + y: getByAccessor(dataset.yAxis?.accessor ?? yAxisConfig.accessor), x: getByAccessor(dataset.xAxis?.accessor), } ) @@ -326,10 +394,10 @@ const _D3MonitoringCharts = >({ return groups }) - }, [yAxisConfig, chartArea, datasetGroups, animDurationMs, createAxesGroup]) + }, [yAxisConfig, chartArea, datasets, animDurationMs, createAxesGroup]) useEffect(() => { - const axesGroups = d3.select(axesAreaRef) + const axesGroups = axesArea() .selectAll('.charts-group') .data(groups) @@ -338,7 +406,7 @@ const _D3MonitoringCharts = >({ .append('g') .attr('class', 'charts-group') - const actualAxesGroups = d3.select(axesAreaRef) + const actualAxesGroups = axesArea() .selectAll>('.charts-group') .attr('class', (g) => `charts-group ${getGroupClass(g.key)}`) .attr('transform', (g) => `translate(${sizes.groupLeft(g.key)}, 0)`) @@ -391,7 +459,7 @@ const _D3MonitoringCharts = >({ .attr('stroke', j === chartsData.length - 1 ? 'gray' : 'currentColor') }) }) - }, [groups, sizes, spaceBetweenGroups, chartDomains]) + }, [groups, sizes, spaceBetweenGroups, chartDomains, axesArea]) useEffect(() => { // Рисуем ось Y if (!yAxis) return @@ -479,7 +547,11 @@ const _D3MonitoringCharts = >({ className={`asb-d3-chart ${className}`} > {data ? ( - + setSettingsVisible(true)} + {...plugins?.menu} + svg={svgRef} + > @@ -516,6 +588,13 @@ const _D3MonitoringCharts = >({
)} + setSettingsVisible(false)} + onReset={onGroupsReset} + /> ) diff --git a/src/components/d3/D3MonitoringGroupsEditor.tsx b/src/components/d3/D3MonitoringGroupsEditor.tsx new file mode 100644 index 0000000..25f9f13 --- /dev/null +++ b/src/components/d3/D3MonitoringGroupsEditor.tsx @@ -0,0 +1,170 @@ +import { CSSProperties, Key, memo, useCallback, useEffect, useMemo, useState } from 'react' +import { Button, Divider, Empty, Modal, Popconfirm, Tooltip, Tree } from 'antd' +import { UndoOutlined } from '@ant-design/icons' +import { EventDataNode } from 'antd/lib/tree' + +import { getChartIcon } from '@utils' + +import { ExtendedChartDataset } from './D3MonitoringCharts' +import D3MonitoringChartEditor from './D3MonitoringChartEditor' +import { notify } from '../factory' + +export type D3MonitoringGroupsEditorProps = { + visible?: boolean + groups: ExtendedChartDataset[][] + onChange: (value: ExtendedChartDataset[][]) => void + onCancel: () => void + onReset: () => void +} + +const getChartLabel = (chart: ExtendedChartDataset) => ( + + {getChartIcon(chart)} {chart.label} + +) + +const divStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + alignItems: 'center', + flexGrow: 1, +} + +const getNodePos = (node: EventDataNode): { group: number, chart?: number } => { + const out = node.pos.split('-').map(Number) + return { group: out[1], chart: out[2] } +} + +const _D3MonitoringGroupsEditor = ({ + visible, + groups: oldGroups, + onChange, + onCancel, + onReset, +}: D3MonitoringGroupsEditorProps) => { + const [groups, setGroups] = useState[][]>([]) + const [expand, setExpand] = useState([]) + const [selected, setSelected] = useState([]) + + useEffect(() => setGroups(oldGroups), [oldGroups]) + + const onModalOk = useCallback(() => onChange(groups), [groups]) + + const onDrop = useCallback((info: { + node: EventDataNode + dragNode: EventDataNode + dropPosition: number + }) => { + const { dragNode, dropPosition, node } = info + + const targetNodes = getNodePos(node) + const dragNodes = getNodePos(dragNode) + const groupPos = dragNodes.group + if (!Number.isFinite(groupPos + dropPosition)) return + setGroups((prev) => { + const chartPos = dragNodes.chart + if (typeof chartPos === 'undefined') { + const groups = [ ...prev ] + const movedGroups = groups.splice(groupPos, 1) + groups.splice(Math.max(dropPosition - 1, 0), 0, ...movedGroups) + return groups + } else if (Number.isFinite(chartPos)) { + const targetGroup = targetNodes.group + const dragKey = prev[groupPos][chartPos].key + if (groupPos !== targetGroup) { + if (prev[targetGroup].find((chart) => chart.key === dragKey)) { + notify('График с данным ключом уже существует в этой группе. Перемещение невозможно', 'warning') + return prev + } + } + const groups = [ ...prev ] + const charts = groups[groupPos].filter((chart) => chart.key === dragKey || chart.linkedTo === dragKey) + groups[groupPos] = groups[groupPos].filter((chart) => chart.key !== dragKey && chart.linkedTo !== dragKey) + groups[targetGroup].splice(Math.max(dropPosition - 1, 0), 0, ...charts) + return groups + } + return prev + }) + }, []) + + const treeItems = useMemo(() => groups.map((group, i) => ({ + key: `0-${i}`, + title: `Группа #${i} (${group.length})`, + selectable: false, + children: group.map((chart, j) => ({ + key: `0-${i}-${j}`, + title: getChartLabel(chart), + selectable: true, + })) + })), [groups]) + + const selectedIdx = useMemo(() => { + if (!selected) return null + const parts = String(selected[0]).split('-') + const group = Number(parts[1]) + const chart = Number(parts[2]) + if (!Number.isFinite(group + chart)) return null + return { group, chart } + }, [selected]) + + const selectedGroup = useMemo(() => selectedIdx ? groups[selectedIdx.group] : null, [groups, selectedIdx]) + const selectedChart = useMemo(() => selectedIdx ? groups[selectedIdx.group][selectedIdx.chart] : null, [groups, selectedIdx]) + + const onChartChange = useCallback((chart: ExtendedChartDataset) => { + if (!selectedIdx) return false + setGroups((prev) => { + const groups = [ ...prev ] + groups[selectedIdx.group][selectedIdx.chart] = chart + return groups + }) + return true + }, [selectedIdx]) + + return ( + + + + + + + + )} + > +
+
+ setExpand(keys)} + expandedKeys={expand} + selectedKeys={selected} + treeData={treeItems} + onDrop={onDrop} + onSelect={setSelected} + height={250} + /> +
+ +
+ {selectedGroup && selectedChart ? ( + group={selectedGroup} chart={selectedChart} onChange={onChartChange} /> + ) : ( + + )} +
+
+
+ ) +} + +export const D3MonitoringGroupsEditor = memo(_D3MonitoringGroupsEditor) as typeof _D3MonitoringGroupsEditor + +export default D3MonitoringGroupsEditor diff --git a/src/components/d3/plugins/D3ContextMenu.tsx b/src/components/d3/plugins/D3ContextMenu.tsx index 9c78d0d..3bbc553 100644 --- a/src/components/d3/plugins/D3ContextMenu.tsx +++ b/src/components/d3/plugins/D3ContextMenu.tsx @@ -8,7 +8,7 @@ import { BasePluginSettings } from './base' export type D3ContextMenuSettings = { /** Метод или объект отрисовки пунктов выпадающего меню */ - overlay?: FunctionalValue<(svg: SVGSVGElement | null) => ReactElement | null> + overlay?: FunctionalValue<(svg: SVGSVGElement | null, onUpdate?: () => void, onSettingsOpen?: () => void) => ReactElement | null> /** Название графика для загрузки */ downloadFilename?: string /** Событие, вызываемое при нажатий кнопки "Обновить" */ @@ -23,6 +23,8 @@ export type D3ContextMenuProps = BasePluginSettings & D3ContextMenuSettings & { children: any /** SVG-элемент */ svg: SVGSVGElement | null + /** Событие, вызываемое при нажатий кнопки "Настройки" */ + onSettingsOpen?: () => void } export const D3ContextMenu = memo(({ @@ -30,6 +32,7 @@ export const D3ContextMenu = memo(({ downloadFilename = 'chart', additionalMenuItems, onUpdate, + onSettingsOpen, trigger = ['contextMenu'], enabled = true, children, @@ -43,6 +46,9 @@ export const D3ContextMenu = memo(({ if (onUpdate) menuItems.push({ key: 'refresh', label: 'Обновить', onClick: onUpdate }) + if (onSettingsOpen) + menuItems.push({ key: 'settings', label: 'Настройки', onClick: onSettingsOpen }) + if (svg) menuItems.push({ key: 'download', label: ( Сохранить @@ -52,11 +58,11 @@ export const D3ContextMenu = memo(({ menuItems.push(...additionalMenuItems) return menuItems - }, [svg, downloadFilename, onUpdate, additionalMenuItems]) + }, [svg, downloadFilename, onUpdate, onSettingsOpen, additionalMenuItems]) return ( )} + overlay={overlay(svg, onUpdate, onSettingsOpen) || ( )} disabled={!enabled} trigger={trigger} > diff --git a/src/components/d3/plugins/D3HorizontalCursor.tsx b/src/components/d3/plugins/D3HorizontalCursor.tsx index 2a3f212..3b99157 100644 --- a/src/components/d3/plugins/D3HorizontalCursor.tsx +++ b/src/components/d3/plugins/D3HorizontalCursor.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, ReactNode, SVGProps, useEffect, useMemo, useRef, useState } from 'react' +import { CSSProperties, ReactNode, SVGProps, useCallback, useEffect, useMemo, useRef, useState } from 'react' import * as d3 from 'd3' import { useD3MouseZone } from '@components/d3/D3MouseZone' @@ -104,7 +104,7 @@ const _D3HorizontalCursor = ({ if (!unsubscribe() && isDev()) console.warn('Не удалось отвязать эвент') } - }, [subscribe]) + }, [subscribe, fixed, mouseState.visible]) useEffect(() => { if (!zone || !getXLine) return diff --git a/src/components/d3/plugins/D3Tooltip.tsx b/src/components/d3/plugins/D3Tooltip.tsx index 8622d59..86e9f9c 100644 --- a/src/components/d3/plugins/D3Tooltip.tsx +++ b/src/components/d3/plugins/D3Tooltip.tsx @@ -110,7 +110,7 @@ function _D3Tooltip>({ if (!unsubscribe() && isDev()) console.warn('Не удалось отвязать эвент') } - }, [visible]) + }, [subscribe, visible]) useEffect(() => { if (!tooltipRef.current || !zoneRect || fixed) return diff --git a/src/components/d3/types.ts b/src/components/d3/types.ts index 6094803..5f7ae80 100644 --- a/src/components/d3/types.ts +++ b/src/components/d3/types.ts @@ -62,7 +62,7 @@ export type BaseChartDataset = { /** Параметры штриховки графика */ dash?: string | number | [string | number, string | number] /** Привязка домена к домену другого графика */ - bindDomainFrom?: string | number + linkedTo?: string | number } export type LineChartDataset = { diff --git a/src/components/factory.ts b/src/components/factory.ts index 3d721da..9fe9c64 100755 --- a/src/components/factory.ts +++ b/src/components/factory.ts @@ -35,6 +35,15 @@ export const notify = (body?: ReactNode, notifyType: NotifyType = 'info', other? }) } +export const copyToClipboard = (value: string, successText?: string, errorText?: string) => { + try { + navigator.clipboard.writeText(value) + notify(successText ?? 'Текст успешно скопирован в буфер обмена', 'info') + } catch (ex) { + notify(errorText ?? 'Не удалось скопировать текст в буфер обмена', 'error') + } +} + type asyncFunction = (...args: any) => Promise const parseApiEror = (err: unknown, actionName?: string) => { diff --git a/src/components/icons/WellIcon.tsx b/src/components/icons/WellIcon.tsx index f0b7ddf..e0f8ed1 100755 --- a/src/components/icons/WellIcon.tsx +++ b/src/components/icons/WellIcon.tsx @@ -31,7 +31,7 @@ const defaultProps: WellIconProps = { } export const WellIcon = React.memo(({ width, height, state, online, colors, ...other } : WellIconProps = defaultProps) => { - colors = {...defaultColors, ...colors} + colors = { ...defaultColors, ...colors } return ( {online && ( // Полоски, показывающие наличие свежей телеметрии - - - + + + )} diff --git a/src/pages/AdminPanel/Telemetry/TelemetryMerger.jsx b/src/pages/AdminPanel/Telemetry/TelemetryMerger.jsx index 1e43eb5..4444121 100755 --- a/src/pages/AdminPanel/Telemetry/TelemetryMerger.jsx +++ b/src/pages/AdminPanel/Telemetry/TelemetryMerger.jsx @@ -35,7 +35,7 @@ export const TelemetryInfo = memo(({ info, danger, ...other }) => ( const TelemetryMerger = memo(() => { const [primary, setPrimary] = useState(null) const [secondary, setSecondary] = useState(null) - const [telemetry, setTelemetry] = useState(null) + const [telemetry, setTelemetry] = useState([]) const [isLoading, setIsLoading] = useState(false) const [isMerging, setIsMerging] = useState(false) diff --git a/src/pages/Telemetry/Archive/index.jsx b/src/pages/Telemetry/Archive/index.jsx index 4f0d2d3..03b20b9 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 { makeChartGroups, normalizeData } from '../TelemetryView' +import { makeChartGroups, normalizeData, yAxis } from '../TelemetryView' import cursorRender from '../TelemetryView/cursorRender' const DATA_COUNT = 2048 // Колличество точек на подгрузку графика @@ -222,7 +222,7 @@ const Archive = memo(() => { const chartData = useMemo(() => cutData(dataSaub, domain.min, domain.max), [dataSaub, domain]) return ( - <> +
Начальная дата:  @@ -243,27 +243,26 @@ const Archive = memo(() => {
- - formatDate(d) - }} - plugins={{ - menu: { enabled: false }, - cursor: { - width: 200, - render: cursorRender, - } - }} - height={'76vh'} - onWheel={onGraphWheel} - /> - - + formatDate(d) + }} + plugins={{ + menu: { enabled: false }, + cursor: { + width: 200, + render: cursorRender, + } + }} + height={'76vh'} + onWheel={onGraphWheel} + /> +
) }) diff --git a/src/pages/Telemetry/Operations/OperationsChart.jsx b/src/pages/Telemetry/Operations/OperationsChart.jsx index a0e7e82..3a20be2 100644 --- a/src/pages/Telemetry/Operations/OperationsChart.jsx +++ b/src/pages/Telemetry/Operations/OperationsChart.jsx @@ -8,7 +8,7 @@ import '@styles/detected_operations.less' const displayNumber = makeDisplayValue({ fixed: 2 }) -const makeTooltipRender = (category) => ([{ data: [data] }]) => ( +export const makeTooltipRender = (category) => ([{ data: [data] }]) => (
{data.operationCategory?.name} {formatDate(data.dateStart, undefined, 'DD.MM.YYYY')} @@ -21,12 +21,12 @@ const makeTooltipRender = (category) => ([{ data: [data] }]) => ( {displayNumber(data.depthEnd)} м - {category?.name ?? 'Ключ'}: + {category?.keyValueName ?? 'Ключ'}: {displayNumber(data.value)} - {category?.unit ?? '----'} + {category?.keyValueUnits ?? '----'} Цель: {displayNumber(data.operationValue?.targetValue)} - {category?.unit ?? '----'} + {category?.keyValueUnits ?? '----'}
) diff --git a/src/pages/Telemetry/Operations/index.jsx b/src/pages/Telemetry/Operations/index.jsx index 8306be8..b04f835 100644 --- a/src/pages/Telemetry/Operations/index.jsx +++ b/src/pages/Telemetry/Operations/index.jsx @@ -70,7 +70,11 @@ const Operations = memo(() => { invokeWebApiWrapperAsync( async () => { const categories = arrayOrDefault(await DetectedOperationService.getCategories()) - setCategories(categories.map(({ id, name }) => ({ value: id, label: name }))) + setCategories(categories.map((category) => ({ + ...category, + value: category.id, + label: category.name, + }))) }, setIsLoading, 'Не удалось загрзуить категории операций' diff --git a/src/pages/Telemetry/TelemetryView/index.jsx b/src/pages/Telemetry/TelemetryView/index.jsx index 7d5791c..dca17a5 100755 --- a/src/pages/Telemetry/TelemetryView/index.jsx +++ b/src/pages/Telemetry/TelemetryView/index.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, memo, useMemo } from 'react' import { BehaviorSubject, buffer, throttleTime } from 'rxjs' -import { Select } from 'antd' +import { Button, Select } from 'antd' import { useIdWell } from '@asb/context' import { makeDateSorter } from '@components/Table' @@ -36,7 +36,7 @@ import '@styles/message.css' const { Option } = Select -const yAxis = { +export const yAxis = { type: 'time', accessor: (d) => new Date(d.date), format: (d) => formatDate(d, undefined, 'YYYY-MM-DD HH:mm:ss'), @@ -49,7 +49,6 @@ const makeDataset = (label, shortLabel, color, key, unit, other) => ({ label, shortLabel, color, - yAxis, xAxis: { type: 'linear', accessor: key, @@ -72,7 +71,7 @@ export const makeChartGroups = (flowChart) => { maxXAccessor: 'depthEnd', minYAccessor: accessor + 'Min', maxYAccessor: accessor + 'Max', - bindDomainFrom: accessor, + linkedTo: accessor, }) return [ @@ -149,6 +148,7 @@ const TelemetryView = memo(() => { const [flowChartData, setFlowChartData] = useState([]) const [rop, setRop] = useState(null) const [domain, setDomain] = useState({}) + const [chartMethods, setChartMethods] = useState() const idWell = useIdWell() @@ -171,7 +171,7 @@ const TelemetryView = memo(() => { useEffect(() => { const subscribtion = saubSubject$.pipe( buffer(saubSubject$.pipe(throttleTime(700))) - ).subscribe((data) => handleDataSaub(data.flat())) + ).subscribe((data) => handleDataSaub(data.flat().filter(Boolean))) return () => subscribtion.unsubscribe() }, [saubSubject$]) @@ -179,7 +179,7 @@ const TelemetryView = memo(() => { useEffect(() => { const subscribtion = spinSubject$.pipe( buffer(spinSubject$.pipe(throttleTime(700))) - ).subscribe((data) => handleDataSpin(data.flat())) + ).subscribe((data) => handleDataSpin(data.flat().filter(Boolean))) return () => subscribtion.unsubscribe() }, [spinSubject$]) @@ -272,6 +272,7 @@ const TelemetryView = memo(() => { Интервал:  +
Статус: