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, tagLabels,
}); });
const showAvgLine = shouldShowAvgLine(avgLineMode, zoomRange); 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 legendData = useMemo(() => lines.map((line) => line.label), [lines]);
const loading = lines.some((line) => line.loading); const loading = lines.some((line) => line.loading);
const hasData = series.length > 0; const hasData = series.length > 0;

View File

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

View File

@@ -9,8 +9,6 @@ export type HistoryBucketValue = [
max: number, max: number,
count: number, count: number,
slotMs: number, slotMs: number,
previousTime: number | null,
previousAvg: number | null,
]; ];
export type AvgLineMode = 'auto' | 'show' | 'hide'; 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 { import {
DataZoomComponent, DataZoomComponent,
GridComponent, GridComponent,
@@ -10,6 +10,7 @@ import { CanvasRenderer } from 'echarts/renderers';
echarts.use([ echarts.use([
CustomChart, CustomChart,
LineChart,
GridComponent, GridComponent,
LegendComponent, LegendComponent,
TooltipComponent, TooltipComponent,

View File

@@ -27,8 +27,6 @@ const BUCKET = {
max: 3, max: 3,
count: 4, count: 4,
slotMs: 5, slotMs: 5,
previousTime: 6,
previousAvg: 7,
} as const; } as const;
type CartesianCoordSys = { type CartesianCoordSys = {
@@ -46,10 +44,6 @@ type BucketValues = {
min: number; min: number;
max: number; max: number;
slotMs: number; slotMs: number;
previousTime: number;
previousAvg: number;
previousTimeValue: unknown;
previousAvgValue: unknown;
}; };
/** Ограничивает число заданными границами, чтобы фигуры не выходили за canvas графика. */ /** Ограничивает число заданными границами, чтобы фигуры не выходили за canvas графика. */
@@ -75,56 +69,31 @@ function isFinitePoint(point: HistoryPoint): boolean {
return [point.avg_value, point.min_value, point.max_value].every(Number.isFinite); 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[] { function createBucketData(points: HistoryPoint[], slotMs: number): HistoryBucketValue[] {
const finitePoints = points.filter(isFinitePoint); const finitePoints = points.filter(isFinitePoint);
return finitePoints.map((point, index) => { return finitePoints.map((point) => [
const previousPoint = finitePoints[index - 1];
return [
new Date(point.time).getTime(), new Date(point.time).getTime(),
point.avg_value, point.avg_value,
point.min_value, point.min_value,
point.max_value, point.max_value,
point.point_count, point.point_count,
slotMs, slotMs,
previousPoint ? new Date(previousPoint.time).getTime() : null, ]);
previousPoint?.avg_value ?? null,
];
});
} }
/** Читает tuple bucket-а из ECharts API и возвращает значения с понятными именами. */ /** Читает tuple bucket-а из ECharts API и возвращает значения с понятными именами. */
function readBucketValues(api: CustomSeriesRenderItemAPI): BucketValues { function readBucketValues(api: CustomSeriesRenderItemAPI): BucketValues {
const previousTimeValue = api.value(BUCKET.previousTime);
const previousAvgValue = api.value(BUCKET.previousAvg);
return { return {
time: Number(api.value(BUCKET.time)), time: Number(api.value(BUCKET.time)),
avg: Number(api.value(BUCKET.avg)), avg: Number(api.value(BUCKET.avg)),
min: Number(api.value(BUCKET.min)), min: Number(api.value(BUCKET.min)),
max: Number(api.value(BUCKET.max)), max: Number(api.value(BUCKET.max)),
slotMs: Number(api.value(BUCKET.slotMs)), 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-а и обрезает их по области графика. */ /** Считает пиксельные фигуры одного bucket-а и обрезает их по области графика. */
function createBucketShapes(api: CustomSeriesRenderItemAPI, values: BucketValues, chartArea: ChartArea) { function createBucketShapes(api: CustomSeriesRenderItemAPI, values: BucketValues, chartArea: ChartArea) {
const avgPoint = api.coord([values.time, values.avg]); const avgPoint = api.coord([values.time, values.avg]);
@@ -155,30 +124,8 @@ function createBucketShapes(api: CustomSeriesRenderItemAPI, values: BucketValues
}; };
} }
/** Создает отрезок к предыдущей avg-точке, если режим соединительной линии включен. */ /** Рисует один bucket: min..max слот и горизонтальный avg-маркер. */
function createAvgLineShape( function renderBucket(color: string) {
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) {
return (params: CustomSeriesRenderItemParams, api: CustomSeriesRenderItemAPI): CustomSeriesRenderItemReturn => { return (params: CustomSeriesRenderItemParams, api: CustomSeriesRenderItemAPI): CustomSeriesRenderItemReturn => {
const values = readBucketValues(api); const values = readBucketValues(api);
@@ -190,10 +137,6 @@ function renderBucket(color: string, showAvgLine: boolean) {
return null; return null;
} }
// Avg-линия рисуется внутри той же custom-серии, чтобы не удваивать число ECharts-серий при зуме.
const lineShape = showAvgLine
? createAvgLineShape(api, values, bucketShapes.avgPoint, bucketShapes.avgY, chartArea)
: null;
const avgOffset = clamp( const avgOffset = clamp(
(bucketShapes.avgY - bucketShapes.rangeShape.y) / bucketShapes.rangeShape.height, (bucketShapes.avgY - bucketShapes.rangeShape.y) / bucketShapes.rangeShape.height,
0, 0,
@@ -210,17 +153,6 @@ function renderBucket(color: string, showAvgLine: boolean) {
fill: new graphic.LinearGradient(0, 0, 0, 1, createAvgGradientStops(color, avgOffset)), 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 ...(bucketShapes.avgShape
? [{ ? [{
type: 'rect' as const, 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( export function createSeriesOptions(
points: HistoryPoint[], points: HistoryPoint[],
index: number, index: number,
label: string, label: string,
granulate: string, granulate: string,
showAvgLine: boolean,
): SeriesOption[] { ): SeriesOption[] {
const color = SERIES_COLORS[index % SERIES_COLORS.length]; const color = SERIES_COLORS[index % SERIES_COLORS.length];
@@ -250,7 +181,7 @@ export function createSeriesOptions(
name: label, name: label,
type: 'custom', type: 'custom',
data: createBucketData(points, parseGranulateMs(granulate)), data: createBucketData(points, parseGranulateMs(granulate)),
renderItem: renderBucket(color, showAvgLine), renderItem: renderBucket(color),
encode: { encode: {
x: BUCKET.time, x: BUCKET.time,
y: [BUCKET.avg, BUCKET.min, BUCKET.max], y: [BUCKET.avg, BUCKET.min, BUCKET.max],

View File

@@ -1,18 +1,26 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { SeriesOption } from 'echarts'; import type { SeriesOption } from 'echarts';
import { createAvgLineSeries } from './historyChartAvgLineSeries';
import { createSeriesOptions } from './historyChartSeries'; import { createSeriesOptions } from './historyChartSeries';
import type { HistoryChartLineData } from './useHistoryChartQueries'; import type { HistoryChartLineData } from './useHistoryChartQueries';
import type { AvgLineMode } from './chartTypes';
export function useHistoryChartSeries( export function useHistoryChartSeries(
lines: HistoryChartLineData[], lines: HistoryChartLineData[],
granulate: string, granulate: string,
avgLineMode: AvgLineMode,
showAvgLine: boolean, showAvgLine: boolean,
): SeriesOption[] { ): SeriesOption[] {
return useMemo( return useMemo(
() => () =>
lines.flatMap((line) => 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],
); );
} }