* Мониторинг переписан на flex

* Блок текущих значений перемещён наверх и переписан
* Выделена строка управления графиком
* Удалена мнемосхема
This commit is contained in:
Александр Сироткин 2022-11-16 11:58:03 +05:00
parent b2c34d07a9
commit 11a632c246
9 changed files with 346 additions and 306 deletions

View File

@ -1,101 +0,0 @@
import moment from 'moment'
import { useState, useEffect, memo, ReactNode } from 'react'
import {CaretUpOutlined, CaretDownOutlined, CaretRightOutlined} from '@ant-design/icons'
import '@styles/display.less'
export const formatNumber = (value?: unknown, format?: number) =>
Number.isInteger(format) && Number.isFinite(value)
? Number(value).toFixed(format)
: Number(value).toPrecision(4)
const iconStyle = { color:'#0008' }
const displayValueStyle = { display: 'flex', flexGrow: 1 }
export type ValueDisplayProps = {
prefix?: ReactNode
suffix?: ReactNode
format?: number | string | ((arg: string) => ReactNode)
isArrowVisible?: boolean
enumeration?: Record<string, string>
value: string
}
export type DisplayProps = ValueDisplayProps & {
className?: string
label?: ReactNode
}
export const ValueDisplay = memo<ValueDisplayProps>(({ prefix, value, suffix, isArrowVisible, format, enumeration }) => {
const [val, setVal] = useState<ReactNode>('---')
const [arrowState, setArrowState] = useState({
preVal: NaN,
preTimestamp: Date.now(),
direction: 0,
})
useEffect(() => {
setVal((preVal) => {
if ((value ?? '-') === '-' || value === '--') return '---'
if (typeof format === 'function') return format(enumeration?.[value] ?? value)
if (enumeration?.[value]) return enumeration[value]
if (Number.isFinite(+value)) {
if (isArrowVisible && (arrowState.preTimestamp + 1000 < Date.now())) {
let direction = 0
if (+value > arrowState.preVal)
direction = 1
if (+value < arrowState.preVal)
direction = -1
setArrowState({
preVal: +value,
preTimestamp: Date.now(),
direction: direction,
})
}
return formatNumber(value, Number(format))
}
if (value.length > 4) {
const valueDate = moment(value)
if (valueDate.isValid())
return valueDate.format(String(format))
}
return value
})
},[value, isArrowVisible, arrowState, format, enumeration])
let arrow = null
if(isArrowVisible)
switch (arrowState.direction){
case 0:
arrow = <CaretRightOutlined style={iconStyle}/>
break
case 1:
arrow = <CaretUpOutlined style={iconStyle}/>
break
case -1:
arrow = <CaretDownOutlined style={iconStyle}/>
break
default:
break
}
return(
<span className={'display_value'}>
{prefix} {val} {suffix}{arrow}
</span>
)
})
export const Display = memo<DisplayProps>(({ className, label, ...other })=> (
<div className={className}>
<div className={'display_label'}>{label}</div>
<div style={displayValueStyle}>
<ValueDisplay {...other}/>
</div>
</div>
))

View File

@ -73,8 +73,8 @@ export type ChartGroup<DataType extends BaseDataType> = {
}
const defaultOffsets: ChartOffset = {
top: 10,
bottom: 10,
top: 0,
bottom: 0,
left: 100,
right: 20,
}

View File

