forked from ddrilling/asb_cloud_front
* Выполнен базовый перенос TVD на D3
* Добавлена возможность указания отдельных массивов данных для датасетов * Добавлен проп форматирования тиков
This commit is contained in:
parent
571d8de440
commit
ad66af1a10
@ -50,7 +50,7 @@ const createAxis = <DataType,>(config: ChartAxis<DataType>) => {
|
||||
export type D3ChartProps<DataType = DefaultDataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
xAxis: ChartAxis<DataType>
|
||||
datasets: ChartDataset<DataType>[]
|
||||
data?: DataType[]
|
||||
data?: DataType[] | Record<string, DataType[]>
|
||||
domain?: Partial<ChartDomain>
|
||||
width?: number | string
|
||||
height?: number | string
|
||||
@ -58,7 +58,7 @@ export type D3ChartProps<DataType = DefaultDataType> = React.DetailedHTMLProps<R
|
||||
offset?: Partial<ChartOffset>
|
||||
animDurationMs?: number
|
||||
backgroundColor?: Property.Color
|
||||
ticks?: ChartTicks
|
||||
ticks?: ChartTicks<DataType>
|
||||
plugins?: {
|
||||
menu?: BasePluginSettings & D3ContextMenuSettings
|
||||
tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
|
||||
@ -104,10 +104,25 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
if (!data) return
|
||||
|
||||
const xAxis = createAxis(xAxisConfig)
|
||||
xAxis.range([0, width - offset.left - offset.right])
|
||||
|
||||
if (domain?.x?.min && domain?.x?.max) {
|
||||
xAxis.domain([domain.x.min, domain.x.max])
|
||||
return xAxis
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
const [minX, maxX] = d3.extent(data, getX)
|
||||
xAxis.domain([domain?.x?.min ?? minX, domain?.x?.max ?? maxX])
|
||||
xAxis.range([0, width - offset.left - offset.right])
|
||||
} else {
|
||||
let [minX, maxX] = [Infinity, -Infinity]
|
||||
for (const key in data) {
|
||||
const [min, max] = d3.extent(data[key], getX)
|
||||
if (min < minX) minX = min
|
||||
if (max > maxX) maxX = max
|
||||
}
|
||||
xAxis.domain([domain?.x?.min ?? minX, domain?.x?.max ?? maxX])
|
||||
}
|
||||
|
||||
return xAxis
|
||||
}, [xAxisConfig, getX, data, domain, width, offset])
|
||||
@ -127,11 +142,21 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
|
||||
let minY = Infinity
|
||||
let maxY = -Infinity
|
||||
if (Array.isArray(data)) {
|
||||
charts.forEach(({ y }) => {
|
||||
const [min, max] = d3.extent(data, y)
|
||||
if (min && min < minY) minY = min
|
||||
if (max && max > maxY) maxY = max
|
||||
})
|
||||
} else {
|
||||
for (const key in data) {
|
||||
const chart = charts.find((chart) => chart.key === key)
|
||||
if (!chart) continue
|
||||
const [min, max] = d3.extent(data[key], chart.y)
|
||||
if (min && min < minY) minY = min
|
||||
if (max && max > maxY) maxY = max
|
||||
}
|
||||
}
|
||||
|
||||
yAxis.domain([
|
||||
domain?.y?.min ?? minY,
|
||||
@ -149,7 +174,7 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
.duration(animDurationMs)
|
||||
.call(d3.axisBottom(xAxis)
|
||||
.tickSize((ticks?.x?.visible ?? false) ? -height + offset.bottom : 0)
|
||||
.tickFormat((d) => formatDate(d, undefined, 'YYYY-MM-DD') || 'NaN')
|
||||
.tickFormat((d) => ticks?.x?.format?.(d) ?? String(d))
|
||||
.ticks(ticks?.x?.count ?? 10) as any // TODO: Исправить тип
|
||||
)
|
||||
|
||||
@ -223,7 +248,8 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
.attr('opacity', chart.opacity ?? 1)
|
||||
.attr('fill', 'none')
|
||||
|
||||
let d = data
|
||||
let d = Array.isArray(data) ? data : data[String(chart.key).split(':')[0]]
|
||||
if (!d) return
|
||||
let elms
|
||||
|
||||
switch (chart.type) {
|
||||
@ -294,15 +320,13 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
.attr('stroke', chart.point?.strokeColor ?? null)
|
||||
.attr('fill', chart.point?.fillColor ?? null)
|
||||
|
||||
elms = chart().selectAll()
|
||||
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
chart.afterDraw?.(elms)
|
||||
chart.afterDraw?.(chart)
|
||||
})
|
||||
}, [charts, data, xAxis, yAxis, height])
|
||||
|
||||
@ -311,15 +335,17 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
|
||||
}, [redrawCharts])
|
||||
|
||||
return (
|
||||
<LoaderPortal show={loading}>
|
||||
<div
|
||||
{...other}
|
||||
ref={rootRef}
|
||||
className={`asb-d3-chart ${className}`}
|
||||
<LoaderPortal
|
||||
show={loading}
|
||||
style={{
|
||||
width: givenWidth,
|
||||
height: givenHeight,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{...other}
|
||||
ref={rootRef}
|
||||
className={`asb-d3-chart ${className}`}
|
||||
>
|
||||
{data ? (
|
||||
<D3ContextMenu {...plugins?.menu} svg={svgRef}>
|
||||
|
@ -60,9 +60,13 @@ export type ChartOffset = {
|
||||
right: number
|
||||
}
|
||||
|
||||
export type ChartTicks = {
|
||||
export type ChartTicks<DataType> = {
|
||||
color?: Property.Color
|
||||
x?: { visible?: boolean, count?: number }
|
||||
x?: {
|
||||
visible?: boolean,
|
||||
count?: number,
|
||||
format?: (d: d3.NumberValue) => string,
|
||||
}
|
||||
y?: { visible?: boolean, count?: number }
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { D3Chart } from '@components/d3'
|
||||
import { formatDate } from '@utils'
|
||||
|
||||
import '@styles/detected_operations.less'
|
||||
|
||||
@ -45,6 +46,12 @@ const xAxis = {
|
||||
accessor: (row) => new Date(row.dateStart),
|
||||
}
|
||||
|
||||
const ticks = {
|
||||
color: '#F9F2ED',
|
||||
x: { visible: true, format: (d) => formatDate(d, undefined, 'YYYY-MM-DD') },
|
||||
y: { visible: true },
|
||||
}
|
||||
|
||||
export const OperationsChart = memo(({ data, yDomain, height }) => {
|
||||
const [isChartLoading, setIsChartLoading] = useState(false)
|
||||
|
||||
@ -58,15 +65,7 @@ export const OperationsChart = memo(({ data, yDomain, height }) => {
|
||||
setTimeout(() => setIsChartLoading(false), 2000)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<D3Chart
|
||||
xAxis={xAxis}
|
||||
domain={domain}
|
||||
datasets={chartDatasets}
|
||||
data={data}
|
||||
loading={isChartLoading}
|
||||
height={height}
|
||||
plugins={{
|
||||
const plugins = useMemo(() => ({
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
type: 'nearest',
|
||||
@ -79,8 +78,18 @@ export const OperationsChart = memo(({ data, yDomain, height }) => {
|
||||
enabled: false,
|
||||
onUpdate: onChartUpdate,
|
||||
}
|
||||
}}
|
||||
ticks={{ color: '#F9F2ED', y: { visible: true }, x: { visible: true } }}
|
||||
}), [onChartUpdate])
|
||||
|
||||
return (
|
||||
<D3Chart
|
||||
xAxis={xAxis}
|
||||
domain={domain}
|
||||
datasets={chartDatasets}
|
||||
data={data}
|
||||
loading={isChartLoading}
|
||||
height={height}
|
||||
plugins={plugins}
|
||||
ticks={ticks}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
@ -1,26 +1,14 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { memo, useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { memo, useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { DoubleLeftOutlined, DoubleRightOutlined } from '@ant-design/icons'
|
||||
import { Switch, Button } from 'antd'
|
||||
|
||||
import {
|
||||
Chart,
|
||||
TimeScale,
|
||||
LinearScale,
|
||||
Legend,
|
||||
LineController,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import 'chartjs-adapter-moment'
|
||||
import zoomPlugin from 'chartjs-plugin-zoom'
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels'
|
||||
|
||||
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 { unique } from '@utils/filters'
|
||||
|
||||
import NptTable from './NptTable'
|
||||
import NetGraphExport from './NetGraphExport'
|
||||
@ -28,97 +16,96 @@ import AdditionalTables from './AdditionalTables'
|
||||
|
||||
import '@styles/index.css'
|
||||
import '@styles/tvd.less'
|
||||
import { unique } from '@asb/utils/filters'
|
||||
|
||||
Chart.register(
|
||||
TimeScale,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Legend,
|
||||
ChartDataLabels,
|
||||
zoomPlugin,
|
||||
Tooltip,
|
||||
)
|
||||
const datasets = [{
|
||||
key: 'fact',
|
||||
label: 'Факт',
|
||||
type: 'line',
|
||||
color: '#0A0',
|
||||
width: 3,
|
||||
yAxis: {
|
||||
type: 'linear',
|
||||
accessor: (row) => row.depth,
|
||||
unit: 'м',
|
||||
},
|
||||
}, {
|
||||
key: 'plan',
|
||||
label: 'План',
|
||||
type: 'line',
|
||||
color: '#F00',
|
||||
width: 3,
|
||||
yAxis: {
|
||||
type: 'linear',
|
||||
accessor: (row) => row.depth,
|
||||
unit: 'м',
|
||||
},
|
||||
}, {
|
||||
key: 'predict',
|
||||
label: 'Прогноз',
|
||||
type: 'line',
|
||||
color: 'purple',
|
||||
width: 1,
|
||||
afterDraw: (d) => d().selectAll('path').attr('stroke-dasharray', [7, 3]),
|
||||
yAxis: {
|
||||
type: 'linear',
|
||||
accessor: 'depth',
|
||||
unit: 'м',
|
||||
},
|
||||
}, {
|
||||
key: 'withoutNpt',
|
||||
label: '',
|
||||
type: 'line',
|
||||
color: '#00F',
|
||||
width: 3,
|
||||
yAxis: {
|
||||
type: 'linear',
|
||||
accessor: 'depth',
|
||||
unit: 'м',
|
||||
},
|
||||
}]
|
||||
|
||||
const xAxis = {
|
||||
date: {
|
||||
type: 'time',
|
||||
accessor: (row) => new Date(row.dateStart),
|
||||
},
|
||||
day: {
|
||||
type: 'linear',
|
||||
accessor: 'day',
|
||||
unit: 'день',
|
||||
}
|
||||
}
|
||||
|
||||
const ticks = {
|
||||
day: {
|
||||
x: {
|
||||
visible: true,
|
||||
format: (d) => d,
|
||||
},
|
||||
y: { visible: true },
|
||||
},
|
||||
date: {
|
||||
x: {
|
||||
visible: true,
|
||||
format: (d) => formatDate(d, undefined, 'YYYY-MM-DD'),
|
||||
},
|
||||
y: { visible: true },
|
||||
}
|
||||
}
|
||||
|
||||
const domain = {
|
||||
date: {
|
||||
y: { min: 4500, max: 0 },
|
||||
},
|
||||
day: {
|
||||
x: { min: 0 },
|
||||
y: { min: 4500, max: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
const numericRender = (value) => Number.isFinite(value) ? (+value).toFixed(2) : '-'
|
||||
|
||||
const scaleTypes = {
|
||||
day: {
|
||||
min: 0,
|
||||
type: 'linear',
|
||||
display: true,
|
||||
title: { display: false, text: '' },
|
||||
ticks: { stepSize: 1 }
|
||||
},
|
||||
date: {
|
||||
display: true,
|
||||
title: { display: true },
|
||||
type: 'time',
|
||||
time: { unit: 'day', displayFormats: { day: 'DD.MM.YYYY' } },
|
||||
grid: { drawTicks: true },
|
||||
ticks: {
|
||||
stepSize: 3,
|
||||
major: { enabled: true },
|
||||
z: 1,
|
||||
display: true,
|
||||
textStrokeColor: '#fff',
|
||||
textStrokeWidth: 2,
|
||||
color: '#000',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: false,
|
||||
interaction: { intersect: false, mode: 'point' },
|
||||
scales: {
|
||||
x: scaleTypes.day,
|
||||
y: {
|
||||
type: 'linear',
|
||||
position: 'top',
|
||||
reverse: true,
|
||||
display: true,
|
||||
title: { display: false, text: '' }
|
||||
}
|
||||
},
|
||||
parsing: { xAxisKey: 'day', yAxisKey: 'depth' },
|
||||
elements: { point: { radius: 1.7 } },
|
||||
plugins: {
|
||||
legend: { display: true },
|
||||
datalabels: { display: false },
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
position: 'nearest',
|
||||
callbacks: {
|
||||
title: (items) => [
|
||||
`Дата: ${formatDate(items[0].raw?.date) ?? '-'}`,
|
||||
`День с начала бурения: ${parseInt(items[0].raw?.day)}`,
|
||||
],
|
||||
afterTitle: (items) => `Глубина: ${numericRender(items[0].raw?.depth)}`,
|
||||
label: (item) => [
|
||||
item.raw.wellSectionTypeName + ': ' + item.raw.categoryName,
|
||||
`Длительность (ч): ${item.raw.nptHours}`
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const makeDataset = (data, label, color, borderWidth = 1.5, borderDash) => ({
|
||||
label,
|
||||
data,
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
borderWidth,
|
||||
borderDash,
|
||||
})
|
||||
|
||||
const Tvd = memo(({ idWell: wellId, title, ...other }) => {
|
||||
const [chart, setChart] = useState()
|
||||
const [xLabel, setXLabel] = useState('day')
|
||||
const [operations, setOperations] = useState({})
|
||||
const [tableVisible, setTableVisible] = useState(false)
|
||||
@ -127,7 +114,6 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
|
||||
const idWellContext = useIdWell()
|
||||
const idWell = useMemo(() => wellId ?? idWellContext, [wellId, idWellContext])
|
||||
|
||||
const chartRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const onPointClick = useCallback((e) => {
|
||||
@ -142,14 +128,10 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
|
||||
navigate(`/well/${idWell}/operations/${datasetName}/?selectedId=${ids}`)
|
||||
}, [idWell, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
invokeWebApiWrapperAsync(
|
||||
async () => setOperations(await getOperations(idWell)),
|
||||
setIsLoading,
|
||||
`Не удалось загрузить операции по скважине "${idWell}"`,
|
||||
'Получение списка опервций по скважине'
|
||||
)
|
||||
}, [idWell])
|
||||
const toogleTable = useCallback(() => {
|
||||
setOperations(pre => ({ ...pre }))
|
||||
setTableVisible(v => !v)
|
||||
}, [])
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const withoutNpt = []
|
||||
@ -163,46 +145,17 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
|
||||
})
|
||||
})
|
||||
|
||||
return { datasets: [
|
||||
makeDataset(operations?.fact, 'Факт', '#0A0', 3),
|
||||
makeDataset(operations?.predict, 'Прогноз', 'purple', 1, [7, 3]),
|
||||
makeDataset(operations?.plan, 'План', '#F00', 3),
|
||||
makeDataset(withoutNpt, 'Факт без НПВ', '#00F', 3)
|
||||
]}
|
||||
return { ...operations, withoutNpt }
|
||||
}, [operations])
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return
|
||||
const options = {}
|
||||
Object.assign(options, defaultOptions)
|
||||
|
||||
const newChart = new Chart(chartRef.current, {
|
||||
type: 'line',
|
||||
options: options,
|
||||
plugins: [ChartDataLabels],
|
||||
data: { datasets: [] },
|
||||
})
|
||||
setChart(newChart)
|
||||
|
||||
return () => newChart?.destroy()
|
||||
}, [chartRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!chart) return
|
||||
chart.data = chartData
|
||||
chart.options.onClick = onPointClick
|
||||
chart.options.scales.x = scaleTypes[xLabel]
|
||||
chart.options.parsing.xAxisKey = xLabel
|
||||
chart.update()
|
||||
// Обнуление ширины необходимо для уменьшения размена при resize после появления элементов
|
||||
chart.canvas.parentNode.style.width = '0'
|
||||
chart.resize()
|
||||
}, [chart, chartData, xLabel, onPointClick])
|
||||
|
||||
const toogleTable = useCallback(() => {
|
||||
setOperations(pre => ({ ...pre }))
|
||||
setTableVisible(v => !v)
|
||||
}, [])
|
||||
invokeWebApiWrapperAsync(
|
||||
async () => setOperations(await getOperations(idWell)),
|
||||
setIsLoading,
|
||||
`Не удалось загрузить операции по скважине "${idWell}"`,
|
||||
'Получение списка опервций по скважине'
|
||||
)
|
||||
}, [idWell])
|
||||
|
||||
return (
|
||||
<div className={'container tvd-page'} {...other}>
|
||||
@ -224,7 +177,19 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
|
||||
<div className={'tvd-main'}>
|
||||
<div className={'tvd-left'}>
|
||||
<AdditionalTables operations={operations} xLabel={xLabel} setIsLoading={setIsLoading} />
|
||||
<canvas ref={chartRef} />
|
||||
<D3Chart
|
||||
xAxis={xAxis[xLabel]}
|
||||
data={chartData}
|
||||
domain={domain[xLabel]}
|
||||
datasets={datasets}
|
||||
loading={isLoading}
|
||||
ticks={ticks[xLabel]}
|
||||
plugins={{
|
||||
tooltip: {
|
||||
enabled: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{tableVisible && <NptTable operations={operations?.fact} />}
|
||||
</div>
|
||||
|
@ -1,4 +1,6 @@
|
||||
.asb-d3-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
& .tooltip {
|
||||
@color: white;
|
||||
|
@ -22,7 +22,7 @@
|
||||
position: relative;
|
||||
flex: 1;
|
||||
|
||||
> div {
|
||||
> .tvd-tr-table, > .tvd-bl-table {
|
||||
position: absolute;
|
||||
//pointer-events: none;
|
||||
transition: opacity .25s ease-out;
|
||||
|
Loading…
Reference in New Issue
Block a user