Добавлено окно настройки группы и графика

This commit is contained in:
goodmice 2022-08-04 01:34:03 +05:00
parent 354d6945d7
commit cefcf9a75e
4 changed files with 326 additions and 22 deletions

View 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

View File

@ -25,6 +25,9 @@ import {
import D3MouseZone from './D3MouseZone' import D3MouseZone from './D3MouseZone'
import { getByAccessor, getChartClass, getGroupClass, getTicks } from './functions' import { getByAccessor, getChartClass, getGroupClass, getTicks } from './functions'
import { renderArea, renderLine, renderNeedle, renderPoint, renderRectArea } from './renders' 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) => { const roundTo = (v: number, to: number = 50) => {
if (to == 0) return v if (to == 0) return v
@ -43,14 +46,14 @@ const calculateDomain = (mm: MinMax, round: number = 100): Required<MinMax> => {
return { min, max } return { min, max }
} }
type ExtendedChartDataset<DataType> = ChartDataset<DataType> & { export type ExtendedChartDataset<DataType> = ChartDataset<DataType> & {
/** Диапазон отображаемых значений по горизонтальной оси */ /** Диапазон отображаемых значений по горизонтальной оси */
xDomain: MinMax xDomain: MinMax
/** Скрыть отображение шкалы графика */ /** Скрыть отображение шкалы графика */
hideLabel?: boolean hideLabel?: boolean
} }
type ExtendedChartRegistry<DataType> = ChartRegistry<DataType> & ExtendedChartDataset<DataType> export type ExtendedChartRegistry<DataType> = ChartRegistry<DataType> & ExtendedChartDataset<DataType>
export type ChartGroup<DataType> = { export type ChartGroup<DataType> = {
/** Получить D3 выборку, содержащую корневой G-элемент группы */ /** Получить D3 выборку, содержащую корневой G-элемент группы */
@ -121,6 +124,8 @@ export type D3MonitoringChartsProps<DataType extends Record<string, any>> = Reac
axisHeight?: number axisHeight?: number
/** Отступ между группами графиков в пикселях (30 по умолчанию) */ /** Отступ между группами графиков в пикселях (30 по умолчанию) */
spaceBetweenGroups?: number spaceBetweenGroups?: number
/** Название графика для сохранения в базе */
chartName?: string
} }
export type ChartSizes = ChartOffset & { export type ChartSizes = ChartOffset & {
@ -165,15 +170,46 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
axisHeight = 20, axisHeight = 20,
spaceBetweenGroups = 30, spaceBetweenGroups = 30,
dash, dash,
chartName,
className = '', className = '',
...other ...other
}: D3MonitoringChartsProps<DataType>) => { }: D3MonitoringChartsProps<DataType>) => {
const [datasets, setDatasets] = useState<ExtendedChartDataset<DataType>[][]>([])
const [groups, setGroups] = useState<ChartGroup<DataType>[]>([]) const [groups, setGroups] = useState<ChartGroup<DataType>[]>([])
const [svgRef, setSvgRef] = useState<SVGSVGElement | null>(null) const [svgRef, setSvgRef] = useState<SVGSVGElement | null>(null)
const [yAxisRef, setYAxisRef] = useState<SVGGElement | null>(null) const [yAxisRef, setYAxisRef] = useState<SVGGElement | null>(null)
const [chartAreaRef, setChartAreaRef] = useState<SVGGElement | null>(null) const [chartAreaRef, setChartAreaRef] = useState<SVGGElement | null>(null)
const [axesAreaRef, setAxesAreaRef] = 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 offset = usePartialProps(_offset, defaultOffsets)
const yTicks = usePartialProps<Required<ChartTick<DataType>>>(_yTicks, getDefaultYTicks) const yTicks = usePartialProps<Required<ChartTick<DataType>>>(_yTicks, getDefaultYTicks)
@ -218,14 +254,6 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
return yAxis return yAxis
}, [groups, data, yDomain, sizes.chartsHeight]) }, [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 chartDomains = useMemo(() => groups.map((group) => {
const out: [string | number, ChartDomain][] = group.charts.map((chart) => { const out: [string | number, ChartDomain][] = group.charts.map((chart) => {
const mm = { ...chart.xDomain } const mm = { ...chart.xDomain }
@ -254,14 +282,26 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
return Object.fromEntries(out) return Object.fromEntries(out)
}), [groups, data]) }), [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(() => { useEffect(() => {
if (isDev()) { if (isDev()) {
datasetGroups.forEach((sets, i) => { datasets.forEach((sets, i) => {
sets.forEach((set, j) => { 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) if (set.key === sets[k].key)
console.warn(`Ключ датасета "${set.key}" неуникален (группа ${i}, индексы ${j} и ${k})!`) console.warn(`Ключ датасета "${set.key}" неуникален (группа ${i}, индексы ${j} и ${k})!`)
}
}) })
}) })
} }
@ -269,15 +309,15 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
setGroups((oldGroups) => { setGroups((oldGroups) => {
const groups: ChartGroup<DataType>[] = [] const groups: ChartGroup<DataType>[] = []
if (datasetGroups.length < oldGroups.length) { if (datasets.length < oldGroups.length) {
// Удаляем неактуальные группы // Удаляем неактуальные группы
oldGroups.slice(datasetGroups.length).forEach((group) => group().remove()) oldGroups.slice(datasets.length).forEach((group) => group().remove())
groups.push(...oldGroups.slice(0, datasetGroups.length)) groups.push(...oldGroups.slice(0, datasets.length))
} else { } else {
groups.push(...oldGroups) groups.push(...oldGroups)
} }
datasetGroups.forEach((datasets, i) => { datasets.forEach((datasets, i) => {
let group: ChartGroup<DataType> = createAxesGroup(i) let group: ChartGroup<DataType> = createAxesGroup(i)
if (group().empty()) if (group().empty())
@ -326,7 +366,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
return groups return groups
}) })
}, [yAxisConfig, chartArea, datasetGroups, animDurationMs, createAxesGroup]) }, [yAxisConfig, chartArea, datasets, animDurationMs, createAxesGroup])
useEffect(() => { useEffect(() => {
const axesGroups = d3.select(axesAreaRef) const axesGroups = d3.select(axesAreaRef)
@ -479,7 +519,11 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
className={`asb-d3-chart ${className}`} className={`asb-d3-chart ${className}`}
> >
{data ? ( {data ? (
<D3ContextMenu {...plugins?.menu} svg={svgRef}> <D3ContextMenu
onSettingsOpen={() => setSettingsVisible(true)}
{...plugins?.menu}
svg={svgRef}
>
<svg ref={setSvgRef} width={'100%'} height={'100%'}> <svg ref={setSvgRef} width={'100%'} height={'100%'}>
<defs> <defs>
<clipPath id={`chart-clip`}> <clipPath id={`chart-clip`}>
@ -516,6 +560,13 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} /> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div> </div>
)} )}
<D3MonitoringGroupsEditor
name={chartName}
groups={datasets}
visible={settingsVisible}
onChange={onGroupsChange}
onCancel={() => setSettingsVisible(false)}
/>
</div> </div>
</LoaderPortal> </LoaderPortal>
) )

View 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

View File

@ -8,7 +8,7 @@ import { BasePluginSettings } from './base'
export type D3ContextMenuSettings = { export type D3ContextMenuSettings = {
/** Метод или объект отрисовки пунктов выпадающего меню */ /** Метод или объект отрисовки пунктов выпадающего меню */
overlay?: FunctionalValue<(svg: SVGSVGElement | null) => ReactElement | null> overlay?: FunctionalValue<(svg: SVGSVGElement | null, onUpdate?: () => void, onSettingsOpen?: () => void) => ReactElement | null>
/** Название графика для загрузки */ /** Название графика для загрузки */
downloadFilename?: string downloadFilename?: string
/** Событие, вызываемое при нажатий кнопки "Обновить" */ /** Событие, вызываемое при нажатий кнопки "Обновить" */
@ -23,6 +23,8 @@ export type D3ContextMenuProps = BasePluginSettings & D3ContextMenuSettings & {
children: any children: any
/** SVG-элемент */ /** SVG-элемент */
svg: SVGSVGElement | null svg: SVGSVGElement | null
/** Событие, вызываемое при нажатий кнопки "Настройки" */
onSettingsOpen?: () => void
} }
export const D3ContextMenu = memo<D3ContextMenuProps>(({ export const D3ContextMenu = memo<D3ContextMenuProps>(({
@ -30,6 +32,7 @@ export const D3ContextMenu = memo<D3ContextMenuProps>(({
downloadFilename = 'chart', downloadFilename = 'chart',
additionalMenuItems, additionalMenuItems,
onUpdate, onUpdate,
onSettingsOpen,
trigger = ['contextMenu'], trigger = ['contextMenu'],
enabled = true, enabled = true,
children, children,
@ -43,6 +46,9 @@ export const D3ContextMenu = memo<D3ContextMenuProps>(({
if (onUpdate) if (onUpdate)
menuItems.push({ key: 'refresh', label: 'Обновить', onClick: onUpdate }) menuItems.push({ key: 'refresh', label: 'Обновить', onClick: onUpdate })
if (onSettingsOpen)
menuItems.push({ key: 'settings', label: 'Настройки', onClick: onSettingsOpen })
if (svg) if (svg)
menuItems.push({ key: 'download', label: ( menuItems.push({ key: 'download', label: (
<a href={svgToDataURL(svg)} download={`${downloadFilename}.svg`}>Сохранить</a> <a href={svgToDataURL(svg)} download={`${downloadFilename}.svg`}>Сохранить</a>
@ -52,11 +58,11 @@ export const D3ContextMenu = memo<D3ContextMenuProps>(({
menuItems.push(...additionalMenuItems) menuItems.push(...additionalMenuItems)
return menuItems return menuItems
}, [svg, downloadFilename, onUpdate, additionalMenuItems]) }, [svg, downloadFilename, onUpdate, onSettingsOpen, additionalMenuItems])
return ( return (
<Dropdown <Dropdown
overlay={overlay(svg) || ( <Menu items={menuItems} /> )} overlay={overlay(svg, onUpdate, onSettingsOpen) || ( <Menu items={menuItems} /> )}
disabled={!enabled} disabled={!enabled}
trigger={trigger} trigger={trigger}
> >