avg line serie and mode

This commit is contained in:
2026-07-08 14:17:10 +05:00
parent 5ffe9192c1
commit 9a542ce33e
7 changed files with 113 additions and 82 deletions

View File

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

View File

@@ -128,6 +128,7 @@ export function HistoryChartArea({
</div>
</div>
<ReactEChartsCore
key={avgLineMode}
ref={chartRef}
echarts={echarts}
option={option}

View File

@@ -9,8 +9,6 @@ export type HistoryBucketValue = [
max: number,
count: number,
slotMs: number,
previousTime: number | null,
previousAvg: number | null,
];
export type AvgLineMode = 'auto' | 'show' | 'hide';

View File

@@ -0,0 +1,92 @@
import type { LineSeriesOption, SeriesOption } from 'echarts';
import type { HistoryPoint } from '../../entities/history/types';
import { parseGranulateMs } from '../../utils/historyGranularity';
import { SERIES_COLORS } from './historyChartSeries';
type AvgLineSeriesValue = [time: number, avg: number, min: number, max: number, count: number, slotMs: number];
type AvgLineSeriesPoint = AvgLineSeriesValue | null;
function isFinitePoint(point: HistoryPoint): boolean {
return [point.avg_value, point.min_value, point.max_value].every(Number.isFinite);
}
function createAvgLineData(points: HistoryPoint[], granulate: string, breakOnGaps: boolean): AvgLineSeriesPoint[] {
const slotMs = parseGranulateMs(granulate);
const finitePoints = points.filter(isFinitePoint);
if (!breakOnGaps) {
return finitePoints.map((point) => [
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,
];
}

View File

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

View File

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

View File

@@ -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],
);
}