@ -1,58 +0,0 @@
import moment from 'moment'
import { memo } from 'react'
import { Tooltip, Typography } from 'antd'
import { Display } from '@components/Display'
import RigMnemo from './RigMnemo'
const getTimeFormat = (value) => {
const date = moment(value)
return (
<Tooltip title={`Время последних данных: ${date.format('DD.MM.YYYY HH:mm:ss')}`}>
{date.isSame(new Date(), 'day') || (
<Typography.Text disabled style={{ fontSize: '12px', marginRight: '5px' }}>{date.format('DD.MM.YYYY')}</Typography.Text>
)}
{date.format('HH:mm:ss')}
</Tooltip>
)
}
const params = [
{ label: 'Рот., об/мин', accessorName: 'rotorSpeed', isArrowVisible: true },
{ label: 'Долото, м', accessorName: 'bitDepth', isArrowVisible: true, format: 2 },
{ label: 'Забой, м', accessorName: 'wellDepth', isArrowVisible: true, format: 2 },
{ label: 'Расход, м³/ч', accessorName: 'flow', isArrowVisible: true },
{ label: 'Расход х.х., м³/ч', accessorName: 'flowIdle', isArrowVisible: true },
{ label: 'Время', accessorName: 'date', format: getTimeFormat },
{ label: 'MSE, %', accessorName: 'mse', format: 2 },
]
export const CustomColumn = memo(({ data }) => {
const dataLast = data[data.length - 1]
params.forEach(param => param.value = dataLast?.[param.accessorName] ?? '-')
return (
<>
{params.map(param => (
<Display
className={'border_small display_flex_container'}
key={param.label}
label={param.label}
value={param.value}
suffix={param.units}
format={param.format}
isArrowVisible={param.isArrowVisible}
/>
))}
<RigMnemo
wellDepth={dataLast?.wellDepth ?? Number.NaN}
bitPosition={dataLast?.bitDepth ?? Number.NaN}
blockPosition={dataLast?.blockPosition ?? Number.NaN}
/>
</>
)
})
export default CustomColumn

View File

@ -1,24 +0,0 @@
import { memo } from 'react'
import { Display } from '@components/Display'
const modeNames = {
0: 'Ручной',
1: 'Бурение в роторе',
2: 'Проработка',
3: 'Бурение в слайде',
4: 'Спуск СПО',
5: 'Подъем СПО',
6: 'Подъем с проработкой',
10: 'БЛОКИРОВКА',
}
export const ModeDisplay = memo(({ data }) => (
<Display
className={'border_small display_flex_container user_card'}
label={'Режим:'}
value={data?.[data?.length - 1]?.mode}
enumeration={modeNames}
/>
))

View File

@ -0,0 +1,107 @@
import { isValidElement, memo, useEffect, useMemo, useState } from 'react'
import { CaretUpOutlined, CaretDownOutlined, CaretRightOutlined } from '@ant-design/icons'
import { Tooltip, Typography } from 'antd'
import moment from 'moment'
import { formatDate, isRawDate } from '@utils'
import '@styles/components/data_summary.less'
export const parseValue = (value, formatter) => {
if (!value || String(value).trim().length <= 0) return '---'
if (typeof formatter === 'function') return formatter(value)
if (isRawDate(value)) return formatDate(value)
const v = +value
if (Number.isFinite(v))
return Number.isInteger(formatter) ? v.toFixed(formatter) : v.toPrecision(4)
return value
}
export const DashboardDisplay = memo(({ label, title, unit, iconRenderer, value, format }) => {
const [icon, setIcon] = useState(null)
const val = useMemo(() => parseValue(value, format), [value, format])
useEffect(() => setIcon((prev) => iconRenderer?.(value, prev)), [value])
return (
<div className={'dashboard-display'}>
<div className={'display-label'}>
{title ? (
<Tooltip title={title}>{label}</Tooltip>
) : (
<span>{label}</span>
)}
<span>{unit}</span>
</div>
<div className={'display-value'}>
<span>{val}</span>
{isValidElement(icon) ? icon : icon?.value}
</div>
</div>
)
})
const getTimeFormat = (value) => {
const date = moment(value)
return (
<Tooltip title={`Время последних данных: ${date.format('DD.MM.YYYY HH:mm:ss')}`}>
{date.isSame(new Date(), 'day') || (
<Typography.Text disabled style={{ fontSize: '12px', marginRight: '5px' }}>{date.format('DD.MM.YYYY')}</Typography.Text>
)}
{date.format('HH:mm:ss')}
</Tooltip>
)
}
const iconRenderer = (value, prev) => {
if (!Number.isFinite(+value)) return null
if (prev?.prevDate + 1000 >= Date.now()) return prev
const val = +value
let Component = CaretRightOutlined
if ((prev?.prev ?? null) && val !== prev.prev)
Component = val > prev.prev ? CaretUpOutlined : CaretDownOutlined
return {
prev: val,
prevDate: Date.now(),
value: <Component style={{ color:'#0008' }} />,
}
}
const modeNames = {
0: 'Ручной',
1: 'Бурение в роторе',
2: 'Проработка',
3: 'Бурение в слайде',
4: 'Спуск СПО',
5: 'Подъем СПО',
6: 'Подъем с проработкой',
10: 'БЛОКИРОВКА',
}
const params = [
{ label: 'Режим', accessorName: 'mode', format: (value) => modeNames[value] || '---' },
{ label: 'Пользователь', accessorName: 'user', title: 'Пользователь панели оператора' },
{ label: 'Рот.', unit: 'об/мин', accessorName: 'rotorSpeed', iconRenderer },
{ label: 'Долото', unit: 'м', accessorName: 'bitDepth', iconRenderer, format: 2 },
{ label: 'Забой', unit: 'м', accessorName: 'wellDepth', iconRenderer, format: 2 },
{ label: 'Расход', unit: 'м³/ч', accessorName: 'flow', iconRenderer },
{ label: 'Расход х.х.', unit: 'м³/ч', accessorName: 'flowIdle', iconRenderer },
{ label: 'Время', accessorName: 'date', format: getTimeFormat },
{ label: 'MSE', unit: '%', accessorName: 'mse', format: 2 },
]
export const TelemetrySummary = memo(({ data }) => {
return (
<div className={'data-summary'}>
{params.map((param, i) => (
<DashboardDisplay key={i} {...param} value={data?.[param.accessorName]} />
))}
</div>
)
})
export default TelemetrySummary

