diff --git a/src/features/history-chart/HistoryChart.tsx b/src/features/history-chart/HistoryChart.tsx index 5b3647f..e32a5f9 100644 --- a/src/features/history-chart/HistoryChart.tsx +++ b/src/features/history-chart/HistoryChart.tsx @@ -72,7 +72,7 @@ export function HistoryChart({ tagLabels, }); const showAvgLine = shouldShowAvgLine(avgLineMode, zoomRange); - const series = useHistoryChartSeries(lines, zoomRange.granulate, showAvgLine); + const series = useHistoryChartSeries(lines, zoomRange.granulate, avgLineMode, showAvgLine); const legendData = useMemo(() => lines.map((line) => line.label), [lines]); const loading = lines.some((line) => line.loading); const hasData = series.length > 0; diff --git a/src/features/history-chart/HistoryChartArea.tsx b/src/features/history-chart/HistoryChartArea.tsx index 1849971..dea99a6 100644 --- a/src/features/history-chart/HistoryChartArea.tsx +++ b/src/features/history-chart/HistoryChartArea.tsx @@ -128,6 +128,7 @@ export function HistoryChartArea({ [ + new Date(point.time).getTime(), + point.avg_value, + point.min_value, + point.max_value, + point.point_count, + slotMs, + ]); + } + + return finitePoints.flatMap((point, index) => { + const currentTime = new Date(point.time).getTime(); + const currentPoint: AvgLineSeriesValue = [ + currentTime, + point.avg_value, + point.min_value, + point.max_value, + point.point_count, + slotMs, + ]; + + if (index === 0) { + return [currentPoint]; + } + + const previousTime = new Date(finitePoints[index - 1].time).getTime(); + const hasAcceptableGap = currentTime - previousTime <= slotMs * 100; + + return hasAcceptableGap ? [currentPoint] : [null, currentPoint]; + }); +} + +export function createAvgLineSeries( + points: HistoryPoint[], + index: number, + label: string, + granulate: string, + showAvgLine: boolean, + breakOnGaps: boolean, +): SeriesOption[] { + if (!showAvgLine) { + return []; + } + + const color = SERIES_COLORS[index % SERIES_COLORS.length]; + + return [ + { + name: `${label} avg-line`, + type: 'line', + data: createAvgLineData(points, granulate, breakOnGaps), + encode: { + x: 0, + y: 1, + }, + smooth: true, + connectNulls: false, + showSymbol: false, + symbol: 'none', + silent: true, + tooltip: { + show: false, + }, + lineStyle: { + color, + width: 1.5, + opacity: 0.78, + }, + emphasis: { + disabled: true, + }, + z: 4, + } as LineSeriesOption, + ]; +} diff --git a/src/features/history-chart/historyChartEcharts.ts b/src/features/history-chart/historyChartEcharts.ts index 8cf309e..537c4ef 100644 --- a/src/features/history-chart/historyChartEcharts.ts +++ b/src/features/history-chart/historyChartEcharts.ts @@ -1,4 +1,4 @@ -import { CustomChart } from 'echarts/charts'; +import { CustomChart, LineChart } from 'echarts/charts'; import { DataZoomComponent, GridComponent, @@ -10,6 +10,7 @@ import { CanvasRenderer } from 'echarts/renderers'; echarts.use([ CustomChart, + LineChart, GridComponent, LegendComponent, TooltipComponent, diff --git a/src/features/history-chart/historyChartSeries.ts b/src/features/history-chart/historyChartSeries.ts index 8000176..86e1520 100644 --- a/src/features/history-chart/historyChartSeries.ts +++ b/src/features/history-chart/historyChartSeries.ts @@ -27,8 +27,6 @@ const BUCKET = { max: 3, count: 4, slotMs: 5, - previousTime: 6, - previousAvg: 7, } as const; type CartesianCoordSys = { @@ -46,10 +44,6 @@ type BucketValues = { min: number; max: number; slotMs: number; - previousTime: number; - previousAvg: number; - previousTimeValue: unknown; - previousAvgValue: unknown; }; /** Ограничивает число заданными границами, чтобы фигуры не выходили за canvas графика. */ @@ -75,56 +69,31 @@ function isFinitePoint(point: HistoryPoint): boolean { return [point.avg_value, point.min_value, point.max_value].every(Number.isFinite); } -/** Упаковывает bucket и предыдущую avg-точку в tuple для custom series ECharts. */ +/** Упаковывает bucket в tuple для custom series ECharts. */ function createBucketData(points: HistoryPoint[], slotMs: number): HistoryBucketValue[] { const finitePoints = points.filter(isFinitePoint); - return finitePoints.map((point, index) => { - const previousPoint = finitePoints[index - 1]; - - return [ + return finitePoints.map((point) => [ new Date(point.time).getTime(), point.avg_value, point.min_value, point.max_value, point.point_count, slotMs, - previousPoint ? new Date(previousPoint.time).getTime() : null, - previousPoint?.avg_value ?? null, - ]; - }); + ]); } /** Читает tuple bucket-а из ECharts API и возвращает значения с понятными именами. */ function readBucketValues(api: CustomSeriesRenderItemAPI): BucketValues { - const previousTimeValue = api.value(BUCKET.previousTime); - const previousAvgValue = api.value(BUCKET.previousAvg); - return { time: Number(api.value(BUCKET.time)), avg: Number(api.value(BUCKET.avg)), min: Number(api.value(BUCKET.min)), max: Number(api.value(BUCKET.max)), slotMs: Number(api.value(BUCKET.slotMs)), - previousTime: Number(previousTimeValue), - previousAvg: Number(previousAvgValue), - previousTimeValue, - previousAvgValue, }; } -/** Проверяет, можно ли рисовать соединительный отрезок к предыдущей avg-точке. */ -function hasPreviousAvg(values: BucketValues): boolean { - return ( - values.previousTimeValue !== null && - values.previousTimeValue !== undefined && - values.previousAvgValue !== null && - values.previousAvgValue !== undefined && - Number.isFinite(values.previousTime) && - Number.isFinite(values.previousAvg) - ); -} - /** Считает пиксельные фигуры одного bucket-а и обрезает их по области графика. */ function createBucketShapes(api: CustomSeriesRenderItemAPI, values: BucketValues, chartArea: ChartArea) { const avgPoint = api.coord([values.time, values.avg]); @@ -155,30 +124,8 @@ function createBucketShapes(api: CustomSeriesRenderItemAPI, values: BucketValues }; } -/** Создает отрезок к предыдущей avg-точке, если режим соединительной линии включен. */ -function createAvgLineShape( - api: CustomSeriesRenderItemAPI, - values: BucketValues, - avgPoint: number[], - avgY: number, - chartArea: ChartArea, -) { - if (!hasPreviousAvg(values)) { - return null; - } - - const previousPoint = api.coord([values.previousTime, values.previousAvg]); - - return { - x1: clamp(previousPoint[0], chartArea.x, chartArea.x + chartArea.width), - y1: clamp(previousPoint[1], chartArea.y, chartArea.y + chartArea.height), - x2: clamp(avgPoint[0], chartArea.x, chartArea.x + chartArea.width), - y2: clamp(avgY, chartArea.y, chartArea.y + chartArea.height), - }; -} - -/** Рисует один bucket: min..max слот, avg-маркер и опциональный отрезок к предыдущей avg-точке. */ -function renderBucket(color: string, showAvgLine: boolean) { +/** Рисует один bucket: min..max слот и горизонтальный avg-маркер. */ +function renderBucket(color: string) { return (params: CustomSeriesRenderItemParams, api: CustomSeriesRenderItemAPI): CustomSeriesRenderItemReturn => { const values = readBucketValues(api); @@ -190,10 +137,6 @@ function renderBucket(color: string, showAvgLine: boolean) { return null; } - // Avg-линия рисуется внутри той же custom-серии, чтобы не удваивать число ECharts-серий при зуме. - const lineShape = showAvgLine - ? createAvgLineShape(api, values, bucketShapes.avgPoint, bucketShapes.avgY, chartArea) - : null; const avgOffset = clamp( (bucketShapes.avgY - bucketShapes.rangeShape.y) / bucketShapes.rangeShape.height, 0, @@ -210,17 +153,6 @@ function renderBucket(color: string, showAvgLine: boolean) { fill: new graphic.LinearGradient(0, 0, 0, 1, createAvgGradientStops(color, avgOffset)), }, }, - ...(lineShape - ? [{ - type: 'line' as const, - shape: lineShape, - style: { - stroke: color, - lineWidth: 1, - opacity: 0.72, - }, - }] - : []), ...(bucketShapes.avgShape ? [{ type: 'rect' as const, @@ -235,13 +167,12 @@ function renderBucket(color: string, showAvgLine: boolean) { }; } -/** Создает одну custom-серию на тег: bucket-слоты и avg-линия живут в одном renderItem. */ +/** Создает bucket-серию на тег: min..max слот и горизонтальный avg-маркер. */ export function createSeriesOptions( points: HistoryPoint[], index: number, label: string, granulate: string, - showAvgLine: boolean, ): SeriesOption[] { const color = SERIES_COLORS[index % SERIES_COLORS.length]; @@ -250,7 +181,7 @@ export function createSeriesOptions( name: label, type: 'custom', data: createBucketData(points, parseGranulateMs(granulate)), - renderItem: renderBucket(color, showAvgLine), + renderItem: renderBucket(color), encode: { x: BUCKET.time, y: [BUCKET.avg, BUCKET.min, BUCKET.max], diff --git a/src/features/history-chart/useHistoryChartSeries.ts b/src/features/history-chart/useHistoryChartSeries.ts index 7f23c98..7cef921 100644 --- a/src/features/history-chart/useHistoryChartSeries.ts +++ b/src/features/history-chart/useHistoryChartSeries.ts @@ -1,18 +1,26 @@ import { useMemo } from 'react'; import type { SeriesOption } from 'echarts'; +import { createAvgLineSeries } from './historyChartAvgLineSeries'; import { createSeriesOptions } from './historyChartSeries'; import type { HistoryChartLineData } from './useHistoryChartQueries'; +import type { AvgLineMode } from './chartTypes'; export function useHistoryChartSeries( lines: HistoryChartLineData[], granulate: string, + avgLineMode: AvgLineMode, showAvgLine: boolean, ): SeriesOption[] { return useMemo( () => lines.flatMap((line) => - line.rows.length ? createSeriesOptions(line.rows, line.index, line.label, granulate, showAvgLine) : [], + line.rows.length + ? [ + ...createSeriesOptions(line.rows, line.index, line.label, granulate), + ...createAvgLineSeries(line.rows, line.index, line.label, granulate, showAvgLine, avgLineMode === 'auto'), + ] + : [], ), - [granulate, lines, showAvgLine], + [granulate, lines, showAvgLine, avgLineMode], ); }