diff --git a/src/components/selectors/WellTreeSelector.tsx b/src/components/selectors/WellTreeSelector.tsx index f12fa2d..f06b588 100755 --- a/src/components/selectors/WellTreeSelector.tsx +++ b/src/components/selectors/WellTreeSelector.tsx @@ -70,7 +70,6 @@ const getWellSortScore = (well: WellDto) => { let out = [1, 2, 0][well.idState ?? 2] const timeout = Date.now() - +new Date(well.lastTelemetryDate || 0) if (timeout < 600_000) out += 600_000 - timeout - console.log(well, out) return out } diff --git a/src/pages/Analytics/WellCompositeEditor/WellCompositeSections.jsx b/src/pages/Analytics/WellCompositeEditor/WellCompositeSections.jsx old mode 100755 new mode 100644 diff --git a/src/pages/Telemetry/Operations/index.jsx b/src/pages/Telemetry/Operations/index.jsx index 2688873..8306be8 100644 --- a/src/pages/Telemetry/Operations/index.jsx +++ b/src/pages/Telemetry/Operations/index.jsx @@ -69,9 +69,17 @@ const Operations = memo(() => { useEffect(() => { invokeWebApiWrapperAsync( async () => { - const categories = arrayOrDefault(await DetectedOperationService.getCategories(idWell)) + const categories = arrayOrDefault(await DetectedOperationService.getCategories()) setCategories(categories.map(({ id, name }) => ({ value: id, label: name }))) + }, + setIsLoading, + 'Не удалось загрзуить категории операций' + ) + }, []) + useEffect(() => { + invokeWebApiWrapperAsync( + async () => { const dates = await TelemetryDataSaubService.getDataDatesRange(idWell) if (dates) { const dt = [moment(dates.from), moment(dates.to)] @@ -131,11 +139,11 @@ const Operations = memo(() => {
- +
diff --git a/src/pages/Telemetry/TelemetryView/index.jsx b/src/pages/Telemetry/TelemetryView/index.jsx index 5f28e42..7d5791c 100755 --- a/src/pages/Telemetry/TelemetryView/index.jsx +++ b/src/pages/Telemetry/TelemetryView/index.jsx @@ -1,6 +1,6 @@ -import { Select } from 'antd' -import { BehaviorSubject, buffer, throttleTime } from 'rxjs' import { useState, useEffect, useCallback, memo, useMemo } from 'react' +import { BehaviorSubject, buffer, throttleTime } from 'rxjs' +import { Select } from 'antd' import { useIdWell } from '@asb/context' import { makeDateSorter } from '@components/Table' diff --git a/src/pages/WellOperations/Tvd/NetGraphExport.jsx b/src/pages/WellOperations/Tvd/NetGraphExport.jsx index 72a5d72..048a024 100755 --- a/src/pages/WellOperations/Tvd/NetGraphExport.jsx +++ b/src/pages/WellOperations/Tvd/NetGraphExport.jsx @@ -14,15 +14,16 @@ export const NetGraphExport = memo(({ idWell, ...other }) => { ), [idWell]) return ( - +
+ +
) }) diff --git a/src/pages/WellOperations/Tvd/StatExport.jsx b/src/pages/WellOperations/Tvd/StatExport.jsx new file mode 100644 index 0000000..e425ecc --- /dev/null +++ b/src/pages/WellOperations/Tvd/StatExport.jsx @@ -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 ( + + + + + ) +}) + +export default StatExport 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..1305ca5 --- /dev/null +++ b/src/pages/WellOperations/Tvd/TLPie.jsx @@ -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) => ( +
+ ) }), + 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 ( +
+ + {data ? ( +
+
+ + + + + + + +
+ + + ) : ( +
+ +
+ )} + + + ) +}) + +export default TLPie diff --git a/src/pages/WellOperations/Tvd/index.jsx b/src/pages/WellOperations/Tvd/index.jsx old mode 100755 new mode 100644 index 08de1b2..8b495c9 --- a/src/pages/WellOperations/Tvd/index.jsx +++ b/src/pages/WellOperations/Tvd/index.jsx @@ -1,22 +1,34 @@ -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, Button } 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 StatExport from './StatExport' import NetGraphExport from './NetGraphExport' 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 +97,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 +146,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 +168,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 +180,58 @@ 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={'Нажмите для переключения видимости засечек на графиках'} + /> + +
+
+ - +
@@ -221,7 +249,9 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => { animDurationMs={0} />
- {tableVisible && } + {selectedTab === 'НПВ' && } + {selectedTab === 'ЕСО' && } + {selectedTab === 'Статистика' && }
@@ -229,7 +259,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..08137fc 100755 --- a/src/styles/tvd.less +++ b/src/styles/tvd.less @@ -6,9 +6,24 @@ .tvd-top { display: flex; - align-items: baseline; + align-items: center; 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 {