forked from ddrilling/asb_cloud_front
Merge branch 'dev' into feature/add-members-modal-window
This commit is contained in:
commit
423e575483
129
src/components/ColorPicker.tsx
Normal file
129
src/components/ColorPicker.tsx
Normal file
@ -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<React.SetStateAction<Color>>, accessor: 'r' | 'g' | 'b' | 'a') => (value: number) => set((prev: Color) => {
|
||||
const out = new Color(prev)
|
||||
out[accessor] = value
|
||||
return out
|
||||
})
|
||||
|
||||
export const ColorPicker = memo<ColorPickerProps>(({ value = '#AA33BB', onChange, size, ...other }) => {
|
||||
const [color, setColor] = useState<Color>(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 (
|
||||
<Popover
|
||||
trigger={'click'}
|
||||
onVisibleChange={onClose}
|
||||
content={(
|
||||
<div className={'asb-color-picker-content'}>
|
||||
<div className={'asb-color-picker-sliders'}>
|
||||
<Slider vertical min={0} max={255} defaultValue={color.r} onChange={changeR} />
|
||||
<Slider vertical min={0} max={255} defaultValue={color.g} onChange={changeG} />
|
||||
<Slider vertical min={0} max={255} defaultValue={color.b} onChange={changeB} />
|
||||
<Slider vertical min={0} max={1} step={0.01} defaultValue={color.a} onChange={changeA} />
|
||||
</div>
|
||||
<Input {...other} value={color.toHexString()} addonBefore={(
|
||||
<CopyOutlined onClick={onCopyClick} />
|
||||
)} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className={'asb-color-picker-preview'} style={divStyle}/>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
|
||||
export default ColorPicker
|
108
src/components/d3/D3MonitoringChartEditor.tsx
Normal file
108
src/components/d3/D3MonitoringChartEditor.tsx
Normal file
@ -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 = <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> = {
|
||||
group: ExtendedChartDataset<DataType>[]
|
||||
chart: ExtendedChartDataset<DataType>
|
||||
onChange: (value: ExtendedChartDataset<DataType>) => boolean
|
||||
}
|
||||
|
||||
const _D3MonitoringChartEditor = <DataType,>({
|
||||
group,
|
||||
chart: value,
|
||||
onChange,
|
||||
}: D3MonitoringChartEditorProps<DataType>) => {
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const onSave = useCallback((props: Partial<ExtendedChartDataset<DataType>>) => {
|
||||
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 (
|
||||
<Form form={form}>
|
||||
<Tooltip title={'Возможность изменения типов линий будет добавлена в будущих обновлениях'}>
|
||||
<Item label={'Тип'} name={'type'}><Select disabled options={lineTypes} defaultValue={'Неизвестный'} /></Item>
|
||||
</Tooltip>
|
||||
<Item label={'Привязан к'} name={'linkedTo'}>
|
||||
<Select
|
||||
allowClear
|
||||
options={options}
|
||||
defaultValue={'Привязанный график отсутствует в группе'}
|
||||
onChange={(value) => onSave({ linkedTo: value })}
|
||||
/>
|
||||
</Item>
|
||||
<Item label={'Название'}>
|
||||
<Input.Group compact>
|
||||
<Item name={'label'} rules={[{ required: true }]}><Input placeholder={'Полное'} onChange={(e) => onSave({ label: e.target.value })} /></Item>
|
||||
<Item name={'shortLabel'}><Input placeholder={'Краткое'} onChange={(e) => onSave({ shortLabel: e.target.value })} /></Item>
|
||||
</Input.Group>
|
||||
</Item>
|
||||
<Item label={'Диапазон'}>
|
||||
<Input.Group compact>
|
||||
<InputNumber disabled={!!value.linkedTo} value={value.xDomain?.min} onChange={(min) => onDomainChange({ min })} placeholder={'Мин'} />
|
||||
<InputNumber disabled={!!value.linkedTo} value={value.xDomain?.max} onChange={(max) => onDomainChange({ max })} placeholder={'Макс'} />
|
||||
<Button
|
||||
disabled={!!value.linkedTo || (!Number.isFinite(value.xDomain?.min) && !Number.isFinite(value.xDomain?.max))}
|
||||
onClick={() => onDomainChange({ min: undefined, max: undefined })}
|
||||
>Авто</Button>
|
||||
</Input.Group>
|
||||
</Item>
|
||||
<Item label={'Цвет линий'}><ColorPicker onChange={onColorChange} value={value.color} /></Item>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export const D3MonitoringChartEditor = memo(_D3MonitoringChartEditor) as typeof _D3MonitoringChartEditor
|
||||
|
||||
export default D3MonitoringChartEditor
|
@ -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<MinMax> => {
|
||||
const calculateDomain = (mm: MinMax): Required<MinMax> => {
|
||||
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<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-элемент группы */
|
||||
@ -83,7 +91,7 @@ const getDefaultYTicks = <DataType,>(): Required<ChartTick<DataType>> => ({
|
||||
/**
|
||||
* @template DataType тип данных отображаемых записей
|
||||
*/
|
||||
export type D3MonitoringChartsProps<DataType extends Record<string, any>> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
export type D3MonitoringChartsProps<DataType extends Record<string, any>> = Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'> & {
|
||||
/** Двумерный массив датасетов (группа-график) */
|
||||
datasetGroups: ExtendedChartDataset<DataType>[][]
|
||||
/** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */
|
||||
@ -121,6 +129,11 @@ export type D3MonitoringChartsProps<DataType extends Record<string, any>> = Reac
|
||||
axisHeight?: number
|
||||
/** Отступ между группами графиков в пикселях (30 по умолчанию) */
|
||||
spaceBetweenGroups?: number
|
||||
/** Название графика для сохранения в базе */
|
||||
chartName?: string
|
||||
methods?: (value: {
|
||||
setSettingsVisible: (visible: boolean) => void
|
||||
}) => void
|
||||
}
|
||||
|
||||
export type ChartSizes = ChartOffset & {
|
||||
@ -165,15 +178,19 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
axisHeight = 20,
|
||||
spaceBetweenGroups = 30,
|
||||
dash,
|
||||
chartName,
|
||||
methods,
|
||||
|
||||
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>(false)
|
||||
|
||||
const offset = usePartialProps(_offset, defaultOffsets)
|
||||
const yTicks = usePartialProps<Required<ChartTick<DataType>>>(_yTicks, getDefaultYTicks)
|
||||
@ -206,7 +223,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
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 = <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 }
|
||||
let domain: Required<MinMax> = { min: 0, max: 100 }
|
||||
if (mm.min && mm.max) {
|
||||
if (!Number.isNaN((mm.min ?? NaN) + (mm.max ?? NaN))) {
|
||||
domain = mm as Required<MinMax>
|
||||
} 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 = <DataType extends Record<string, unknown>>({
|
||||
|
||||
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 = <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])
|
||||
|
||||
const onGroupsChange = useCallback((sets: ExtendedChartDataset<DataType>[][]) => {
|
||||
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 = <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())
|
||||
@ -305,7 +373,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
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 = <DataType extends Record<string, unknown>>({
|
||||
|
||||
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 = <DataType extends Record<string, unknown>>({
|
||||
.append('g')
|
||||
.attr('class', 'charts-group')
|
||||
|
||||
const actualAxesGroups = d3.select(axesAreaRef)
|
||||
const actualAxesGroups = axesArea()
|
||||
.selectAll<SVGGElement | null, ChartGroup<DataType>>('.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 = <DataType extends Record<string, unknown>>({
|
||||
.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 = <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 +588,13 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</div>
|
||||
)}
|
||||
<D3MonitoringGroupsEditor
|
||||
groups={datasets}
|
||||
visible={settingsVisible}
|
||||
onChange={onGroupsChange}
|
||||
onCancel={() => setSettingsVisible(false)}
|
||||
onReset={onGroupsReset}
|
||||
/>
|
||||
</div>
|
||||
</LoaderPortal>
|
||||
)
|
||||
|
170
src/components/d3/D3MonitoringGroupsEditor.tsx
Normal file
170
src/components/d3/D3MonitoringGroupsEditor.tsx
Normal file
@ -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<DataType> = {
|
||||
visible?: boolean
|
||||
groups: ExtendedChartDataset<DataType>[][]
|
||||
onChange: (value: ExtendedChartDataset<DataType>[][]) => void
|
||||
onCancel: () => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
const getChartLabel = <DataType,>(chart: ExtendedChartDataset<DataType>) => (
|
||||
<Tooltip title={chart.label}>
|
||||
{getChartIcon(chart)} {chart.label}
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
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 = <DataType,>({
|
||||
visible,
|
||||
groups: oldGroups,
|
||||
onChange,
|
||||
onCancel,
|
||||
onReset,
|
||||
}: 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: {
|
||||
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<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={800}
|
||||
visible={visible}
|
||||
title={'Настройка групп графиков'}
|
||||
onCancel={onCancel}
|
||||
footer={(
|
||||
<>
|
||||
<Popconfirm title={'Вы уверены что хотите сбросить настройки графиков?'} onConfirm={onReset}>
|
||||
<Button icon={<UndoOutlined />}>Сброс</Button>
|
||||
</Popconfirm>
|
||||
<Button onClick={onCancel}>Отмена</Button>
|
||||
<Button type={'primary'} onClick={onModalOk}>Сохранить изменения</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'stretch', height: 250 }}>
|
||||
<div style={{ width: '35%' }}>
|
||||
<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' }} />
|
||||
<div style={divStyle}>
|
||||
{selectedGroup && selectedChart ? (
|
||||
<D3MonitoringChartEditor<DataType> group={selectedGroup} chart={selectedChart} onChange={onChartChange} />
|
||||
) : (
|
||||
<Empty description={'Выберите график для редактирования'} />
|
||||
)}
|
||||
</div>
|
||||
</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}
|
||||
>
|
||||
|
@ -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 = <DataType,>({
|
||||
if (!unsubscribe() && isDev())
|
||||
console.warn('Не удалось отвязать эвент')
|
||||
}
|
||||
}, [subscribe])
|
||||
}, [subscribe, fixed, mouseState.visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!zone || !getXLine) return
|
||||
|
@ -110,7 +110,7 @@ function _D3Tooltip<DataType extends Record<string, unknown>>({
|
||||
if (!unsubscribe() && isDev())
|
||||
console.warn('Не удалось отвязать эвент')
|
||||
}
|
||||
}, [visible])
|
||||
}, [subscribe, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!tooltipRef.current || !zoneRect || fixed) return
|
||||
|
@ -62,7 +62,7 @@ export type BaseChartDataset<DataType> = {
|
||||
/** Параметры штриховки графика */
|
||||
dash?: string | number | [string | number, string | number]
|
||||
/** Привязка домена к домену другого графика */
|
||||
bindDomainFrom?: string | number
|
||||
linkedTo?: string | number
|
||||
}
|
||||
|
||||
export type LineChartDataset = {
|
||||
|
@ -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<any|void>
|
||||
|
||||
const parseApiEror = (err: unknown, actionName?: string) => {
|
||||
|
@ -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 (
|
||||
<svg
|
||||
@ -59,9 +59,9 @@ export const WellIcon = React.memo(({ width, height, state, online, colors, ...o
|
||||
</g>
|
||||
{online && ( // Полоски, показывающие наличие свежей телеметрии
|
||||
<g stroke={colors.online}>
|
||||
<path d="m18.4 0.0662a2 2 0 0 1 0.141 1.7 2 2 0 0 1-1.22 1.19"/>
|
||||
<path d="m19.5 0.0402a3 3 0 0 1-1.79 3.85"/>
|
||||
<path d="m20.5 0.031a4 4 0 0 1-2.5 4.79"/>
|
||||
<path d={'m18.4 0.0662a2 2 0 0 1 0.141 1.7 2 2 0 0 1-1.22 1.19'} />
|
||||
<path d={'m19.5 0.0402a3 3 0 0 1-1.79 3.85'} />
|
||||
<path d={'m20.5 0.031a4 4 0 0 1-2.5 4.79'} />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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 (
|
||||
<>
|
||||
<LoaderPortal show={showLoader}>
|
||||
<Flex style={{margin: '8px 8px 0'}}>
|
||||
<div>
|
||||
Начальная дата:
|
||||
@ -243,11 +243,11 @@ const Archive = memo(() => {
|
||||
</div>
|
||||
<CopyUrlButton style={{ marginLeft: '1rem' }} />
|
||||
</Flex>
|
||||
<LoaderPortal show={showLoader}>
|
||||
<D3MonitoringCharts
|
||||
datasetGroups={chartGroups}
|
||||
data={chartData}
|
||||
yDomain={domain}
|
||||
yAxis={yAxis}
|
||||
yTicks={{
|
||||
visible: true,
|
||||
format: (d) => formatDate(d)
|
||||
@ -263,7 +263,6 @@ const Archive = memo(() => {
|
||||
onWheel={onGraphWheel}
|
||||
/>
|
||||
</LoaderPortal>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
|
@ -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] }]) => (
|
||||
<div className={'detected-operations-tooltip'}>
|
||||
<span className={'tooltip-label'}>{data.operationCategory?.name}</span>
|
||||
<span className={'tooltip-label'}>{formatDate(data.dateStart, undefined, 'DD.MM.YYYY')}</span>
|
||||
@ -21,12 +21,12 @@ const makeTooltipRender = (category) => ([{ data: [data] }]) => (
|
||||
<GridItem row={2} col={3}>{displayNumber(data.depthEnd)} м</GridItem>
|
||||
</Grid>
|
||||
<Grid style={{ marginTop: 0 }}>
|
||||
<GridItem row={1} col={1}>{category?.name ?? 'Ключ'}:</GridItem>
|
||||
<GridItem row={1} col={1}>{category?.keyValueName ?? 'Ключ'}:</GridItem>
|
||||
<GridItem row={1} col={2}>{displayNumber(data.value)}</GridItem>
|
||||
<GridItem row={1} col={3}>{category?.unit ?? '----'}</GridItem>
|
||||
<GridItem row={1} col={3}>{category?.keyValueUnits ?? '----'}</GridItem>
|
||||
<GridItem row={2} col={1}>Цель:</GridItem>
|
||||
<GridItem row={2} col={2}>{displayNumber(data.operationValue?.targetValue)}</GridItem>
|
||||
<GridItem row={2} col={3}>{category?.unit ?? '----'}</GridItem>
|
||||
<GridItem row={2} col={3}>{category?.keyValueUnits ?? '----'}</GridItem>
|
||||
</Grid>
|
||||
</div>
|
||||
)
|
||||
|
@ -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,
|
||||
'Не удалось загрзуить категории операций'
|
||||
|
@ -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(() => {
|
||||
Интервал:
|
||||
<PeriodPicker onChange={setChartInterval} />
|
||||
</div>
|
||||
<Button onClick={() => chartMethods?.setSettingsVisible(true)}>Настроить графики</Button>
|
||||
<div style={{ marginLeft: '1rem' }}>
|
||||
Статус:
|
||||
<Select value={wellData.idState ?? 0} onChange={onStatusChanged} disabled={!hasPermission('Well.edit')}>
|
||||
@ -294,9 +295,12 @@ const TelemetryView = memo(() => {
|
||||
</GridItem>
|
||||
<GridItem col={2} row={2} colSpan={8} rowSpan={2}>
|
||||
<D3MonitoringCharts
|
||||
methods={setChartMethods}
|
||||
chartName={'monitoring'}
|
||||
datasetGroups={chartGroups}
|
||||
data={filteredData}
|
||||
yDomain={domain}
|
||||
yAxis={yAxis}
|
||||
yTicks={{
|
||||
visible: true,
|
||||
format: (d) => formatDate(d, 'YYYY-MM-DD')
|
||||
|
@ -8,8 +8,16 @@ import { useIdWell } from '@asb/context'
|
||||
import LoaderPortal from '@components/LoaderPortal'
|
||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||
import { DetectedOperationService } from '@api'
|
||||
import { unique } from '@utils/filters'
|
||||
import { formatDate } from '@utils'
|
||||
|
||||
import { makeTooltipRender } from '@pages/Telemetry/Operations/OperationsChart'
|
||||
import { makeGetColor } from '.'
|
||||
|
||||
import '@styles/d3.less'
|
||||
import '@styles/tvd.less'
|
||||
import '@styles/detected_operations.less'
|
||||
|
||||
const defaultOffset = { left: 40, right: 20, top: 20, bottom: 20 }
|
||||
const zeroDate = moment('2000-01-01 00:00:00')
|
||||
|
||||
@ -39,11 +47,15 @@ export const TLChart = memo(({
|
||||
backgroundColor = '#0000',
|
||||
barHeight = 15,
|
||||
offset = defaultOffset,
|
||||
color,
|
||||
tooltipSize = { width: 200, height: 220 },
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [svgRef, setSvgRef] = useState()
|
||||
const [data, setData] = useState()
|
||||
const [selected, setSelected] = useState()
|
||||
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 })
|
||||
|
||||
const getColor = useMemo(() => makeGetColor(data?.map((row) => row.idCategory).filter(unique)), [data])
|
||||
|
||||
const [rootRef, { width, height }] = useElementSize()
|
||||
|
||||
@ -119,23 +131,54 @@ export const TLChart = memo(({
|
||||
.attr('y', (d) => yAxis(moment(d.startTime).startOf('day')) - barHeight / 2)
|
||||
.attr('width', (d) => xAxis(d.endTime) - xAxis(d.startTime))
|
||||
.attr('height', barHeight)
|
||||
.attr('fill', (d) => color ? color(d.idCategory) : '#0008')
|
||||
}, [svgRef, xAxis, yAxis, data, color])
|
||||
.attr('fill', (d) => getColor(d.idCategory))
|
||||
.on('mouseover', (e, data) => {
|
||||
d3.select(svgRef).select('.chart-area')
|
||||
.selectAll('rect')
|
||||
.filter((d) => d.idCategory === data.idCategory)
|
||||
.attr('stroke-width', '2px')
|
||||
setTooltipPos(e.target.getBoundingClientRect())
|
||||
setSelected(data)
|
||||
})
|
||||
.on('mouseout', (e, data) => {
|
||||
d3.select(svgRef).select('.chart-area')
|
||||
.selectAll('rect')
|
||||
.filter((d) => d.idCategory === data.idCategory)
|
||||
.attr('stroke-width', '0')
|
||||
setSelected(null)
|
||||
})
|
||||
}, [svgRef, xAxis, yAxis, data, getColor])
|
||||
|
||||
const tooltipStyle = useMemo(() => {
|
||||
return {
|
||||
...tooltipSize,
|
||||
left: Math.min(tooltipPos.x, screen.width) - tooltipSize.width - 8,
|
||||
top: Math.min(tooltipPos.y - tooltipSize.height / 2 + barHeight / 2, screen.height - tooltipSize.height - 160),
|
||||
}
|
||||
}, [tooltipPos, tooltipSize, barHeight])
|
||||
|
||||
return (
|
||||
<div className={'tvd-right'} ref={rootRef}>
|
||||
<LoaderPortal show={isLoading} style={{ width: '100%', flexGrow: 1 }}>
|
||||
<LoaderPortal show={isLoading} className={'asb-d3-chart'}>
|
||||
{selected && (
|
||||
<div className={'tl-op-tooltip'} style={tooltipStyle}>
|
||||
<div className={'tooltip right'}>
|
||||
{makeTooltipRender(selected.operationCategory)([{ data: [selected] }])}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data ? (
|
||||
<svg ref={setSvgRef} width={'100%'} height={'100%'}>
|
||||
<svg className={'tl-op-chart'} ref={setSvgRef} width={'100%'} height={'100%'}>
|
||||
<g className={'axis x'} transform={`translate(${offset.left}, ${offset.top})`} />
|
||||
<g className={'axis y'} transform={`translate(${offset.left}, ${offset.top + barHeight})`} />
|
||||
<g className={'chart-area'} transform={`translate(${offset.left}, ${offset.top + barHeight})`} stroke={'none'} />
|
||||
<g className={'chart-area'} transform={`translate(${offset.left}, ${offset.top + barHeight})`} stroke={'red'} strokeWidth={'0'} />
|
||||
<rect
|
||||
x={offset.left}
|
||||
y={offset.top}
|
||||
width={Math.max(width - offset.left - offset.right, 0)}
|
||||
height={Math.max(height - offset.top - offset.bottom, 0)}
|
||||
fill={backgroundColor}
|
||||
pointerEvents={'none'}
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useElementSize } from 'usehooks-ts'
|
||||
import { Empty } from 'antd'
|
||||
import * as d3 from 'd3'
|
||||
@ -8,6 +8,11 @@ import { makeColumn, makeNumericColumn, makeTextColumn, Table } from '@component
|
||||
import LoaderPortal from '@components/LoaderPortal'
|
||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||
import { DetectedOperationService } from '@api'
|
||||
import { unique } from '@utils/filters'
|
||||
|
||||
import { makeGetColor } from '.'
|
||||
|
||||
import '@styles/tvd.less'
|
||||
|
||||
const tableColumns = [
|
||||
makeColumn('Цвет', 'color', { width: 50, render: (d) => (
|
||||
@ -19,10 +24,11 @@ const tableColumns = [
|
||||
makeNumericColumn('Процент, %', 'percent', undefined, undefined, (d) => d ? d.toFixed(2) : '---', 100)
|
||||
]
|
||||
|
||||
export const TLPie = memo(({ color }) => {
|
||||
export const TLPie = memo(() => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [svgRef, setSvgRef] = useState()
|
||||
const [stats, setStats] = useState([])
|
||||
const [selected, setSelected] = useState([])
|
||||
|
||||
const [rootRef, { width, height }] = useElementSize()
|
||||
|
||||
@ -30,20 +36,50 @@ export const TLPie = memo(({ color }) => {
|
||||
|
||||
const pie = useMemo(() => d3.pie().value((d) => d.minutesTotal), [])
|
||||
|
||||
const getColor = useMemo(() => makeGetColor(stats?.map((row) => row.idCategory).filter(unique)), [stats])
|
||||
|
||||
const tableData = useMemo(() => {
|
||||
if (!stats) return null
|
||||
const totalTime = stats.reduce((out, stat) => out + stat.minutesTotal, 0)
|
||||
return stats.map((stat) => ({
|
||||
...stat,
|
||||
color: color(stat.idCategory),
|
||||
color: getColor(stat.idCategory),
|
||||
percent: stat.minutesTotal / totalTime * 100,
|
||||
}))
|
||||
}, [stats, color])
|
||||
}, [stats, getColor])
|
||||
|
||||
const data = useMemo(() => tableData ? pie(tableData) : null, [tableData])
|
||||
|
||||
const radius = useMemo(() => Math.min(width, height) / 2, [width, height])
|
||||
|
||||
const onRow = useCallback((record) => {
|
||||
const out = {
|
||||
onMouseEnter: () => {
|
||||
d3.selectAll('.tl-pie-part')
|
||||
.filter((d) => d.data.idCategory === record.idCategory)
|
||||
.attr('transform', 'scale(1.05)')
|
||||
},
|
||||
onMouseLeave: () => {
|
||||
d3.selectAll('.tl-pie-part')
|
||||
.filter((d) => d.data.idCategory === record.idCategory)
|
||||
.attr('transform', 'scale(1)')
|
||||
}
|
||||
}
|
||||
if (record.idCategory === selected)
|
||||
out.style = { background: '#FAFAFA' }
|
||||
return out
|
||||
}, [selected])
|
||||
|
||||
const onPieOver = useCallback(function (e, d) {
|
||||
setSelected(d.data.idCategory)
|
||||
d3.select(this).attr('transform', 'scale(1.05)')
|
||||
}, [])
|
||||
|
||||
const onPieOut = useCallback(function (e, d) {
|
||||
setSelected(null)
|
||||
d3.select(this).attr('transform', 'scale(1)')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
invokeWebApiWrapperAsync(
|
||||
async () => {
|
||||
@ -66,9 +102,13 @@ export const TLPie = memo(({ color }) => {
|
||||
const newSlices = slices.enter().append('path')
|
||||
|
||||
slices.merge(newSlices)
|
||||
.attr('class', 'tl-pie-part')
|
||||
.attr('d', d3.arc().innerRadius(radius * 0.4).outerRadius(radius * 0.8))
|
||||
.attr('fill', (d) => color ? color(d.data.idCategory) : '#0008')
|
||||
}, [svgRef, data, color, radius])
|
||||
.attr('fill', (d) => d.data.color)
|
||||
.attr('data-id', (d) => d.idCategory)
|
||||
.on('mouseover', onPieOver)
|
||||
.on('mouseout', onPieOut)
|
||||
}, [svgRef, data, radius, onPieOver, onPieOut])
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
@ -81,7 +121,8 @@ export const TLPie = memo(({ color }) => {
|
||||
.data(data, (d) => d.data.category)
|
||||
|
||||
lines.exit().remove()
|
||||
const newLines = lines.enter().append('polyline')
|
||||
const newLines = lines.enter()
|
||||
.append('polyline')
|
||||
|
||||
const abovePi = (d) => (d.startAngle + d.endAngle) / 2 < Math.PI
|
||||
|
||||
@ -118,8 +159,8 @@ export const TLPie = memo(({ color }) => {
|
||||
<div className={'tvd-right'}>
|
||||
<LoaderPortal show={isLoading} style={{ width: '100%', flexGrow: 1 }}>
|
||||
{data ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'stretch', justifyContent: 'space-between', height: '100%' }}>
|
||||
<div ref={rootRef} style={{ flexGrow: 1 }}>
|
||||
<div className={'tl-pie'}>
|
||||
<div className={'tl-pie-chart'} ref={rootRef}>
|
||||
<svg ref={setSvgRef} width={'100%'} height={'100%'}>
|
||||
<g transform={`translate(${width / 2}, ${height / 2})`}>
|
||||
<g className={'slices'} stroke={'#0005'} />
|
||||
@ -134,10 +175,11 @@ export const TLPie = memo(({ color }) => {
|
||||
dataSource={tableData}
|
||||
scroll={{ y: '20vh', x: true }}
|
||||
pagination={false}
|
||||
onRow={onRow}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<div className={'empty-wrapper'}>
|
||||
<Empty />
|
||||
</div>
|
||||
)}
|
||||
|
@ -9,7 +9,6 @@ import { D3Chart } from '@components/d3'
|
||||
import LoaderPortal from '@components/LoaderPortal'
|
||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||
import { formatDate, fractionalSum, wrapPrivateComponent, getOperations } from '@utils'
|
||||
import { DetectedOperationService } from '@api'
|
||||
|
||||
import TLPie from './TLPie'
|
||||
import TLChart from './TLChart'
|
||||
@ -21,12 +20,19 @@ import AdditionalTables from './AdditionalTables'
|
||||
import '@styles/index.css'
|
||||
import '@styles/tvd.less'
|
||||
|
||||
const colorArray = [
|
||||
export const makeGetColor = (types) => (type) => {
|
||||
if (!type) return '#0008'
|
||||
const raw = [
|
||||
'#1abc9c', '#16a085', '#2ecc71', '#27ae60', '#3498db',
|
||||
'#2980b9', '#9b59b6', '#8e44ad', '#34495e', '#2c3e50',
|
||||
'#f1c40f', '#f39c12', '#e67e22', '#d35400', '#e74c3c',
|
||||
'#c0392b', '#ecf0f1', '#bdc3c7', '#95a5a6', '#7f8c8d',
|
||||
]
|
||||
]
|
||||
|
||||
if (!types || types.length <= 0) return raw[type]
|
||||
const i = types.indexOf(type)
|
||||
return i < 0 ? raw[type] : raw[i]
|
||||
}
|
||||
|
||||
const Item = ({ label, children, ...other }) => (<div className={'tvd-input-group'} {...other}><span>{label}: </span>{children}</div>)
|
||||
|
||||
@ -149,7 +155,6 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [pointsEnabled, setPointsEnabled] = useState(true)
|
||||
const [selectedTab, setSelectedTab] = useState('Скрыть')
|
||||
const [color, setColor] = useState()
|
||||
|
||||
const idWellContext = useIdWell()
|
||||
const idWell = useMemo(() => wellId ?? idWellContext, [wellId, idWellContext])
|
||||
@ -189,20 +194,6 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
|
||||
)
|
||||
}, [idWell])
|
||||
|
||||
useEffect(() => {
|
||||
invokeWebApiWrapperAsync(
|
||||
async () => {
|
||||
const cats = await DetectedOperationService.getCategories()
|
||||
const color = d3.scaleOrdinal()
|
||||
.domain(cats.map((cat) => cat.id))
|
||||
.range(colorArray)
|
||||
setColor(() => color)
|
||||
},
|
||||
undefined,
|
||||
'Не удалось получить список типов операций'
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={'container tvd-page'} {...other}>
|
||||
<div className={'tvd-top'}>
|
||||
@ -250,8 +241,8 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
|
||||
/>
|
||||
</div>
|
||||
{selectedTab === 'НПВ' && <NptTable operations={operations?.fact} />}
|
||||
{selectedTab === 'ЕСО' && <TLChart color={color} />}
|
||||
{selectedTab === 'Статистика' && <TLPie color={color} />}
|
||||
{selectedTab === 'ЕСО' && <TLChart />}
|
||||
{selectedTab === 'Статистика' && <TLPie />}
|
||||
</div>
|
||||
</LoaderPortal>
|
||||
</div>
|
||||
|
21
src/styles/components/color_picker.less
Normal file
21
src/styles/components/color_picker.less
Normal file
@ -0,0 +1,21 @@
|
||||
.asb-color-picker-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
|
||||
& > .asb-color-picker-sliders {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.asb-color-picker-preview {
|
||||
border: 1px solid black;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
@ -41,7 +41,7 @@
|
||||
|
||||
|
||||
&.bottom {
|
||||
margin-top: 0;
|
||||
margin-bottom: @arrow-size;
|
||||
|
||||
&::after {
|
||||
border-top-color: @bg-color;
|
||||
@ -51,6 +51,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.left {
|
||||
margin-left: @arrow-size;
|
||||
&::after {
|
||||
border-right-color: @bg-color;
|
||||
top: 50%;
|
||||
right: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.right {
|
||||
margin-right: @arrow-size;
|
||||
&::after {
|
||||
border-left-color: @bg-color;
|
||||
top: 50%;
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
& .tooltip-content {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
@ -78,3 +78,41 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-wrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tl-op-tooltip {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tl-op-chart {
|
||||
& .chart-area > rect {
|
||||
transition: stroke-width .25s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.tl-pie {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
|
||||
& .tl-pie-chart {
|
||||
flex-grow: 1;
|
||||
|
||||
& .lines {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tl-pie-part {
|
||||
transition: transform .1s ease-in-out;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user