140 lines
4.4 KiB
TypeScript
140 lines
4.4 KiB
TypeScript
import type {
|
||
CustomSeriesRenderItemAPI,
|
||
CustomSeriesRenderItemParams,
|
||
CustomSeriesRenderItemReturn,
|
||
SeriesOption,
|
||
} from 'echarts';
|
||
import { graphic } from 'echarts/core';
|
||
import type { HistoryPoint } from '../../entities/history/types';
|
||
import { parseGranulateMs } from '../../utils/historyGranularity';
|
||
import type { HistoryBucketValue } from './chartTypes';
|
||
|
||
export const SERIES_COLORS = [
|
||
'#5B8FF9',
|
||
'#5AD8A6',
|
||
'#F6BD16',
|
||
'#E8684A',
|
||
'#6DC8EC',
|
||
'#FF9D4D',
|
||
'#73D13D',
|
||
'#A7B3FF',
|
||
];
|
||
|
||
type CartesianCoordSys = {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
|
||
function isFinitePoint(point: HistoryPoint): boolean {
|
||
return [point.avg_value, point.min_value, point.max_value].every(Number.isFinite);
|
||
}
|
||
|
||
/** Упаковывает один bucket API в компактный tuple для custom series ECharts. */
|
||
function createBucketData(points: HistoryPoint[], slotMs: number): HistoryBucketValue[] {
|
||
return points.filter(isFinitePoint).map((point) => [
|
||
new Date(point.time).getTime(),
|
||
point.avg_value,
|
||
point.min_value,
|
||
point.max_value,
|
||
point.point_count,
|
||
slotMs,
|
||
]);
|
||
}
|
||
|
||
/** Рисует один bucket как временной слот: затухающий min..max и яркий маркер avg. */
|
||
function renderBucket(color: string) {
|
||
return (params: CustomSeriesRenderItemParams, api: CustomSeriesRenderItemAPI): CustomSeriesRenderItemReturn => {
|
||
const time = Number(api.value(0));
|
||
const avg = Number(api.value(1));
|
||
const min = Number(api.value(2));
|
||
const max = Number(api.value(3));
|
||
const slotMs = Number(api.value(5));
|
||
|
||
// ECharts отдает размеры области графика в runtime, но в публичных типах
|
||
// они не описаны. Поэтому держим cast локально рядом с clipping-логикой.
|
||
const coordSys = params.coordSys as unknown as CartesianCoordSys;
|
||
|
||
// Переводим координаты данных (time/value) в пиксели canvas.
|
||
const avgPoint = api.coord([time, avg]);
|
||
const minPoint = api.coord([time, min]);
|
||
const maxPoint = api.coord([time, max]);
|
||
const slotStart = api.coord([time - slotMs / 2, avg]);
|
||
const slotEnd = api.coord([time + slotMs / 2, avg]);
|
||
|
||
// Слот занимает один интервал грануляции по X и диапазон min..max по Y.
|
||
const x = Math.min(slotStart[0], slotEnd[0]);
|
||
const rawHeight = Math.abs(maxPoint[1] - minPoint[1]);
|
||
const width = Math.max(2, Math.abs(slotEnd[0] - slotStart[0]));
|
||
const height = Math.max(2, rawHeight);
|
||
const y = Math.min(minPoint[1], maxPoint[1]) - (height - rawHeight) / 2;
|
||
const avgY = avgPoint[1];
|
||
const chartArea = { x: coordSys.x, y: coordSys.y, width: coordSys.width, height: coordSys.height };
|
||
|
||
// Обрезаем фигуры, чтобы при Y-зуме они не выходили за область графика.
|
||
const rangeShape = graphic.clipRectByRect(
|
||
{ x, y, width, height },
|
||
chartArea,
|
||
);
|
||
const avgShape = graphic.clipRectByRect(
|
||
{ x, y: avgY - 1, width, height: 2 },
|
||
chartArea,
|
||
);
|
||
|
||
if (!rangeShape) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
type: 'group',
|
||
children: [
|
||
{
|
||
type: 'rect',
|
||
shape: rangeShape,
|
||
style: {
|
||
fill: new graphic.LinearGradient(0, 0, 0, 1, [
|
||
{ offset: 0, color: `${color}00` },
|
||
{ offset: 0.5, color: `${color}66` },
|
||
{ offset: 1, color: `${color}00` },
|
||
]),
|
||
},
|
||
},
|
||
...(avgShape ? [{
|
||
type: 'rect' as const,
|
||
shape: avgShape,
|
||
style: {
|
||
fill: color,
|
||
},
|
||
}] : []),
|
||
],
|
||
};
|
||
};
|
||
}
|
||
|
||
/** Создает отдельный временной слот min..max с акцентом на avg для каждой точки history. */
|
||
export function createSeriesOptions(points: HistoryPoint[], index: number, label: string, granulate: string): SeriesOption[] {
|
||
const color = SERIES_COLORS[index % SERIES_COLORS.length];
|
||
const data = createBucketData(points, parseGranulateMs(granulate));
|
||
|
||
return [
|
||
{
|
||
name: label,
|
||
type: 'custom',
|
||
data,
|
||
renderItem: renderBucket(color),
|
||
encode: {
|
||
x: 0,
|
||
y: [1, 2, 3],
|
||
},
|
||
itemStyle: {
|
||
color,
|
||
},
|
||
emphasis: {
|
||
disabled: true,
|
||
},
|
||
z: 3,
|
||
} as SeriesOption,
|
||
];
|
||
}
|