diff --git a/src/features/history-chart/HistoryChart.tsx b/src/features/history-chart/HistoryChart.tsx index 1e6f705..dd66c93 100644 --- a/src/features/history-chart/HistoryChart.tsx +++ b/src/features/history-chart/HistoryChart.tsx @@ -1,6 +1,6 @@ -import { useCallback, useMemo, useRef } from 'react'; -import type { HistoryAxisLabelFormat } from '../../utils/historyGranularity'; -import type { DataZoomEventBatch, DataZoomState } from './chartTypes'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { getHistoryGranularity, type HistoryAxisLabelFormat } from '../../utils/historyGranularity'; +import type { DataZoomEventBatch, DataZoomState, HistoryZoomRange } from './chartTypes'; import { HistoryChartArea } from './HistoryChartArea'; import { createHistoryChartOptions } from './historyChartOptions'; import { useHistoryChartQueries } from './useHistoryChartQueries'; @@ -17,11 +17,75 @@ type HistoryChartProps = { tagLabels?: Record; }; -/** Контейнер истории: загружает выбранные теги и передает готовые серии в область ECharts. */ +const ZOOM_REQUEST_DELAY_MS = 350; + function isXAxisDataZoom(state: DataZoomState): boolean { return state.dataZoomId?.startsWith('history-x-') || state.dataZoomIndex === 0 || state.dataZoomIndex === 1; } +/** Собирает начальный zoom-диапазон из внешних props графика. */ +function createInitialZoomRange({ + from, + granulate, + labelFormat, + tickIntervalMs, + to, +}: Pick): HistoryZoomRange { + return { from, to, granulate, labelFormat, tickIntervalMs }; +} + +/** Создает zoom-диапазон из миллисекунд и подбирает для него грануляцию. */ +function createZoomRange(fromMs: number, toMs: number): HistoryZoomRange { + const from = new Date(fromMs).toISOString(); + const to = new Date(toMs).toISOString(); + + return { + from, + to, + ...getHistoryGranularity(from, to), + }; +} + +/** Достает подинтервал X-зума из события ECharts. */ +function getZoomRangeFromEvent(state: DataZoomState, baseRange: HistoryZoomRange): HistoryZoomRange | null { + const baseFromMs = new Date(baseRange.from).getTime(); + const baseToMs = new Date(baseRange.to).getTime(); + const baseSpanMs = baseToMs - baseFromMs; + + if (!Number.isFinite(baseFromMs) || !Number.isFinite(baseToMs) || baseSpanMs <= 0) { + return null; + } + + const startValue = Number(state.startValue); + const endValue = Number(state.endValue); + let nextFromMs = Number.isFinite(startValue) ? startValue : undefined; + let nextToMs = Number.isFinite(endValue) ? endValue : undefined; + + // ECharts иногда не передает startValue/endValue, поэтому используем start/end. + if (nextFromMs === undefined || nextToMs === undefined) { + const start = Number(state.start); + const end = Number(state.end); + + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return null; + } + + nextFromMs = baseFromMs + (baseSpanMs * start) / 100; + nextToMs = baseFromMs + (baseSpanMs * end) / 100; + } + + // Проверка на выход из диапазона + const fromMs = Math.max(baseFromMs, Math.min(nextFromMs, nextToMs)); + const toMs = Math.min(baseToMs, Math.max(nextFromMs, nextToMs)); + + return toMs > fromMs ? createZoomRange(fromMs, toMs) : null; +} + +/** Проверяет, что lazy zoom не пытается повторно применить тот же диапазон. */ +function isSameZoomRange(left: HistoryZoomRange, right: HistoryZoomRange): boolean { + return left.from === right.from && left.to === right.to && left.granulate === right.granulate; +} + export function HistoryChart({ edge, from, @@ -32,24 +96,54 @@ export function HistoryChart({ to, tagLabels = {}, }: HistoryChartProps) { - const dataZoomRef = useRef(null); - const lines = useHistoryChartQueries({ edge, from, granulate, tags, to, tagLabels }); - const series = useHistoryChartSeries(lines, granulate); + const [zoomRange, setZoomRange] = useState(() => + createInitialZoomRange({ from, granulate, labelFormat, tickIntervalMs, to }), + ); + const zoomRangeRef = useRef(zoomRange); + const zoomTimerRef = useRef | null>(null); + const lines = useHistoryChartQueries({ + edge, + from: zoomRange.from, + granulate: zoomRange.granulate, + tags, + to: zoomRange.to, + tagLabels, + }); + const series = useHistoryChartSeries(lines, zoomRange.granulate); const legendData = useMemo(() => lines.map((line) => line.label), [lines]); const loading = lines.some((line) => line.loading); + useEffect(() => { + const nextRange = createInitialZoomRange({ from, granulate, labelFormat, tickIntervalMs, to }); + zoomRangeRef.current = nextRange; + setZoomRange(nextRange); + }, [from, granulate, labelFormat, tickIntervalMs, to]); + + useEffect(() => { + zoomRangeRef.current = zoomRange; + }, [zoomRange]); + + useEffect( + () => () => { + if (zoomTimerRef.current) { + clearTimeout(zoomTimerRef.current); + } + }, + [], + ); + const option = useMemo( () => createHistoryChartOptions({ - dataZoomState: dataZoomRef.current ?? {}, - from, - to, - labelFormat, + dataZoomState: { start: 0, end: 100 }, + from: zoomRange.from, + to: zoomRange.to, + labelFormat: zoomRange.labelFormat, legendData, series, - tickIntervalMs, + tickIntervalMs: zoomRange.tickIntervalMs, }), - [from, labelFormat, legendData, series, tickIntervalMs, to], + [legendData, series, zoomRange], ); const handleDataZoom = useCallback((event: DataZoomEventBatch) => { @@ -59,12 +153,21 @@ export function HistoryChart({ return; } - dataZoomRef.current = { - start: state.start, - end: state.end, - startValue: state.startValue, - endValue: state.endValue, - }; + const currentRange = zoomRangeRef.current; + const nextRange = getZoomRangeFromEvent(state, currentRange); + + if (!nextRange || isSameZoomRange(nextRange, currentRange)) { + return; + } + + if (zoomTimerRef.current) { + clearTimeout(zoomTimerRef.current); + } + + zoomTimerRef.current = setTimeout(() => { + zoomRangeRef.current = nextRange; + setZoomRange(nextRange); + }, ZOOM_REQUEST_DELAY_MS); }, []); return ( diff --git a/src/features/history-chart/chartTypes.ts b/src/features/history-chart/chartTypes.ts index 3d76188..a9ae1f2 100644 --- a/src/features/history-chart/chartTypes.ts +++ b/src/features/history-chart/chartTypes.ts @@ -1,7 +1,17 @@ +import type { HistoryAxisLabelFormat } from '../../utils/historyGranularity'; + export type AvgPointValue = [time: number, avg: number, min: number, max: number, count: number]; export type HistoryBucketValue = [...AvgPointValue, slotMs: number]; +export type HistoryZoomRange = { + from: string; + to: string; + granulate: string; + tickIntervalMs?: number; + labelFormat?: HistoryAxisLabelFormat; +}; + export type TooltipParam = { color?: string; marker?: string;