View File

@ -1,12 +0,0 @@
import { Tooltip } from 'antd'
import { Display } from '@components/Display'
export const UserOfWell = ({ data }) => (
<Display
className={'border_small display_flex_container user_card'}
label={<Tooltip title={'Пользователь панели оператора'}>Пользователь</Tooltip>}
value={data[data.length - 1]?.user}
/>
)
export default UserOfWell

View File

@ -1,15 +1,15 @@
import { useState, useEffect, useCallback, memo, useMemo } from 'react'
import { BehaviorSubject, buffer, throttleTime } from 'rxjs'
import { useSearchParams } from 'react-router-dom'
import { Button, Select } from 'antd'
import { useWell } from '@asb/context'
import { makeDateSorter } from '@components/Table'
import { DatePickerWrapper, makeDateSorter } from '@components/Table'
import { D3MonitoringCharts } from '@components/d3/monitoring'
import LoaderPortal from '@components/LoaderPortal'
import { Grid, GridItem } from '@components/Grid'
import { invokeWebApiWrapperAsync } from '@components/factory'
import { PeriodPicker, defaultPeriod } from '@components/selectors/PeriodPicker'
import { formatDate, hasPermission, withPermissions } from '@utils'
import { formatDate, hasPermission, isRawDate, range, withPermissions } from '@utils'
import { Subscribe } from '@services/signalr'
import {
DrillFlowChartService,
@ -20,10 +20,8 @@ import {
import { makeChartGroups, yAxis } from './dataset'
import ActiveMessagesOnline from './ActiveMessagesOnline'
import TelemetrySummary from './TelemetrySummary'
import WirelineRunOut from './WirelineRunOut'
import { CustomColumn } from './CustomColumn'
import { ModeDisplay } from './ModeDisplay'
import { UserOfWell } from './UserOfWells'
import { Setpoints } from './Setpoints'
import { cursorRender } from './cursorRender'
@ -37,6 +35,14 @@ import '@styles/message.less'
const { Option } = Select
const chartProps = {
yAxis,
chartName: 'monitoring',
yTicks: { visible: true, format: (d) => formatDate(d, 'YYYY-MM-DD') },
plugins: { menu: { enabled: false }, cursor: { render: cursorRender } },
style: { flexGrow: 1, height: 'auto', width: 'auto' },
}
const getLast = (data) => Array.isArray(data) ? data.at(-1) : data
const isMseEnabled = (dataSaub) => (getLast(dataSaub)?.mseState && 2) > 0
@ -54,6 +60,7 @@ export const normalizeData = (data) => data?.map(item => ({
})) ?? []
const dateSorter = makeDateSorter('date')
const defaultDate = () => Date.now() - defaultPeriod * 1000
const makeSubjectSubsription = (subject$, handler) => {
const subscribtion = subject$.pipe(
@ -64,20 +71,49 @@ const makeSubjectSubsription = (subject$, handler) => {
}
const TelemetryView = memo(() => {
const [well, updateWell] = useWell()
const [searchParams, setSearchParams] = useSearchParams()
const [currentWellId, setCurrentWellId] = useState(null)
const [dataSaub, setDataSaub] = useState([])
const [dataSpin, setDataSpin] = useState([])
const [chartInterval, setChartInterval] = useState(defaultPeriod)
const [showLoader, setShowLoader] = useState(false)
const [flowChartData, setFlowChartData] = useState([])
const [rop, setRop] = useState(null)
const [domain, setDomain] = useState({})
const [chartMethods, setChartMethods] = useState()
const [well, updateWell] = useWell()
const [chartInterval, setChartInterval] = useState(defaultPeriod)
const [startDate, setStartDate] = useState(defaultDate)
const [dateLimit, setDateLimit] = useState({ from: 0, to: new Date() })
const saubSubject$ = useMemo(() => new BehaviorSubject(), [])
const spinSubject$ = useMemo(() => new BehaviorSubject(), [])
const [archiveMode, setArchiveMode] = useState(false)
const onStatusChanged = useCallback((value) => updateWell({ idState: value }), [well])
const isDateDisabled = useCallback((date) => {
if (!date) return false
const dt = new Date(date).setHours(0, 0, 0, 0)
return dt < dateLimit.from || dt > +dateLimit.to - chartInterval
}, [dateLimit, chartInterval])
const isDateTimeDisabled = useCallback((date) => ({
disabledHours: () => range(24).filter(h => {
if (!date) return false
const dt = +new Date(date).setHours(h)
return dt < dateLimit.from || dt > +dateLimit.to - chartInterval
}),
disabledMinutes: () => range(60).filter(m => {
if (!date) return false
const dt = +new Date(date).setMinutes(m)
return dt < dateLimit.from || dt > +dateLimit.to - chartInterval
}),
disabledSeconds: () => range(60).filter(s => {
if (!date) return false
const dt = +new Date(date).setSeconds(s)
return dt < dateLimit.from || dt > +dateLimit.to - chartInterval
})
}), [dateLimit, chartInterval])
const handleDataSaub = useCallback((data, replace = false) => {
setDataSaub((prev) => {
@ -92,7 +128,21 @@ const TelemetryView = memo(() => {
const handleDataSpin = useCallback((data) => data && setDataSpin((prev) => [...prev, ...data]), [])
const onStatusChanged = useCallback((value) => updateWell({ idState: value }), [well])
const onWheel = useCallback((value) => {
if (!archiveMode && value.deltaY < 0) {
setArchiveMode(true)
// load data
} else {
// move
}
}, [archiveMode])
const spinLast = useMemo(() => dataSpin.at(-1), [dataSpin])
const saubLast = useMemo(() => dataSaub.at(-1), [dataSaub])
const summaryData = useMemo(() => ({ ...saubLast, ...rop }), [saubLast, rop])
const saubSubject$ = useMemo(() => new BehaviorSubject(), [])
const spinSubject$ = useMemo(() => new BehaviorSubject(), [])
const filteredData = useMemo(() => {
let i, j
@ -112,6 +162,32 @@ const TelemetryView = memo(() => {
const chartGroups = useMemo(() => makeChartGroups(flowChartData), [flowChartData])
useEffect(() => {
setArchiveMode(isRawDate(searchParams.get('start')))
const interval = parseInt(searchParams.get('range') || defaultPeriod)
const date = new Date(searchParams.get('start') || (Date.now() - interval))
setChartInterval(interval)
setStartDate(date)
}, [searchParams])
useEffect(() => {
if (archiveMode) return
const subscribtion = saubSubject$.pipe(
buffer(saubSubject$.pipe(throttleTime(700)))
).subscribe((data) => handleDataSaub(data.flat().filter(Boolean)))
return () => subscribtion.unsubscribe()
}, [saubSubject$, archiveMode])
useEffect(() => {
if (archiveMode) return
const subscribtion = spinSubject$.pipe(
buffer(spinSubject$.pipe(throttleTime(700)))
).subscribe((data) => handleDataSpin(data.flat().filter(Boolean)))
return () => subscribtion.unsubscribe()
}, [spinSubject$, archiveMode])
useEffect(() => makeSubjectSubsription(saubSubject$, handleDataSaub), [saubSubject$, handleDataSaub])
useEffect(() => makeSubjectSubsription(spinSubject$, handleDataSpin), [spinSubject$, handleDataSpin])
@ -148,6 +224,12 @@ const TelemetryView = memo(() => {
async () => {
const rop = await OperationStatService.getClusterRopStatByIdWell(well.id)
setRop(rop)
let dates = await TelemetryDataSaubService.getDataDatesRange(well.id)
dates = {
from: new Date(dates?.from ?? 0),
to: new Date(dates?.to ?? 0)
}
setDateLimit(dates)
},
setShowLoader,
`Не удалось загрузить данные`,
@ -156,74 +238,66 @@ const TelemetryView = memo(() => {
}, [well])
useEffect(() => {
if (dataSaub.length <= 0) return
const last = new Date(dataSaub.at(-1).date)
setDomain({
min: new Date(+last - chartInterval * 1000),
max: last
})
}, [dataSaub, chartInterval])
if (!saubLast || archiveMode) return
const last = new Date(saubLast.date)
const startDate = new Date(+last - chartInterval * 1000)
setStartDate(startDate)
setDomain({ min: startDate, max: last })
}, [archiveMode, saubLast, chartInterval])
return (
<LoaderPortal show={showLoader}>
<Grid className={'telemetry-view-page'} style={{ gridTemplateColumns: 'auto repeat(6, 1fr)' }}>
<GridItem col={'1'} row={'1'} colSpan={'8'} style={{ marginBottom: '0.5rem' }}>
<div className={'page-top'}>
<ModeDisplay data={dataSaub} />
<div>
Интервал:&nbsp;
<PeriodPicker onChange={setChartInterval} />
</div>
<Button onClick={() => chartMethods?.setSettingsVisible(true)}>Настроить графики</Button>
<div>
Статус:&nbsp;
<Select value={well.idState ?? 0} onChange={onStatusChanged} disabled={!hasPermission('Well.edit')}>
<Option value={0} disabled hidden>Неизвестно</Option>
<Option value={1}>В работе</Option>
<Option value={2}>Завершено</Option>
</Select>
</div>
<Setpoints />
<span style={{ flexGrow: 20 }}>&nbsp;</span>
<WirelineRunOut />
<div className={'icons'}>
<img src={isTorqueStabEnabled(dataSpin) ? MomentStabPicEnabled : MomentStabPicDisabled} alt={'TorqueMaster'} />
<img src={isSpinEnabled(dataSpin) ? SpinPicEnabled : SpinPicDisabled} alt={'SpinMaster'} />
<h2 style={{ marginBottom: 0, fontWeight: 'bold', color: isMseEnabled(dataSaub) ? 'green' : 'lightgrey' }}>MSE</h2>
</div>
<UserOfWell data={dataSaub} />
</div>
</GridItem>
<GridItem col={'1'} row={'2'} rowSpan={'3'} style={{ minWidth: '260px', width: '0.142fr' }}>
<CustomColumn data={dataSaub} />
</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')
}}
plugins={{
menu: { enabled: false },
cursor: {
render: cursorRender,
},
}}
height={'70vh'}
/>
</GridItem>
<GridItem col={'2'} row={'3'} colSpan={'7'}>
<ActiveMessagesOnline well={well} />
</GridItem>
</Grid>
</LoaderPortal>
)
return (
<LoaderPortal show={showLoader} style={{ flexGrow: 1 }}>
<div className={'telemetry-view-page'}>
<TelemetrySummary data={summaryData} />
<div className={'page-top'}>
<div>
Статус:&nbsp;
<Select value={well.idState ?? 0} onChange={onStatusChanged} disabled={!hasPermission('Well.edit')}>
<Option value={0} disabled hidden>Неизвестно</Option>
<Option value={1}>В работе</Option>
<Option value={2}>Завершено</Option>
</Select>
</div>
<Setpoints />
<WirelineRunOut />
<div className={'icons'}>
<img src={isTorqueStabEnabled(spinLast) ? MomentStabPicEnabled : MomentStabPicDisabled} alt={'TorqueMaster'} />
<img src={isSpinEnabled(spinLast) ? SpinPicEnabled : SpinPicDisabled} alt={'SpinMaster'} />
<h2 style={{ marginBottom: 0, fontWeight: 'bold', color: isMseEnabled(saubLast) ? 'green' : 'lightgrey' }}>MSE</h2>
</div>
</div>
<div className={'page-top'}>
<div>
Начальная дата:&nbsp;
<DatePickerWrapper
disabled={!archiveMode}
value={startDate}
disabledDate={isDateDisabled}
disabledTime={isDateTimeDisabled}
onChange={(startDate) => setStartDate(new Date(startDate))}
/>
</div>
<div>
Интервал:&nbsp;
<PeriodPicker onChange={setChartInterval} />
</div>
<Button onClick={() => chartMethods?.setSettingsVisible(true)}>Настроить графики</Button>
<Button onClick={() => setArchiveMode((prev) => !prev)}>
{archiveMode ? 'Выйти из архива' : 'Войти в архив'}
</Button>
</div>
<D3MonitoringCharts
{...chartProps}
yDomain={domain}
data={filteredData}
methods={setChartMethods}
datasetGroups={chartGroups}
onWheel={onWheel}
/>
<ActiveMessagesOnline well={well} />
</div>
</LoaderPortal>
)
})
export default withPermissions(TelemetryView, [

View File

@ -0,0 +1,56 @@
.data-summary {
display: flex;
gap: .5em;
flex-wrap: wrap;
justify-content: flex-start;
}
.dashboard-display {
display: flex;
flex-direction: column;
align-items: stretch;
border: .067em solid #D9D9D9;
gap: .2em;
border-radius: .14em;
padding: .3em;
min-width: 7.5em;
& .display-label {
gap: 1.5em;
display: flex;
justify-content: space-between;
font-size: .75em;
line-height: 1em;
color: rgba(0, 0, 0, .3);
}
& .display-value {
display: flex;
justify-content: flex-end;
gap: .1em;
font-size: 1.3em;
line-height: 1em;
font-weight: bold;
color: rgba(0, 0, 0, .85);
}
}
@media only screen and (max-width: 1280px) {
.data-summary {
gap: .35em;
}
.dashboard-display {
font-size: 14px;
}
}
@media only screen and (max-width: 1150px) {
.data-summary {
gap: .2em;
}
.dashboard-display {
font-size: 12.5px;
}
}

View File

@ -1,7 +1,6 @@
.display_flex_container{
display: flex;
flex-wrap: wrap;
flex: auto;
}
.display_header {
@ -38,37 +37,36 @@
}
}
.display_label{
font-size: 16px;
color: rgb(70, 70, 70);
text-align: left;
justify-content: center;
margin: 1px 0 1px 1rem;
flex: auto;
align-items: baseline;
text-overflow: ellipsis;
overflow-x: hidden;
overflow-y: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
height: 30px;
.display {
display: flex;
justify-content: space-between;
column-gap: 20px;
padding: 1px 1rem;
align-items: stretch;
& .display_label{
font-size: 16px;
color: rgb(70, 70, 70);
text-overflow: ellipsis;
overflow: hidden;
flex-grow: 1;
}
& .display_value{
display: flex;
align-items: center;
font-size: 18px;
font-weight: bold;
color: rgb(50, 50, 50);
}
}
.display_value{
font-size: 18px;
font-weight: bold;
color: rgb(50, 50, 50);
text-align: right;
justify-content: flex-end;
align-items:baseline;
margin: 1px 1rem;
flex: auto;
}
@media only screen and (max-width: 1280px) {
.display {
padding: 1px 5px;
}
.display_small_value{
color: rgb(50, 50, 50);
text-align: right;
justify-content: center;
margin: 1px 1rem 1px 1rem;
flex: auto;
.display_flex_container {
flex-wrap: wrap;
}
}