diff --git a/src/components/d3/D3MonitoringChartEditor.tsx b/src/components/d3/D3MonitoringChartEditor.tsx new file mode 100644 index 0000000..3bf55d1 --- /dev/null +++ b/src/components/d3/D3MonitoringChartEditor.tsx @@ -0,0 +1,106 @@ +import { Button, Form, FormItemProps, Input, InputNumber, Select, Space, Tooltip, Typography } from 'antd' +import { CSSProperties, memo, useCallback, useEffect, useMemo, useState } 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 = Omit, HTMLDivElement>, 'onChange'> & { + chart?: ExtendedChartDataset | null + onChange: (value: ExtendedChartDataset) => boolean +} + +const _D3MonitoringChartEditor = ({ + chart: value, + onChange, + + style, + ...other +}: D3MonitoringChartEditorProps) => { + const [domain, setDomain] = useState({}) + const [color, setColor] = useState() + + const [form] = Form.useForm() + + const onDomainChange = useCallback((mm: MinMax) => { + setDomain((prev) => ({ + min: ('min' in mm) ? mm.min : prev?.min, + max: ('max' in mm) ? mm.max : prev?.max, + })) + }, []) + + const onColorChange = useCallback((color: Color) => setColor((prev) => color ?? prev), []) + + useEffect(() => { + if (value?.type) { + form.setFieldsValue(value) + } else { + form.resetFields() + } + setColor(value?.color ? new Color(String(value.color)) : undefined) + setDomain(value?.xDomain ?? {}) + }, [value, form]) + + const onSave = useCallback(() => { + if (!value) return + const values = form.getFieldsValue() + const newValue = { + ...value, + color, + xDomain: domain, + ...values, + } + onChange(newValue) + }, [form, domain, color, value]) + + const divStyle: CSSProperties = useMemo(() => ({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + alignItems: 'center', + ...style, + }), [style]) + + return ( +
+
+ + + + + + + + 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..9e7ce25 100644 --- a/src/components/d3/D3MonitoringCharts.tsx +++ b/src/components/d3/D3MonitoringCharts.tsx @@ -25,6 +25,9 @@ import { import D3MouseZone from './D3MouseZone' import { getByAccessor, getChartClass, getGroupClass, getTicks } from './functions' import { renderArea, renderLine, renderNeedle, renderPoint, renderRectArea } from './renders' +import D3MonitoringGroupsEditor from './D3MonitoringGroupsEditor' +import { UserSettingsService } from '@asb/services/api' +import { invokeWebApiWrapperAsync } from '../factory' const roundTo = (v: number, to: number = 50) => { if (to == 0) return v @@ -43,14 +46,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-элемент группы */ @@ -121,6 +124,8 @@ export type D3MonitoringChartsProps> = Reac axisHeight?: number /** Отступ между группами графиков в пикселях (30 по умолчанию) */ spaceBetweenGroups?: number + /** Название графика для сохранения в базе */ + chartName?: string } export type ChartSizes = ChartOffset & { @@ -165,15 +170,46 @@ const _D3MonitoringCharts = >({ axisHeight = 20, spaceBetweenGroups = 30, dash, + chartName, 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(true) + + useEffect(() => { + if (!chartName) { + setDatasets(datasetGroups) + return + } + let datasets: ExtendedChartDataset[][] = [] + let needInsert = false + invokeWebApiWrapperAsync( + async () => { + const sets = await UserSettingsService.get(chartName) + needInsert = !sets + datasets = sets ?? datasetGroups + }, + undefined, + 'Не удалось загрузить настройки графиков' + ) + setDatasets(datasets) + if (needInsert) { + invokeWebApiWrapperAsync( + async () => { + await UserSettingsService.insert(chartName, datasets) + }, + undefined, + 'Не удалось сохранить настройки графиков' + ) + } + }, [datasetGroups, chartName]) const offset = usePartialProps(_offset, defaultOffsets) const yTicks = usePartialProps>>(_yTicks, getDefaultYTicks) @@ -218,14 +254,6 @@ 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 } @@ -254,14 +282,26 @@ 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, axesArea]) + + const onGroupsChange = useCallback((sets: ExtendedChartDataset[][]) => { + setDatasets(sets) + setSettingsVisible(false) + }, []) + 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 +309,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()) @@ -326,7 +366,7 @@ const _D3MonitoringCharts = >({ return groups }) - }, [yAxisConfig, chartArea, datasetGroups, animDurationMs, createAxesGroup]) + }, [yAxisConfig, chartArea, datasets, animDurationMs, createAxesGroup]) useEffect(() => { const axesGroups = d3.select(axesAreaRef) @@ -479,7 +519,11 @@ const _D3MonitoringCharts = >({ className={`asb-d3-chart ${className}`} > {data ? ( - + setSettingsVisible(true)} + {...plugins?.menu} + svg={svgRef} + > @@ -516,6 +560,13 @@ const _D3MonitoringCharts = >({ )} + setSettingsVisible(false)} + /> ) diff --git a/src/components/d3/D3MonitoringGroupsEditor.tsx b/src/components/d3/D3MonitoringGroupsEditor.tsx new file mode 100644 index 0000000..d08ed14 --- /dev/null +++ b/src/components/d3/D3MonitoringGroupsEditor.tsx @@ -0,0 +1,141 @@ +import { Key, memo, useCallback, useEffect, useMemo, useState } from 'react' +import { Divider, Modal, Tooltip, Tree } from 'antd' + +import { getChartIcon } from '@utils' + +import { ChartGroup, ExtendedChartDataset } from './D3MonitoringCharts' +import D3MonitoringChartEditor from './D3MonitoringChartEditor' + +export type D3MonitoringGroupsEditorProps = { + visible?: boolean + groups: ExtendedChartDataset[][] + onChange: (value: ExtendedChartDataset[][]) => void + onCancel: () => void + name?: string +} + +const moveToPos = (arr: T[], pos: number, newPos: number): T[] => { + if (pos === newPos) return arr + if (newPos === -1) return [arr[pos], ...arr.slice(0, pos), ...arr.slice(pos + 1)] + if (newPos === arr.length) return [...arr.slice(0, pos), ...arr.slice(pos + 1), arr[pos]] + const newArray = [] + for (let i = 0; i < arr.length; i++) { + if (i == newPos) newArray.push(arr[pos]) + if (i !== pos) newArray.push(arr[i]) + } + return newArray +} + +const getChartLabel = (chart: ExtendedChartDataset) => ( + + {getChartIcon(chart)} {chart.label} + +) + +const _D3MonitoringGroupsEditor = ({ + visible, + groups: oldGroups, + onChange, + onCancel, + name, +}: 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: any) => { + const { dragNode, dropPosition, dropToGap } = info + + const nodes = dragNode.pos.split('-') + const groupPos = Number(nodes[1]) + if (!Number.isFinite(groupPos)) return + setGroups((prev) => { + if (dropToGap) { + if (nodes.length < 3) + return moveToPos(prev, groupPos, dropPosition) + } else { + if (nodes.length < 3) return prev + const chartPos = Number(nodes[2]) + if (Number.isFinite(chartPos)) { + prev[groupPos] = moveToPos(prev[groupPos], chartPos, dropPosition) + return [ ...prev ] + } + } + 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 selectedChart = useMemo(() => { + if (!selectedIdx) return null + + return groups[selectedIdx.group][selectedIdx.chart] + }, [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} + /> +
+ + chart={selectedChart} style={{ flexGrow: 1 }} 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} >