From 1a994f36bd6f383f31c1c8d0ad44ba681c5910dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Sun, 5 Jul 2026 09:54:05 +0300 Subject: [PATCH] =?UTF-8?q?BUR-62=20=D0=BF=D0=B5=D1=80=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=D0=BD=D0=B0=20=D0=BE=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D0=BE=D1=82=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=B3=D1=80=D0=B0=D0=BD=D1=83?= =?UTF-8?q?=D0=BB=D1=8F=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/history-chart/HistoryChart.tsx | 2 +- src/features/history-chart/chartTypes.ts | 2 + .../history-chart/historyChartEcharts.ts | 5 +- .../history-chart/historyChartSeries.ts | 188 +++++++++++------- .../history-chart/useHistoryChartSeries.ts | 6 +- src/utils/historyGranularity.ts | 22 ++ 6 files changed, 141 insertions(+), 84 deletions(-) diff --git a/src/features/history-chart/HistoryChart.tsx b/src/features/history-chart/HistoryChart.tsx index 3b0ca3f..1e6f705 100644 --- a/src/features/history-chart/HistoryChart.tsx +++ b/src/features/history-chart/HistoryChart.tsx @@ -34,7 +34,7 @@ export function HistoryChart({ }: HistoryChartProps) { const dataZoomRef = useRef(null); const lines = useHistoryChartQueries({ edge, from, granulate, tags, to, tagLabels }); - const series = useHistoryChartSeries(lines); + const series = useHistoryChartSeries(lines, granulate); const legendData = useMemo(() => lines.map((line) => line.label), [lines]); const loading = lines.some((line) => line.loading); diff --git a/src/features/history-chart/chartTypes.ts b/src/features/history-chart/chartTypes.ts index 3bdc6cc..3d76188 100644 --- a/src/features/history-chart/chartTypes.ts +++ b/src/features/history-chart/chartTypes.ts @@ -1,5 +1,7 @@ export type AvgPointValue = [time: number, avg: number, min: number, max: number, count: number]; +export type HistoryBucketValue = [...AvgPointValue, slotMs: number]; + export type TooltipParam = { color?: string; marker?: string; diff --git a/src/features/history-chart/historyChartEcharts.ts b/src/features/history-chart/historyChartEcharts.ts index dba3d06..8cf309e 100644 --- a/src/features/history-chart/historyChartEcharts.ts +++ b/src/features/history-chart/historyChartEcharts.ts @@ -1,4 +1,4 @@ -import { LineChart, ScatterChart } from 'echarts/charts'; +import { CustomChart } from 'echarts/charts'; import { DataZoomComponent, GridComponent, @@ -9,8 +9,7 @@ import * as echarts from 'echarts/core'; import { CanvasRenderer } from 'echarts/renderers'; echarts.use([ - LineChart, - ScatterChart, + CustomChart, GridComponent, LegendComponent, TooltipComponent, diff --git a/src/features/history-chart/historyChartSeries.ts b/src/features/history-chart/historyChartSeries.ts index 325e82c..d35fb15 100644 --- a/src/features/history-chart/historyChartSeries.ts +++ b/src/features/history-chart/historyChartSeries.ts @@ -1,6 +1,13 @@ -import type { SeriesOption } from 'echarts'; +import type { + CustomSeriesRenderItemAPI, + CustomSeriesRenderItemParams, + CustomSeriesRenderItemReturn, + SeriesOption, +} from 'echarts'; +import { graphic } from 'echarts/core'; import type { HistoryPoint } from '../../entities/history/types'; -import type { AvgPointValue } from './chartTypes'; +import { parseGranulateMs } from '../../utils/historyGranularity'; +import type { HistoryBucketValue } from './chartTypes'; export const SERIES_COLORS = [ '#5B8FF9', @@ -13,93 +20,120 @@ export const SERIES_COLORS = [ '#A7B3FF', ]; -/** Создает avg-линию, вертикальные min/max-отрезки и точки экстремумов для одной серии. */ -export function createSeriesOptions(points: HistoryPoint[], index: number, label: string): SeriesOption[] { +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 avgData: AvgPointValue[] = points.map((point) => { - const time = new Date(point.time).getTime(); - return [time, point.avg_value, point.min_value, point.max_value, point.point_count]; - }); - const spreadData = points.flatMap((point) => { - const time = new Date(point.time).getTime(); - return [ - [time, point.min_value], - [time, point.max_value], - [time, null], - ]; - }); + const data = createBucketData(points, parseGranulateMs(granulate)); return [ { name: label, - type: 'line', - data: spreadData, - showSymbol: false, - connectNulls: false, - silent: true, - tooltip: { show: false }, - legendHoverLink: false, - lineStyle: { - width: 1, - opacity: 0.42, - color, + type: 'custom', + data, + renderItem: renderBucket(color), + encode: { + x: 0, + y: [1, 2, 3], }, - emphasis: { - disabled: true, - }, - z: 1, - }, - { - name: label, - type: 'scatter', - data: points.map((point) => [new Date(point.time).getTime(), point.min_value]), - symbolSize: 4, - silent: true, - tooltip: { show: false }, itemStyle: { color, - opacity: 0.72, }, emphasis: { disabled: true, }, - z: 2, - }, - { - name: label, - type: 'scatter', - data: points.map((point) => [new Date(point.time).getTime(), point.max_value]), - symbolSize: 4, - silent: true, - tooltip: { show: false }, - itemStyle: { - color, - opacity: 0.72, - }, - emphasis: { - disabled: true, - }, - z: 2, - }, - { - name: label, - type: 'line', - showSymbol: false, - smooth: false, - sampling: 'lttb', - data: avgData, - lineStyle: { - width: 1.8, - color, - }, - emphasis: { - disabled: true, - lineStyle: { - width: 1.8, - color, - }, - }, z: 3, - }, + } as SeriesOption, ]; } diff --git a/src/features/history-chart/useHistoryChartSeries.ts b/src/features/history-chart/useHistoryChartSeries.ts index cd5bac2..32234e5 100644 --- a/src/features/history-chart/useHistoryChartSeries.ts +++ b/src/features/history-chart/useHistoryChartSeries.ts @@ -3,12 +3,12 @@ import type { SeriesOption } from 'echarts'; import { createSeriesOptions } from './historyChartSeries'; import type { HistoryChartLineData } from './useHistoryChartQueries'; -export function useHistoryChartSeries(lines: HistoryChartLineData[]): SeriesOption[] { +export function useHistoryChartSeries(lines: HistoryChartLineData[], granulate: string): SeriesOption[] { return useMemo( () => lines.flatMap((line) => - line.rows.length ? createSeriesOptions(line.rows, line.index, line.label) : [], + line.rows.length ? createSeriesOptions(line.rows, line.index, line.label, granulate) : [], ), - [lines], + [granulate, lines], ); } diff --git a/src/utils/historyGranularity.ts b/src/utils/historyGranularity.ts index f008f35..a0c6c1d 100644 --- a/src/utils/historyGranularity.ts +++ b/src/utils/historyGranularity.ts @@ -25,6 +25,19 @@ const WEEK = 7 * DAY; const MONTH = 30 * DAY; const YEAR = 365 * DAY; +const GRANULATE_UNIT_MS: Record = { + second: SECOND, + seconds: SECOND, + minute: MINUTE, + minutes: MINUTE, + hour: HOUR, + hours: HOUR, + day: DAY, + days: DAY, + week: WEEK, + weeks: WEEK, +}; + type Rule = { maxMs: number; granulate: string; @@ -60,3 +73,12 @@ export function getHistoryGranularity(from: string, to: string): HistoryGranular } ); } + +/** Переводит текст грануляции API вроде "10 minutes" в миллисекунды. */ +export function parseGranulateMs(granulate: string): number { + const [amountText, unit = ''] = granulate.trim().split(/\s+/); + const amount = Number(amountText); + const unitMs = GRANULATE_UNIT_MS[unit.toLowerCase()]; + + return Number.isFinite(amount) && unitMs ? amount * unitMs : MINUTE; +}