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={'Нажмите для переключения видимости засечек на графиках'} + /> + +
+
- +
@@ -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 {