forked from ddrilling/asb_cloud_front
Добавлено окно настройки группы и графика
This commit is contained in:
parent
354d6945d7
commit
cefcf9a75e
106
src/components/d3/D3MonitoringChartEditor.tsx
Normal file
106
src/components/d3/D3MonitoringChartEditor.tsx
Normal file
@ -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 = <Values,>({ style, ...other }: FormItemProps<Values>) => <RawItem<Values> 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<DataType> = Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'onChange'> & {
|
||||
chart?: ExtendedChartDataset<DataType> | null
|
||||
onChange: (value: ExtendedChartDataset<DataType>) => boolean
|
||||
}
|
||||
|
||||
const _D3MonitoringChartEditor = <DataType,>({
|
||||
chart: value,
|
||||
onChange,
|
||||
|
||||
style,
|
||||
...other
|
||||
}: D3MonitoringChartEditorProps<DataType>) => {
|
||||
const [domain, setDomain] = useState<MinMax>({})
|
||||
const [color, setColor] = useState<Color>()
|
||||
|
||||
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 (
|
||||
<div style={divStyle} {...other}>
|
||||
<Form form={form} onChange={onSave}>
|
||||
<Tooltip title={'Возможность изменения типов линий будет добавлена в будущих обновлениях'}>
|
||||
<Item label={'Тип'}><Select disabled value={value?.type ?? 'Неизвестный'} options={lineTypes} /></Item>
|
||||
</Tooltip>
|
||||
<Item label={'Название'}>
|
||||
<Input.Group compact>
|
||||
<Item name={'label'} rules={[{ required: true }]}><Input placeholder={'Полное'} /></Item>
|
||||
<Item name={'shortLabel'}><Input placeholder={'Краткое'}/></Item>
|
||||
</Input.Group>
|
||||
</Item>
|
||||
<Item label={'Диапазон'}>
|
||||
<Input.Group compact>
|
||||
<Item><InputNumber value={domain?.min} onChange={(min) => onDomainChange({ min })} placeholder={'Мин'} /></Item>
|
||||
<Item><InputNumber value={domain?.max} onChange={(max) => onDomainChange({ max })} placeholder={'Макс'} /></Item>
|
||||
<Button
|
||||
disabled={!domain?.min && !domain?.max}
|
||||
onClick={() => onDomainChange({ min: undefined, max: undefined })}
|
||||
>Авто</Button>
|
||||
</Input.Group>
|
||||
</Item>
|
||||
<Item label={'Цвет линий'}><ColorPicker onChange={onColorChange} value={color} /></Item>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const D3MonitoringChartEditor = memo(_D3MonitoringChartEditor) as typeof _D3MonitoringChartEditor
|
||||
|
||||
export default D3MonitoringChartEditor
|
@ -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<MinMax> => {
|
||||
return { min, max }
|
||||
}
|
||||
|
||||
type ExtendedChartDataset<DataType> = ChartDataset<DataType> & {
|
||||
export type ExtendedChartDataset<DataType> = ChartDataset<DataType> & {
|
||||
/** Диапазон отображаемых значений по горизонтальной оси */
|
||||
xDomain: MinMax
|
||||
/** Скрыть отображение шкалы графика */
|
||||
hideLabel?: boolean
|
||||
}
|
||||
|
||||
type ExtendedChartRegistry<DataType> = ChartRegistry<DataType> & ExtendedChartDataset<DataType>
|
||||
export type ExtendedChartRegistry<DataType> = ChartRegistry<DataType> & ExtendedChartDataset<DataType>
|
||||
|
||||
export type ChartGroup<DataType> = {
|
||||
/** Получить D3 выборку, содержащую корневой G-элемент группы */
|
||||
@ -121,6 +124,8 @@ export type D3MonitoringChartsProps<DataType extends Record<string, any>> = Reac
|
||||
axisHeight?: number
|
||||
/** Отступ между группами графиков в пикселях (30 по умолчанию) */
|
||||
spaceBetweenGroups?: number
|
||||
/** Название графика для сохранения в базе */
|
||||
chartName?: string
|
||||
}
|
||||
|
||||
export type ChartSizes = ChartOffset & {
|
||||
@ -165,15 +170,46 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
axisHeight = 20,
|
||||
spaceBetweenGroups = 30,
|
||||
dash,
|
||||
chartName,
|
||||
|
||||
className = '',
|
||||
...other
|
||||
}: D3MonitoringChartsProps<DataType>) => {
|
||||
const [datasets, setDatasets] = useState<ExtendedChartDataset<DataType>[][]>([])
|
||||
const [groups, setGroups] = useState<ChartGroup<DataType>[]>([])
|
||||
const [svgRef, setSvgRef] = useState<SVGSVGElement | null>(null)
|
||||
const [yAxisRef, setYAxisRef] = useState<SVGGElement | null>(null)
|
||||
const [chartAreaRef, setChartAreaRef] = useState<SVGGElement | null>(null)
|
||||
const [axesAreaRef, setAxesAreaRef] = useState<SVGGElement | null>(null)
|
||||
const [settingsVisible, setSettingsVisible] = useState<boolean>(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartName) {
|
||||
setDatasets(datasetGroups)
|
||||
return
|
||||
}
|
||||
let datasets: ExtendedChartDataset<DataType>[][] = []
|
||||
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<Required<ChartTick<DataType>>>(_yTicks, getDefaultYTicks)
|
||||
@ -218,14 +254,6 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
return yAxis
|
||||
}, [groups, data, yDomain, sizes.chartsHeight])
|
||||
|
||||
const createAxesGroup = useCallback((i: number): ChartGroup<DataType> => Object.assign(
|
||||
() => chartArea().select('.' + getGroupClass(i)) as d3.Selection<SVGGElement, any, any, any>,
|
||||
{
|
||||
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 = <DataType extends Record<string, unknown>>({
|
||||
return Object.fromEntries(out)
|
||||
}), [groups, data])
|
||||
|
||||
const createAxesGroup = useCallback((i: number): ChartGroup<DataType> => Object.assign(
|
||||
() => chartArea().select('.' + getGroupClass(i)) as d3.Selection<SVGGElement, any, any, any>,
|
||||
{
|
||||
key: i,
|
||||
charts: [],
|
||||
}
|
||||
), [chartArea, axesArea])
|
||||
|
||||
const onGroupsChange = useCallback((sets: ExtendedChartDataset<DataType>[][]) => {
|
||||
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 = <DataType extends Record<string, unknown>>({
|
||||
setGroups((oldGroups) => {
|
||||
const groups: ChartGroup<DataType>[] = []
|
||||
|
||||
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<DataType> = createAxesGroup(i)
|
||||
|
||||
if (group().empty())
|
||||
@ -326,7 +366,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
|
||||
return groups
|
||||
})
|
||||
}, [yAxisConfig, chartArea, datasetGroups, animDurationMs, createAxesGroup])
|
||||
}, [yAxisConfig, chartArea, datasets, animDurationMs, createAxesGroup])
|
||||
|
||||
useEffect(() => {
|
||||
const axesGroups = d3.select(axesAreaRef)
|
||||
@ -479,7 +519,11 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
className={`asb-d3-chart ${className}`}
|
||||
>
|
||||
{data ? (
|
||||
<D3ContextMenu {...plugins?.menu} svg={svgRef}>
|
||||
<D3ContextMenu
|
||||
onSettingsOpen={() => setSettingsVisible(true)}
|
||||
{...plugins?.menu}
|
||||
svg={svgRef}
|
||||
>
|
||||
<svg ref={setSvgRef} width={'100%'} height={'100%'}>
|
||||
<defs>
|
||||
<clipPath id={`chart-clip`}>
|
||||
@ -516,6 +560,13 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</div>
|
||||
)}
|
||||
<D3MonitoringGroupsEditor
|
||||
name={chartName}
|
||||
groups={datasets}
|
||||
visible={settingsVisible}
|
||||
onChange={onGroupsChange}
|
||||
onCancel={() => setSettingsVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
</LoaderPortal>
|
||||
)
|
||||
|
141
src/components/d3/D3MonitoringGroupsEditor.tsx
Normal file
141
src/components/d3/D3MonitoringGroupsEditor.tsx
Normal file
@ -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<DataType> = {
|
||||
visible?: boolean
|
||||
groups: ExtendedChartDataset<DataType>[][]
|
||||
onChange: (value: ExtendedChartDataset<DataType>[][]) => void
|
||||
onCancel: () => void
|
||||
name?: string
|
||||
}
|
||||
|
||||
const moveToPos = <T,>(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 = <DataType,>(chart: ExtendedChartDataset<DataType>) => (
|
||||
<Tooltip title={chart.label}>
|
||||
{getChartIcon(chart)} {chart.label}
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
const _D3MonitoringGroupsEditor = <DataType,>({
|
||||
visible,
|
||||
groups: oldGroups,
|
||||
onChange,
|
||||
onCancel,
|
||||
name,
|
||||
}: D3MonitoringGroupsEditorProps<DataType>) => {
|
||||
const [groups, setGroups] = useState<ExtendedChartDataset<DataType>[][]>([])
|
||||
const [expand, setExpand] = useState<Key[]>([])
|
||||
const [selected, setSelected] = useState<Key[]>([])
|
||||
|
||||
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<DataType>) => {
|
||||
if (!selectedIdx) return false
|
||||
setGroups((prev) => {
|
||||
const groups = [ ...prev ]
|
||||
groups[selectedIdx.group][selectedIdx.chart] = chart
|
||||
return groups
|
||||
})
|
||||
return true
|
||||
}, [selectedIdx])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered
|
||||
width={700}
|
||||
visible={visible}
|
||||
onOk={onModalOk}
|
||||
onCancel={onCancel}
|
||||
okText={'Сохранить изменения'}
|
||||
title={'Настройка групп графиков'}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'stretch', height: 250 }}>
|
||||
<div style={{ width: '25%' }}>
|
||||
<Tree
|
||||
// draggable
|
||||
selectable
|
||||
onExpand={(keys) => setExpand(keys)}
|
||||
expandedKeys={expand}
|
||||
selectedKeys={selected}
|
||||
treeData={treeItems}
|
||||
onDrop={onDrop}
|
||||
onSelect={setSelected}
|
||||
height={250}
|
||||
/>
|
||||
</div>
|
||||
<Divider type={'vertical'} style={{ height: '100%', padding: '0 5px' }} />
|
||||
<D3MonitoringChartEditor<DataType> chart={selectedChart} style={{ flexGrow: 1 }} onChange={onChartChange} />
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export const D3MonitoringGroupsEditor = memo(_D3MonitoringGroupsEditor) as typeof _D3MonitoringGroupsEditor
|
||||
|
||||
export default D3MonitoringGroupsEditor
|
@ -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<D3ContextMenuProps>(({
|
||||
@ -30,6 +32,7 @@ export const D3ContextMenu = memo<D3ContextMenuProps>(({
|
||||
downloadFilename = 'chart',
|
||||
additionalMenuItems,
|
||||
onUpdate,
|
||||
onSettingsOpen,
|
||||
trigger = ['contextMenu'],
|
||||
enabled = true,
|
||||
children,
|
||||
@ -43,6 +46,9 @@ export const D3ContextMenu = memo<D3ContextMenuProps>(({
|
||||
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: (
|
||||
<a href={svgToDataURL(svg)} download={`${downloadFilename}.svg`}>Сохранить</a>
|
||||
@ -52,11 +58,11 @@ export const D3ContextMenu = memo<D3ContextMenuProps>(({
|
||||
menuItems.push(...additionalMenuItems)
|
||||
|
||||
return menuItems
|
||||
}, [svg, downloadFilename, onUpdate, additionalMenuItems])
|
||||
}, [svg, downloadFilename, onUpdate, onSettingsOpen, additionalMenuItems])
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlay={overlay(svg) || ( <Menu items={menuItems} /> )}
|
||||
overlay={overlay(svg, onUpdate, onSettingsOpen) || ( <Menu items={menuItems} /> )}
|
||||
disabled={!enabled}
|
||||
trigger={trigger}
|
||||
>
|
||||
|
Loading…
Reference in New Issue
Block a user