Merge branch 'dev' into feature/monitoring-chart-editor

This commit is contained in:
Александр Сироткин 2022-08-07 14:13:20 +05:00
commit cda32551ed
10 changed files with 448 additions and 57 deletions

View File

@ -70,7 +70,6 @@ const getWellSortScore = (well: WellDto) => {
let out = [1, 2, 0][well.idState ?? 2] let out = [1, 2, 0][well.idState ?? 2]
const timeout = Date.now() - +new Date(well.lastTelemetryDate || 0) const timeout = Date.now() - +new Date(well.lastTelemetryDate || 0)
if (timeout < 600_000) out += 600_000 - timeout if (timeout < 600_000) out += 600_000 - timeout
console.log(well, out)
return out return out
} }

View File

View File

@ -69,9 +69,17 @@ const Operations = memo(() => {
useEffect(() => { useEffect(() => {
invokeWebApiWrapperAsync( invokeWebApiWrapperAsync(
async () => { async () => {
const categories = arrayOrDefault(await DetectedOperationService.getCategories(idWell)) const categories = arrayOrDefault(await DetectedOperationService.getCategories())
setCategories(categories.map(({ id, name }) => ({ value: id, label: name }))) setCategories(categories.map(({ id, name }) => ({ value: id, label: name })))
},
setIsLoading,
'Не удалось загрзуить категории операций'
)
}, [])
useEffect(() => {
invokeWebApiWrapperAsync(
async () => {
const dates = await TelemetryDataSaubService.getDataDatesRange(idWell) const dates = await TelemetryDataSaubService.getDataDatesRange(idWell)
if (dates) { if (dates) {
const dt = [moment(dates.from), moment(dates.to)] const dt = [moment(dates.from), moment(dates.to)]
@ -131,11 +139,11 @@ const Operations = memo(() => {
<div className={'page-main'}> <div className={'page-main'}>
<OperationsChart <OperationsChart
category={categories?.[selectedCategory]} category={categories?.[selectedCategory]}
data={data.operations} data={data?.operations}
height={'50vh'} height={'50vh'}
yDomain={yDomain} yDomain={yDomain}
/> />
<OperationsTable data={data.stats} height={'20vh'} /> <OperationsTable data={data?.stats} height={'20vh'} />
</div> </div>
</LoaderPortal> </LoaderPortal>
</div> </div>

View File

@ -1,6 +1,6 @@
import { Select } from 'antd'
import { BehaviorSubject, buffer, throttleTime } from 'rxjs'
import { useState, useEffect, useCallback, memo, useMemo } from 'react' import { useState, useEffect, useCallback, memo, useMemo } from 'react'
import { BehaviorSubject, buffer, throttleTime } from 'rxjs'
import { Select } from 'antd'
import { useIdWell } from '@asb/context' import { useIdWell } from '@asb/context'
import { makeDateSorter } from '@components/Table' import { makeDateSorter } from '@components/Table'

View File

@ -14,15 +14,16 @@ export const NetGraphExport = memo(({ idWell, ...other }) => {
), [idWell]) ), [idWell])
return ( return (
<Button <div className={'tvd-input-group'}>
icon={<ExportOutlined />} <Button
loading={isFileExporting} icon={<ExportOutlined />}
onClick={onExport} loading={isFileExporting}
style={{ marginRight: '5px' }} onClick={onExport}
{...other} {...other}
> >
Сетевой график Сетевой график
</Button> </Button>
</div>
) )
}) })

View File

@ -0,0 +1,38 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { Button, Input } from 'antd'
import { useIdWell } from '@asb/context'
import { download, invokeWebApiWrapperAsync } from '@components/factory'
import { WellService } from '@api'
export const StatExport = memo(() => {
const [isFileExporting, setIsFileExporting] = useState(false)
const [idCluster, setIdCluster] = useState()
const idWell = useIdWell()
useEffect(() => {
invokeWebApiWrapperAsync(
async () => {
const { idCluster } = await WellService.get(idWell)
setIdCluster(idCluster)
},
setIsFileExporting,
'Не удалось загрузить ID куста'
)
}, [idWell])
const onExport = useCallback((well) => invokeWebApiWrapperAsync(
async () => await download(`/api/DetectedOperation/export?${well ? 'idWell' : 'idCluster'}=${well ? idWell : idCluster}`),
setIsFileExporting,
'Не удалось загрузить файл'
), [idWell, idCluster])
return (
<Input.Group compact>
<Button loading={isFileExporting} onClick={() => onExport(false)}>Выгрузка (куст)</Button>
<Button loading={isFileExporting} onClick={() => onExport(true)}>Выгрузка (скважина)</Button>
</Input.Group>
)
})
export default StatExport

View File

@ -0,0 +1,151 @@
import { memo, useEffect, useMemo, useState } from 'react'
import { useElementSize } from 'usehooks-ts'
import { Empty } from 'antd'
import moment from 'moment'
import * as d3 from 'd3'
import { useIdWell } from '@asb/context'
import LoaderPortal from '@components/LoaderPortal'
import { invokeWebApiWrapperAsync } from '@components/factory'
import { DetectedOperationService } from '@api'
import { formatDate } from '@utils'
const defaultOffset = { left: 40, right: 20, top: 20, bottom: 20 }
const zeroDate = moment('2000-01-01 00:00:00')
const applyTime = (date) => moment(`${zeroDate.format('YYYY-MM-DD')} ${date.format('HH:mm:ss')}`)
const splitByDate = (startTime, endTime) => {
if (startTime.isSame(endTime, 'day'))
return [{ startTime, endTime }]
const out = []
let date = moment(startTime).startOf('day').add(1, 'day')
out.push({ startTime, endTime: moment(date).subtract(1, 'ms') })
while(!date.isSame(endTime, 'day')) {
const newDate = moment(date).add(1, 'day')
out.push({ startTime: date, endTime: moment(newDate).subtract(1, 'ms') })
date = newDate
}
out.push({ startTime: date, endTime })
return out
}
export const TLChart = memo(({
backgroundColor = '#0000',
barHeight = 15,
offset = defaultOffset,
color,
}) => {
const [isLoading, setIsLoading] = useState(false)
const [svgRef, setSvgRef] = useState()
const [data, setData] = useState()
const [rootRef, { width, height }] = useElementSize()
const idWell = useIdWell()
const dates = useMemo(() => {
if (!data || data.length <= 0) return [0, 0]
return [
d3.min(data, (d) => moment(d.dateStart)).startOf('day'),
d3.max(data, (d) => moment(d.dateEnd)).endOf('day'),
]
}, [data])
const xAxis = useMemo(() => d3.scaleTime()
.range([0, width - offset.left - offset.right])
.domain([zeroDate, moment(zeroDate).endOf('day')])
, [width, offset])
const yAxis = useMemo(() => d3.scaleTime()
.range([0, height - offset.top - offset.bottom - barHeight])
.domain(dates)
, [height, offset, barHeight, dates])
useEffect(() => {
invokeWebApiWrapperAsync(
async () => {
const { operations } = await DetectedOperationService.get(idWell)
setData(operations.map((raw) => {
const startTime = moment(raw.dateStart)
const endTime = moment(raw.dateEnd)
return splitByDate(startTime, endTime).map((dt) => ({
...raw,
startTime: dt.startTime,
endTime: dt.endTime,
}))
}).flat())
},
setIsLoading,
'Не удалось загрузить список операций'
)
}, [idWell])
useEffect(() => { // Рисуем ось X
const xAxisArea = d3.select(svgRef).select('.axis.x')
xAxisArea.call(d3.axisTop(xAxis)
.tickSize(offset.top + offset.bottom - height)
.tickFormat((d) => formatDate(d, undefined, 'HH:mm:ss'))
.ticks(d3.timeHour.every(3))
)
xAxisArea.selectAll('.tick line')
.attr('stroke', 'black')
.attr('stroke-dasharray', [5, 3])
}, [svgRef, xAxis, height, offset])
useEffect(() => { // Рисуем ось Y
d3.select(svgRef)
.select('.axis.y')
.call(d3.axisLeft(yAxis)
.tickSize(0)
.ticks(d3.timeDay.every(1))
.tickFormat((d) => moment(d).format('DD.MM'))
)
}, [svgRef, yAxis, height, offset, dates])
useEffect(() => {
if (!data) return
const elms = d3.select(svgRef).select('.chart-area').selectAll('rect').data(data)
elms.exit().remove()
const newElms = elms.enter().append('rect')
elms.merge(newElms)
.attr('x', (d) => xAxis(applyTime(d.startTime)))
.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])
return (
<div className={'tvd-right'} ref={rootRef}>
<LoaderPortal show={isLoading} style={{ width: '100%', flexGrow: 1 }}>
{data ? (
<svg 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'} />
<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}
/>
</svg>
) : (
<div style={{ height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Empty />
</div>
)}
</LoaderPortal>
</div>
)
})
export default TLChart

View File

@ -0,0 +1,149 @@
import { memo, useEffect, useMemo, useState } from 'react'
import { useElementSize } from 'usehooks-ts'
import { Empty } from 'antd'
import * as d3 from 'd3'
import { useIdWell } from '@asb/context'
import { makeColumn, makeNumericColumn, makeTextColumn, Table } from '@components/Table'
import LoaderPortal from '@components/LoaderPortal'
import { invokeWebApiWrapperAsync } from '@components/factory'
import { DetectedOperationService } from '@api'
const tableColumns = [
makeColumn('Цвет', 'color', { width: 50, render: (d) => (
<div style={{ backgroundColor: d, padding: '5px 0' }} />
) }),
makeTextColumn('Название', 'category', undefined, undefined, undefined, { width: 300 }),
makeNumericColumn('Время, мин', 'minutesTotal', undefined, undefined, undefined, 100),
makeNumericColumn('Кол-во', 'count', undefined, undefined, (d) => d ? d.toString() : '---', 100),
makeNumericColumn('Процент, %', 'percent', undefined, undefined, (d) => d ? d.toFixed(2) : '---', 100)
]
export const TLPie = memo(({ color }) => {
const [isLoading, setIsLoading] = useState(false)
const [svgRef, setSvgRef] = useState()
const [stats, setStats] = useState([])
const [rootRef, { width, height }] = useElementSize()
const idWell = useIdWell()
const pie = useMemo(() => d3.pie().value((d) => d.minutesTotal), [])
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),
percent: stat.minutesTotal / totalTime * 100,
}))
}, [stats, color])
const data = useMemo(() => tableData ? pie(tableData) : null, [tableData])
const radius = useMemo(() => Math.min(width, height) / 2, [width, height])
useEffect(() => {
invokeWebApiWrapperAsync(
async () => {
const stats = await DetectedOperationService.getStat(idWell)
setStats(stats)
},
setIsLoading,
'Не удалось загрузить статистику автоопределённых операций'
)
}, [idWell])
useEffect(() => {
if (!data) return
const slices = d3.select(svgRef)
.select('.slices')
.selectAll('path')
.data(data)
slices.exit().remove()
const newSlices = slices.enter().append('path')
slices.merge(newSlices)
.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])
useEffect(() => {
if (!data) return
const innerArc = d3.arc().innerRadius(radius * 0.4).outerRadius(radius * 0.8)
const outerArc = d3.arc().innerRadius(radius * 0.9).outerRadius(radius * 0.9)
const lines = d3.select(svgRef)
.select('.lines')
.selectAll('polyline')
.data(data, (d) => d.data.category)
lines.exit().remove()
const newLines = lines.enter().append('polyline')
const abovePi = (d) => (d.startAngle + d.endAngle) / 2 < Math.PI
lines.merge(newLines)
.attr('points', (d) => {
const pos = outerArc.centroid(d)
pos[0] = radius * 0.95 * (abovePi(d) ? 1 : -1)
return [innerArc.centroid(d), outerArc.centroid(d), pos]
})
const lables = d3.select(svgRef)
.select('.labels')
.selectAll('text')
.data(data, (d) => d.data.category)
lables.exit().remove()
const newLabels = lables.enter()
.append('text')
.attr('dy', '.35em')
lables.merge(newLabels)
.attr('transform', (d) => {
const pos = outerArc.centroid(d)
pos[0] = radius * 0.95 * (abovePi(d) ? 1 : -1)
return `translate(${pos})`
})
.style('text-anchor', (d) => abovePi(d) ? 'start' : 'end')
.attr('width', radius * 0.4)
.text((d) => `${d.data.percent.toFixed(2)}% (${d.data.minutesTotal.toFixed(2)} мин)`)
}, [svgRef, data, radius])
return (
<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 }}>
<svg ref={setSvgRef} width={'100%'} height={'100%'}>
<g transform={`translate(${width / 2}, ${height / 2})`}>
<g className={'slices'} stroke={'#0005'} />
<g className={'labels'} fill={'black'} />
<g className={'lines'} fill={'none'} stroke={'black'} />
</g>
</svg>
</div>
<Table
size={'small'}
columns={tableColumns}
dataSource={tableData}
scroll={{ y: '20vh', x: true }}
pagination={false}
/>
</div>
) : (
<div style={{ height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Empty />
</div>
)}
</LoaderPortal>
</div>
)
})
export default TLPie

112
src/pages/WellOperations/Tvd/index.jsx Executable file → Normal file
View File

@ -1,22 +1,34 @@
import { DoubleLeftOutlined, DoubleRightOutlined, LineChartOutlined, LinkOutlined } from '@ant-design/icons' import { LineChartOutlined, LinkOutlined } from '@ant-design/icons'
import { memo, useState, useEffect, useCallback, useMemo } from 'react' import { memo, useState, useEffect, useMemo } from 'react'
import { Switch, Segmented, Button } from 'antd'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Switch, Button } from 'antd' import * as d3 from 'd3'
import { timeDay } from 'd3'
import { useIdWell } from '@asb/context' import { useIdWell } from '@asb/context'
import { D3Chart } from '@components/d3' import { D3Chart } from '@components/d3'
import LoaderPortal from '@components/LoaderPortal' import LoaderPortal from '@components/LoaderPortal'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { formatDate, fractionalSum, wrapPrivateComponent, getOperations } from '@utils' import { formatDate, fractionalSum, wrapPrivateComponent, getOperations } from '@utils'
import { DetectedOperationService } from '@api'
import TLPie from './TLPie'
import TLChart from './TLChart'
import NptTable from './NptTable' import NptTable from './NptTable'
import StatExport from './StatExport'
import NetGraphExport from './NetGraphExport' import NetGraphExport from './NetGraphExport'
import AdditionalTables from './AdditionalTables' import AdditionalTables from './AdditionalTables'
import '@styles/index.css' import '@styles/index.css'
import '@styles/tvd.less' import '@styles/tvd.less'
const colorArray = [
'#1abc9c', '#16a085', '#2ecc71', '#27ae60', '#3498db',
'#2980b9', '#9b59b6', '#8e44ad', '#34495e', '#2c3e50',
'#f1c40f', '#f39c12', '#e67e22', '#d35400', '#e74c3c',
'#c0392b', '#ecf0f1', '#bdc3c7', '#95a5a6', '#7f8c8d',
]
const Item = ({ label, children, ...other }) => (<div className={'tvd-input-group'} {...other}><span>{label}: </span>{children}</div>)
const numericRender = (d) => d && Number.isFinite(+d) ? (+d).toFixed(2) : '-' const numericRender = (d) => d && Number.isFinite(+d) ? (+d).toFixed(2) : '-'
@ -85,7 +97,7 @@ const ticks = {
date: { date: {
x: { x: {
visible: true, visible: true,
count: timeDay.every(1), count: d3.timeDay.every(1),
format: (d, i) => i % 2 === 0 ? formatDate(d, undefined, 'YYYY-MM-DD') : '', format: (d, i) => i % 2 === 0 ? formatDate(d, undefined, 'YYYY-MM-DD') : '',
}, },
y: { visible: true }, y: { visible: true },
@ -134,18 +146,14 @@ const makeDataset = (key, label, color, width, radius, dash) => ({
const Tvd = memo(({ idWell: wellId, title, ...other }) => { const Tvd = memo(({ idWell: wellId, title, ...other }) => {
const [xLabel, setXLabel] = useState('day') const [xLabel, setXLabel] = useState('day')
const [operations, setOperations] = useState({}) const [operations, setOperations] = useState({})
const [tableVisible, setTableVisible] = useState(false)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [pointsEnabled, setPointsEnabled] = useState(true) const [pointsEnabled, setPointsEnabled] = useState(true)
const [selectedTab, setSelectedTab] = useState('Скрыть')
const [color, setColor] = useState()
const idWellContext = useIdWell() const idWellContext = useIdWell()
const idWell = useMemo(() => wellId ?? idWellContext, [wellId, idWellContext]) const idWell = useMemo(() => wellId ?? idWellContext, [wellId, idWellContext])
const toogleTable = useCallback(() => {
setOperations(pre => ({ ...pre }))
setTableVisible(v => !v)
}, [])
const chartData = useMemo(() => { const chartData = useMemo(() => {
const withoutNpt = [] const withoutNpt = []
operations?.fact?.forEach((row) => { operations?.fact?.forEach((row) => {
@ -161,15 +169,6 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
return { ...operations, withoutNpt } return { ...operations, withoutNpt }
}, [operations]) }, [operations])
useEffect(() => {
invokeWebApiWrapperAsync(
async () => setOperations(await getOperations(idWell)),
setIsLoading,
`Не удалось загрузить операции по скважине "${idWell}"`,
'Получение списка опервций по скважине'
)
}, [idWell])
const datasets = useMemo(() => { const datasets = useMemo(() => {
const radius = pointsEnabled ? 6 : 1 const radius = pointsEnabled ? 6 : 1
@ -181,29 +180,58 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
] ]
}, [pointsEnabled]) }, [pointsEnabled])
useEffect(() => {
invokeWebApiWrapperAsync(
async () => setOperations(await getOperations(idWell)),
setIsLoading,
`Не удалось загрузить операции по скважине "${idWell}"`,
'Получение списка опервций по скважине'
)
}, [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 ( return (
<div className={'container tvd-page'} {...other}> <div className={'container tvd-page'} {...other}>
<div className={'tvd-top'}> <div className={'tvd-top'}>
<h2>{title || 'График Глубина-день'}</h2> <div className={'tvd-inputs'}>
<div> <h2>{title || 'График Глубина-день'}</h2>
<Switch <Item label={'Ось времени'} style={{ marginLeft: 50 }}>
defaultChecked <Segmented
checkedChildren={'С рисками'} options={[{ label: 'Дата', value: 'date' }, { label: 'Дни со старта', value: 'day' }]}
unCheckedChildren={'Без рисок'} onChange={setXLabel}
onChange={(checked) => setPointsEnabled(checked)} value={xLabel}
style={{ marginRight: 20 }} title={'Нажмите для переключения горизонтальной оси'}
title={'Нажмите для переключения видимости засечек на графиках'} />
/> </Item>
<Switch <Item label={'Риски'}>
checkedChildren={'Дата'} <Switch
unCheckedChildren={'Дни со старта'} defaultChecked
loading={isLoading} onChange={(checked) => setPointsEnabled(checked)}
onChange={(checked) => setXLabel(checked ? 'date' : 'day')} title={'Нажмите для переключения видимости засечек на графиках'}
style={{ marginRight: '20px' }} />
title={'Нажмите для переключения горизонтальной оси'} </Item>
/> </div>
<div className={'tvd-inputs'}>
<StatExport />
<NetGraphExport idWell={idWell} /> <NetGraphExport idWell={idWell} />
<Button icon={tableVisible ? <DoubleRightOutlined /> : <DoubleLeftOutlined />} onClick={toogleTable}>НПВ</Button> <Segmented
options={['НПВ', 'ЕСО', 'Статистика', 'Скрыть']}
value={selectedTab}
onChange={setSelectedTab}
/>
</div> </div>
</div> </div>
<LoaderPortal show={isLoading} style={{ flex: 1 }}> <LoaderPortal show={isLoading} style={{ flex: 1 }}>
@ -221,7 +249,9 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
animDurationMs={0} animDurationMs={0}
/> />
</div> </div>
{tableVisible && <NptTable operations={operations?.fact} />} {selectedTab === 'НПВ' && <NptTable operations={operations?.fact} />}
{selectedTab === 'ЕСО' && <TLChart color={color} />}
{selectedTab === 'Статистика' && <TLPie color={color} />}
</div> </div>
</LoaderPortal> </LoaderPortal>
</div> </div>
@ -229,7 +259,7 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
}) })
export default wrapPrivateComponent(Tvd, { export default wrapPrivateComponent(Tvd, {
requirements: [ 'OperationStat.get' ], requirements: [ 'OperationStat.get', 'DetectedOperation.get' ],
title: 'TVD', title: 'TVD',
route: 'tvd', route: 'tvd',
}) })

View File

@ -6,9 +6,24 @@
.tvd-top { .tvd-top {
display: flex; display: flex;
align-items: baseline; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-top: 20px; margin-top: 20px;
.tvd-inputs {
display: flex;
align-items: center;
.tvd-input-group {
display: flex;
align-items: center;
margin: 0 15px;
& > span {
margin-right: 5px;
}
}
}
} }
.tvd-main { .tvd-main {