* Выполнен базовый перенос 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> & { export type D3ChartProps<DataType = DefaultDataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
xAxis: ChartAxis<DataType> xAxis: ChartAxis<DataType>
datasets: ChartDataset<DataType>[] datasets: ChartDataset<DataType>[]
data?: DataType[] data?: DataType[] | Record<string, DataType[]>
domain?: Partial<ChartDomain> domain?: Partial<ChartDomain>
width?: number | string width?: number | string
height?: number | string height?: number | string
@ -58,7 +58,7 @@ export type D3ChartProps<DataType = DefaultDataType> = React.DetailedHTMLProps<R
offset?: Partial<ChartOffset> offset?: Partial<ChartOffset>
animDurationMs?: number animDurationMs?: number
backgroundColor?: Property.Color backgroundColor?: Property.Color
ticks?: ChartTicks ticks?: ChartTicks<DataType>
plugins?: { plugins?: {
menu?: BasePluginSettings & D3ContextMenuSettings menu?: BasePluginSettings & D3ContextMenuSettings
tooltip?: BasePluginSettings & D3TooltipSettings<DataType> tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
@ -104,11 +104,26 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
if (!data) return if (!data) return
const xAxis = createAxis(xAxisConfig) const xAxis = createAxis(xAxisConfig)
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]) 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])
} 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 return xAxis
}, [xAxisConfig, getX, data, domain, width, offset]) }, [xAxisConfig, getX, data, domain, width, offset])
@ -127,11 +142,21 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
let minY = Infinity let minY = Infinity
let maxY = -Infinity let maxY = -Infinity
charts.forEach(({ y }) => { if (Array.isArray(data)) {
const [min, max] = d3.extent(data, y) charts.forEach(({ y }) => {
if (min && min < minY) minY = min const [min, max] = d3.extent(data, y)
if (max && max > maxY) maxY = max 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([ yAxis.domain([
domain?.y?.min ?? minY, domain?.y?.min ?? minY,
@ -149,7 +174,7 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
.duration(animDurationMs) .duration(animDurationMs)
.call(d3.axisBottom(xAxis) .call(d3.axisBottom(xAxis)
.tickSize((ticks?.x?.visible ?? false) ? -height + offset.bottom : 0) .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: Исправить тип .ticks(ticks?.x?.count ?? 10) as any // TODO: Исправить тип
) )
@ -223,7 +248,8 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
.attr('opacity', chart.opacity ?? 1) .attr('opacity', chart.opacity ?? 1)
.attr('fill', 'none') .attr('fill', 'none')
let d = data let d = Array.isArray(data) ? data : data[String(chart.key).split(':')[0]]
if (!d) return
let elms let elms
switch (chart.type) { switch (chart.type) {
@ -294,15 +320,13 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
.attr('stroke', chart.point?.strokeColor ?? null) .attr('stroke', chart.point?.strokeColor ?? null)
.attr('fill', chart.point?.fillColor ?? null) .attr('fill', chart.point?.fillColor ?? null)
elms = chart().selectAll()
break break
} }
default: default:
break break
} }
chart.afterDraw?.(elms) chart.afterDraw?.(chart)
}) })
}, [charts, data, xAxis, yAxis, height]) }, [charts, data, xAxis, yAxis, height])
@ -311,15 +335,17 @@ export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
}, [redrawCharts]) }, [redrawCharts])
return ( return (
<LoaderPortal show={loading}> <LoaderPortal
show={loading}
style={{
width: givenWidth,
height: givenHeight,
}}
>
<div <div
{...other} {...other}
ref={rootRef} ref={rootRef}
className={`asb-d3-chart ${className}`} className={`asb-d3-chart ${className}`}
style={{
width: givenWidth,
height: givenHeight,
}}
> >
{data ? ( {data ? (
<D3ContextMenu {...plugins?.menu} svg={svgRef}> <D3ContextMenu {...plugins?.menu} svg={svgRef}>

View File

@ -60,9 +60,13 @@ export type ChartOffset = {
right: number right: number
} }
export type ChartTicks = { export type ChartTicks<DataType> = {
color?: Property.Color color?: Property.Color
x?: { visible?: boolean, count?: number } x?: {
visible?: boolean,
count?: number,
format?: (d: d3.NumberValue) => string,
}
y?: { visible?: boolean, count?: number } y?: { visible?: boolean, count?: number }
} }

View File

@ -1,6 +1,7 @@
import { memo, useCallback, useMemo, useState } from 'react' import { memo, useCallback, useMemo, useState } from 'react'
import { D3Chart } from '@components/d3' import { D3Chart } from '@components/d3'
import { formatDate } from '@utils'
import '@styles/detected_operations.less' import '@styles/detected_operations.less'
@ -45,6 +46,12 @@ const xAxis = {
accessor: (row) => new Date(row.dateStart), 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 }) => { export const OperationsChart = memo(({ data, yDomain, height }) => {
const [isChartLoading, setIsChartLoading] = useState(false) const [isChartLoading, setIsChartLoading] = useState(false)
@ -58,6 +65,21 @@ export const OperationsChart = memo(({ data, yDomain, height }) => {
setTimeout(() => setIsChartLoading(false), 2000) setTimeout(() => setIsChartLoading(false), 2000)
}, []) }, [])
const plugins = useMemo(() => ({
tooltip: {
enabled: true,
type: 'nearest',
limit: 10,
},
cursor: {
enabled: true,
},
menu: {
enabled: false,
onUpdate: onChartUpdate,
}
}), [onChartUpdate])
return ( return (
<D3Chart <D3Chart
xAxis={xAxis} xAxis={xAxis}
@ -66,21 +88,8 @@ export const OperationsChart = memo(({ data, yDomain, height }) => {
data={data} data={data}
loading={isChartLoading} loading={isChartLoading}
height={height} height={height}
plugins={{ plugins={plugins}
tooltip: { ticks={ticks}
enabled: true,
type: 'nearest',
limit: 10,
},
cursor: {
enabled: true,
},
menu: {
enabled: false,
onUpdate: onChartUpdate,
}
}}
ticks={{ color: '#F9F2ED', y: { visible: true }, x: { visible: true } }}
/> />
) )
}) })

View File

@ -1,26 +1,14 @@
import { useNavigate } from 'react-router-dom' 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 { DoubleLeftOutlined, DoubleRightOutlined } from '@ant-design/icons'
import { Switch, Button } from 'antd' 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 { useIdWell } from '@asb/context'
import { D3Chart } from '@components/d3'
import LoaderPortal from '@components/LoaderPortal' import LoaderPortal from '@components/LoaderPortal'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { formatDate, fractionalSum, wrapPrivateComponent, getOperations } from '@utils' import { formatDate, fractionalSum, wrapPrivateComponent, getOperations } from '@utils'
import { unique } from '@utils/filters'
import NptTable from './NptTable' import NptTable from './NptTable'
import NetGraphExport from './NetGraphExport' import NetGraphExport from './NetGraphExport'
@ -28,97 +16,96 @@ import AdditionalTables from './AdditionalTables'
import '@styles/index.css' import '@styles/index.css'
import '@styles/tvd.less' import '@styles/tvd.less'
import { unique } from '@asb/utils/filters'
Chart.register( const datasets = [{
TimeScale, key: 'fact',
LinearScale, label: 'Факт',
LineController, type: 'line',
LineElement, color: '#0A0',
PointElement, width: 3,
Legend, yAxis: {
ChartDataLabels,
zoomPlugin,
Tooltip,
)
const numericRender = (value) => Number.isFinite(value) ? (+value).toFixed(2) : '-'
const scaleTypes = {
day: {
min: 0,
type: 'linear', type: 'linear',
display: true, accessor: (row) => row.depth,
title: { display: false, text: '' }, unit: 'м',
ticks: { stepSize: 1 }
}, },
}, {
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: { date: {
display: true,
title: { display: true },
type: 'time', type: 'time',
time: { unit: 'day', displayFormats: { day: 'DD.MM.YYYY' } }, accessor: (row) => new Date(row.dateStart),
grid: { drawTicks: true }, },
ticks: { day: {
stepSize: 3, type: 'linear',
major: { enabled: true }, accessor: 'day',
z: 1, unit: 'день',
display: true,
textStrokeColor: '#fff',
textStrokeWidth: 2,
color: '#000',
}
} }
} }
const defaultOptions = { const ticks = {
responsive: true, day: {
maintainAspectRatio: false, x: {
aspectRatio: false, visible: true,
interaction: { intersect: false, mode: 'point' }, format: (d) => d,
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}`
],
},
}, },
y: { visible: true },
}, },
date: {
x: {
visible: true,
format: (d) => formatDate(d, undefined, 'YYYY-MM-DD'),
},
y: { visible: true },
}
} }
const makeDataset = (data, label, color, borderWidth = 1.5, borderDash) => ({ const domain = {
label, date: {
data, y: { min: 4500, max: 0 },
backgroundColor: color, },
borderColor: color, day: {
borderWidth, x: { min: 0 },
borderDash, y: { min: 4500, max: 0 },
}) }
}
const numericRender = (value) => Number.isFinite(value) ? (+value).toFixed(2) : '-'
const Tvd = memo(({ idWell: wellId, title, ...other }) => { const Tvd = memo(({ idWell: wellId, title, ...other }) => {
const [chart, setChart] = useState()
const [xLabel, setXLabel] = useState('day') const [xLabel, setXLabel] = useState('day')
const [operations, setOperations] = useState({}) const [operations, setOperations] = useState({})
const [tableVisible, setTableVisible] = useState(false) const [tableVisible, setTableVisible] = useState(false)
@ -127,7 +114,6 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
const idWellContext = useIdWell() const idWellContext = useIdWell()
const idWell = useMemo(() => wellId ?? idWellContext, [wellId, idWellContext]) const idWell = useMemo(() => wellId ?? idWellContext, [wellId, idWellContext])
const chartRef = useRef(null)
const navigate = useNavigate() const navigate = useNavigate()
const onPointClick = useCallback((e) => { const onPointClick = useCallback((e) => {
@ -141,15 +127,11 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
const ids = points.map((p) => p.raw.id).filter(Boolean).filter(unique).join(',') const ids = points.map((p) => p.raw.id).filter(Boolean).filter(unique).join(',')
navigate(`/well/${idWell}/operations/${datasetName}/?selectedId=${ids}`) navigate(`/well/${idWell}/operations/${datasetName}/?selectedId=${ids}`)
}, [idWell, navigate]) }, [idWell, navigate])
useEffect(() => { const toogleTable = useCallback(() => {
invokeWebApiWrapperAsync( setOperations(pre => ({ ...pre }))
async () => setOperations(await getOperations(idWell)), setTableVisible(v => !v)
setIsLoading, }, [])
`Не удалось загрузить операции по скважине "${idWell}"`,
'Получение списка опервций по скважине'
)
}, [idWell])
const chartData = useMemo(() => { const chartData = useMemo(() => {
const withoutNpt = [] const withoutNpt = []
@ -163,46 +145,17 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
}) })
}) })
return { datasets: [ return { ...operations, withoutNpt }
makeDataset(operations?.fact, 'Факт', '#0A0', 3),
makeDataset(operations?.predict, 'Прогноз', 'purple', 1, [7, 3]),
makeDataset(operations?.plan, 'План', '#F00', 3),
makeDataset(withoutNpt, 'Факт без НПВ', '#00F', 3)
]}
}, [operations]) }, [operations])
useEffect(() => { useEffect(() => {
if (!chartRef.current) return invokeWebApiWrapperAsync(
const options = {} async () => setOperations(await getOperations(idWell)),
Object.assign(options, defaultOptions) setIsLoading,
`Не удалось загрузить операции по скважине "${idWell}"`,
const newChart = new Chart(chartRef.current, { 'Получение списка опервций по скважине'
type: 'line', )
options: options, }, [idWell])
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)
}, [])
return ( return (
<div className={'container tvd-page'} {...other}> <div className={'container tvd-page'} {...other}>
@ -224,7 +177,19 @@ const Tvd = memo(({ idWell: wellId, title, ...other }) => {
<div className={'tvd-main'}> <div className={'tvd-main'}>
<div className={'tvd-left'}> <div className={'tvd-left'}>
<AdditionalTables operations={operations} xLabel={xLabel} setIsLoading={setIsLoading} /> <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> </div>
{tableVisible && <NptTable operations={operations?.fact} />} {tableVisible && <NptTable operations={operations?.fact} />}
</div> </div>

View File

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

View File

@ -22,7 +22,7 @@
position: relative; position: relative;
flex: 1; flex: 1;
> div { > .tvd-tr-table, > .tvd-bl-table {
position: absolute; position: absolute;
//pointer-events: none; //pointer-events: none;
transition: opacity .25s ease-out; transition: opacity .25s ease-out;