diff --git a/src/pages/WellOperations/Tvd/TLChart.jsx b/src/pages/WellOperations/Tvd/TLChart.jsx
new file mode 100644
index 0000000..b2ed74b
--- /dev/null
+++ b/src/pages/WellOperations/Tvd/TLChart.jsx
@@ -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 (
+
+
+ {data ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ )
+})
+
+export default TLChart
diff --git a/src/pages/WellOperations/Tvd/TLPie.jsx b/src/pages/WellOperations/Tvd/TLPie.jsx
new file mode 100644
index 0000000..c4cd1ca
--- /dev/null
+++ b/src/pages/WellOperations/Tvd/TLPie.jsx
@@ -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 (
+
+
+ {data ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ )
+})
+
+export default TLPie
diff --git a/src/pages/WellOperations/Tvd/index.jsx b/src/pages/WellOperations/Tvd/index.jsx
index 08de1b2..c2e5139 100755
--- a/src/pages/WellOperations/Tvd/index.jsx
+++ b/src/pages/WellOperations/Tvd/index.jsx
@@ -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 }) => ({label}: {children}
)
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,17 +143,13 @@ 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 = []
@@ -160,15 +165,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 (
-
{title || 'График Глубина-день'}
-
-
setPointsEnabled(checked)}
- style={{ marginRight: 20 }}
- title={'Нажмите для переключения видимости засечек на графиках'}
- />
- setXLabel(checked ? 'date' : 'day')}
- style={{ marginRight: '20px' }}
- title={'Нажмите для переключения горизонтальной оси'}
- />
+
+
{title || 'График Глубина-день'}
+ -
+
+
+ -
+ setPointsEnabled(checked)}
+ title={'Нажмите для переключения видимости засечек на графиках'}
+ />
+
+
+
- : } onClick={toogleTable}>НПВ
+
@@ -221,7 +245,9 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
animDurationMs={0}
/>
- {tableVisible &&
}
+ {selectedTab === 'НПВ' &&
}
+ {selectedTab === 'ЕСО' &&
}
+ {selectedTab === 'Статистика' &&
}
@@ -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',
})
diff --git a/src/styles/tvd.less b/src/styles/tvd.less
index c5c1356..fe42bf6 100755
--- a/src/styles/tvd.less
+++ b/src/styles/tvd.less
@@ -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 {