* Выполнен базовый перенос TVD на D3

* Добавлена возможность указания отдельных массивов данных для датасетов
* Добавлен проп форматирования тиков
This commit is contained in:
Александр Сироткин 2022-06-27 08:11:49 +05:00
parent 571d8de440
commit ad66af1a10
6 changed files with 184 additions and 178 deletions

View File

@ -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}>

View File

@ -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 }
}

View File

@ -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}
/>
)
})

View File

@ -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>

View File

@ -1,4 +1,6 @@
.asb-d3-chart {
width: 100%;
height: 100%;
& .tooltip {
@color: white;

View File

@ -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;