Добавлены страницы ЕСО и статистики по операциям на ГГД

This commit is contained in:
goodmice 2022-08-05 12:02:57 +05:00
parent a031ee9d8b
commit 765e2e820e
4 changed files with 349 additions and 41 deletions

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,116 @@
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 LoaderPortal from '@components/LoaderPortal'
import { invokeWebApiWrapperAsync } from '@components/factory'
import { DetectedOperationService } from '@api'
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.count), [])
const data = useMemo(() => stats ? pie(stats) : null, [stats, pie])
const radius = useMemo(() => Math.min(width, height) / 2 - 100, [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.category} (${d.data.count})`)
}, [svgRef, data, radius])
return (
<div ref={rootRef} className={'tvd-right'}>
<LoaderPortal show={isLoading} style={{ width: '100%', flexGrow: 1 }}>
{data ? (
<svg ref={setSvgRef} style={{ 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 style={{ height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Empty />
</div>
)}
</LoaderPortal>
</div>
)
})
export default TLPie

View File

@ -1,15 +1,18 @@
import { DoubleLeftOutlined, DoubleRightOutlined, LineChartOutlined, LinkOutlined } from '@ant-design/icons'
import { memo, useState, useEffect, useCallback, useMemo } from 'react'
import { LineChartOutlined, LinkOutlined } from '@ant-design/icons'
import { memo, useState, useEffect, useMemo } from 'react'
import { Switch, Segmented } from 'antd'
import { Link } from 'react-router-dom'
import { Switch, Button } from 'antd'
import { timeDay } from 'd3'
import * as d3 from 'd3'
import { useIdWell } from '@asb/context'
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'
import NptTable from './NptTable'
import NetGraphExport from './NetGraphExport'
import AdditionalTables from './AdditionalTables'
@ -17,6 +20,12 @@ import AdditionalTables from './AdditionalTables'
import '@styles/index.css'
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) : '-'
@ -85,7 +94,7 @@ const ticks = {
date: {
x: {
visible: true,
count: timeDay.every(1),
count: d3.timeDay.every(1),
format: (d, i) => i % 2 === 0 ? formatDate(d, undefined, 'YYYY-MM-DD') : '',
},
y: { visible: true },
@ -134,18 +143,14 @@ const makeDataset = (key, label, color, width, radius, dash) => ({
const Tvd = memo(({ idWell: wellId, title, ...other }) => {
const [xLabel, setXLabel] = useState('day')
const [operations, setOperations] = useState({})
const [tableVisible, setTableVisible] = useState(false)
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])
const toogleTable = useCallback(() => {
setOperations(pre => ({ ...pre }))
setTableVisible(v => !v)
}, [])
const chartData = useMemo(() => {
const withoutNpt = []
operations?.fact?.forEach((row) => {
@ -161,15 +166,6 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
return { ...operations, withoutNpt }
}, [operations])
useEffect(() => {
invokeWebApiWrapperAsync(
async () => setOperations(await getOperations(idWell)),
setIsLoading,
`Не удалось загрузить операции по скважине "${idWell}"`,
'Получение списка опервций по скважине'
)
}, [idWell])
const datasets = useMemo(() => {
const radius = pointsEnabled ? 6 : 1
@ -181,29 +177,57 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
]
}, [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 (
<div className={'container tvd-page'} {...other}>
<div className={'tvd-top'}>
<h2>{title || 'График Глубина-день'}</h2>
<div>
<Switch
defaultChecked
checkedChildren={'С рисками'}
unCheckedChildren={'Без рисок'}
onChange={(checked) => setPointsEnabled(checked)}
style={{ marginRight: 20 }}
title={'Нажмите для переключения видимости засечек на графиках'}
/>
<Switch
checkedChildren={'Дата'}
unCheckedChildren={'Дни со старта'}
loading={isLoading}
onChange={(checked) => setXLabel(checked ? 'date' : 'day')}
style={{ marginRight: '20px' }}
title={'Нажмите для переключения горизонтальной оси'}
/>
<div className={'tvd-inputs'}>
<h2>{title || 'График Глубина-день'}</h2>
<Item label={'Ось времени'} style={{ marginLeft: 50 }}>
<Segmented
options={[{ label: 'Дата', value: 'date' }, { label: 'Дни со старта', value: 'day' }]}
onChange={setXLabel}
value={xLabel}
title={'Нажмите для переключения горизонтальной оси'}
/>
</Item>
<Item label={'Риски'}>
<Switch
defaultChecked
onChange={(checked) => setPointsEnabled(checked)}
title={'Нажмите для переключения видимости засечек на графиках'}
/>
</Item>
</div>
<div className={'tvd-inputs'}>
<NetGraphExport idWell={idWell} />
<Button icon={tableVisible ? <DoubleRightOutlined /> : <DoubleLeftOutlined />} onClick={toogleTable}>НПВ</Button>
<Segmented
options={['НПВ', 'ЕСО', 'Статистика', 'Скрыть']}
value={selectedTab}
onChange={setSelectedTab}
/>
</div>
</div>
<LoaderPortal show={isLoading} style={{ flex: 1 }}>
@ -221,7 +245,9 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
animDurationMs={0}
/>
</div>
{tableVisible && <NptTable operations={operations?.fact} />}
{selectedTab === 'НПВ' && <NptTable operations={operations?.fact} />}
{selectedTab === 'ЕСО' && <TLChart color={color} />}
{selectedTab === 'Статистика' && <TLPie color={color} />}
</div>
</LoaderPortal>
</div>
@ -229,7 +255,7 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
})
export default wrapPrivateComponent(Tvd, {
requirements: [ 'OperationStat.get' ],
requirements: [ 'OperationStat.get', 'DetectedOperation.get' ],
title: 'TVD',
route: 'tvd',
})

View File

@ -9,6 +9,21 @@
align-items: baseline;
justify-content: space-between;
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 {