3 Commits

4 changed files with 70 additions and 26 deletions

View File

@@ -4,9 +4,8 @@ import { useRef } from 'react';
import { CalendarDays, CalendarClock, Check, ChevronDown, Clock3, DatabaseZap, Search } from 'lucide-react'; import { CalendarDays, CalendarClock, Check, ChevronDown, Clock3, DatabaseZap, Search } from 'lucide-react';
import type { CurrentItem } from '../../../entities/current/types'; import type { CurrentItem } from '../../../entities/current/types';
import { HistoryChart } from '../../../features/history-chart/HistoryChart'; import { HistoryChart } from '../../../features/history-chart/HistoryChart';
import type { HistoryZoomRange } from '../../../features/history-chart/chartTypes';
import { RANGE_PRESETS, createRange, type DateRangeState } from '../../../features/history/dateRange'; import { RANGE_PRESETS, createRange, type DateRangeState } from '../../../features/history/dateRange';
import { toInputDateTimeValue, toIsoFromInput } from '../../../utils/format'; import { toIsoFromInput } from '../../../utils/format';
import type { HistoryGranularity } from '../../../utils/historyGranularity'; import type { HistoryGranularity } from '../../../utils/historyGranularity';
type ArchiveViewProps = { type ArchiveViewProps = {
@@ -109,12 +108,6 @@ export function ArchiveView({
[rangePart]: updateRangeInputPart(range[rangePart], inputPart, value), [rangePart]: updateRangeInputPart(range[rangePart], inputPart, value),
}); });
}; };
const updateRangeFromZoom = (nextRange: HistoryZoomRange) => {
onRangeChange({
from: toInputDateTimeValue(new Date(nextRange.from)),
to: toInputDateTimeValue(new Date(nextRange.to)),
});
};
return ( return (
<section className="chart-section chart-section--archive"> <section className="chart-section chart-section--archive">
@@ -270,7 +263,6 @@ export function ArchiveView({
labelFormat={historyAxis.labelFormat} labelFormat={historyAxis.labelFormat}
tags={selectedTags} tags={selectedTags}
tagLabels={tagLabels} tagLabels={tagLabels}
onZoomRangeChange={updateRangeFromZoom}
/> />
) : ( ) : (
<div className="chart-placeholder">Разверните список показателей и выберите серию для графика</div> <div className="chart-placeholder">Разверните список показателей и выберите серию для графика</div>

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { EChartsOption } from 'echarts';
import { getHistoryGranularity, type HistoryAxisLabelFormat } from '../../utils/historyGranularity'; import { getHistoryGranularity, type HistoryAxisLabelFormat } from '../../utils/historyGranularity';
import type { DataZoomEventBatch, DataZoomState, HistoryZoomRange } from './chartTypes'; import type { DataZoomEventBatch, DataZoomState, HistoryZoomRange } from './chartTypes';
import { HistoryChartArea } from './HistoryChartArea'; import { HistoryChartArea } from './HistoryChartArea';
@@ -15,7 +16,6 @@ type HistoryChartProps = {
tickIntervalMs?: number; tickIntervalMs?: number;
to: string; to: string;
tagLabels?: Record<string, string>; tagLabels?: Record<string, string>;
onZoomRangeChange?: (range: HistoryZoomRange) => void;
}; };
const ZOOM_REQUEST_DELAY_MS = 350; const ZOOM_REQUEST_DELAY_MS = 350;
@@ -87,6 +87,39 @@ function isSameZoomRange(left: HistoryZoomRange, right: HistoryZoomRange): boole
return left.from === right.from && left.to === right.to && left.granulate === right.granulate; return left.from === right.from && left.to === right.to && left.granulate === right.granulate;
} }
/** Вычисляет, какую часть верхнеуровневого периода занимает текущий пользовательский zoom. */
function createDataZoomState(baseRange: HistoryZoomRange, zoomRange: HistoryZoomRange): DataZoomState {
// Верхнеуровневый период берём из инпутов "С/По" и считаем его полными 0..100%.
const baseFromMs = new Date(baseRange.from).getTime();
const baseToMs = new Date(baseRange.to).getTime();
// Пользовательский zoom живёт внутри верхнеуровневого периода и должен быть выделен на нижней шкале.
const zoomFromMs = new Date(zoomRange.from).getTime();
const zoomToMs = new Date(zoomRange.to).getTime();
const baseSpanMs = baseToMs - baseFromMs;
// Если даты некорректны, показываем весь период, чтобы не ломать управление графиком.
if (
!Number.isFinite(baseFromMs) ||
!Number.isFinite(baseToMs) ||
!Number.isFinite(zoomFromMs) ||
!Number.isFinite(zoomToMs) ||
baseSpanMs <= 0
) {
return { start: 0, end: 100 };
}
// Переводим абсолютные даты zoom-а в проценты относительно верхнеуровневого периода.
const start = ((zoomFromMs - baseFromMs) / baseSpanMs) * 100;
const end = ((zoomToMs - baseFromMs) / baseSpanMs) * 100;
// Ограничиваем значения диапазоном 0..100, чтобы ECharts не получил выход за шкалу.
return {
start: Math.max(0, Math.min(100, start)),
end: Math.max(0, Math.min(100, end)),
};
}
export function HistoryChart({ export function HistoryChart({
edge, edge,
from, from,
@@ -96,13 +129,18 @@ export function HistoryChart({
tickIntervalMs, tickIntervalMs,
to, to,
tagLabels = {}, tagLabels = {},
onZoomRangeChange,
}: HistoryChartProps) { }: HistoryChartProps) {
const baseRange = useMemo(
() => createInitialZoomRange({ from, granulate, labelFormat, tickIntervalMs, to }),
[from, granulate, labelFormat, tickIntervalMs, to],
);
const [zoomRange, setZoomRange] = useState(() => const [zoomRange, setZoomRange] = useState(() =>
createInitialZoomRange({ from, granulate, labelFormat, tickIntervalMs, to }), createInitialZoomRange({ from, granulate, labelFormat, tickIntervalMs, to }),
); );
const baseRangeRef = useRef(baseRange);
const zoomRangeRef = useRef(zoomRange); const zoomRangeRef = useRef(zoomRange);
const zoomTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const zoomTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastReadyOptionRef = useRef<EChartsOption | null>(null);
const lines = useHistoryChartQueries({ const lines = useHistoryChartQueries({
edge, edge,
from: zoomRange.from, from: zoomRange.from,
@@ -114,12 +152,13 @@ export function HistoryChart({
const series = useHistoryChartSeries(lines, zoomRange.granulate); const series = useHistoryChartSeries(lines, zoomRange.granulate);
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;
useEffect(() => { useEffect(() => {
const nextRange = createInitialZoomRange({ from, granulate, labelFormat, tickIntervalMs, to }); baseRangeRef.current = baseRange;
zoomRangeRef.current = nextRange; zoomRangeRef.current = baseRange;
setZoomRange(nextRange); setZoomRange(baseRange);
}, [from, granulate, labelFormat, tickIntervalMs, to]); }, [baseRange]);
useEffect(() => { useEffect(() => {
zoomRangeRef.current = zoomRange; zoomRangeRef.current = zoomRange;
@@ -137,17 +176,29 @@ export function HistoryChart({
const option = useMemo( const option = useMemo(
() => () =>
createHistoryChartOptions({ createHistoryChartOptions({
dataZoomState: { start: 0, end: 100 }, dataZoomState: createDataZoomState(baseRange, zoomRange),
from: zoomRange.from, from: baseRange.from,
to: zoomRange.to, to: baseRange.to,
labelFormat: zoomRange.labelFormat, labelFormat: zoomRange.labelFormat,
legendData, legendData,
series, series,
tickIntervalMs: zoomRange.tickIntervalMs, tickIntervalMs: zoomRange.tickIntervalMs,
}), }),
[legendData, series, zoomRange], [baseRange, legendData, series, zoomRange],
); );
// Во время lazy-загрузки нового zoom-диапазона оставляем на экране последний готовый график.
// Так canvas не заменяется loader-ом и пользователь не теряет визуальный контекст.
const fallbackOption = loading && !hasData ? lastReadyOptionRef.current : null;
const displayOption = fallbackOption ?? option;
const displayHasData = hasData || Boolean(fallbackOption);
useEffect(() => {
if (hasData) {
lastReadyOptionRef.current = option;
}
}, [hasData, option]);
const handleDataZoom = useCallback((event: DataZoomEventBatch) => { const handleDataZoom = useCallback((event: DataZoomEventBatch) => {
const state = (event.batch ?? [event]).find(isXAxisDataZoom); const state = (event.batch ?? [event]).find(isXAxisDataZoom);
@@ -155,8 +206,9 @@ export function HistoryChart({
return; return;
} }
const baseRange = baseRangeRef.current;
const currentRange = zoomRangeRef.current; const currentRange = zoomRangeRef.current;
const nextRange = getZoomRangeFromEvent(state, currentRange); const nextRange = getZoomRangeFromEvent(state, baseRange);
if (!nextRange || isSameZoomRange(nextRange, currentRange)) { if (!nextRange || isSameZoomRange(nextRange, currentRange)) {
return; return;
@@ -169,16 +221,15 @@ export function HistoryChart({
zoomTimerRef.current = setTimeout(() => { zoomTimerRef.current = setTimeout(() => {
zoomRangeRef.current = nextRange; zoomRangeRef.current = nextRange;
setZoomRange(nextRange); setZoomRange(nextRange);
onZoomRangeChange?.(nextRange);
}, ZOOM_REQUEST_DELAY_MS); }, ZOOM_REQUEST_DELAY_MS);
}, [onZoomRangeChange]); }, []);
return ( return (
<HistoryChartArea <HistoryChartArea
hasData={series.length > 0} hasData={displayHasData}
hasSelection={tags.length > 0} hasSelection={tags.length > 0}
loading={loading} loading={loading}
option={option} option={displayOption}
onDataZoom={handleDataZoom} onDataZoom={handleDataZoom}
/> />
); );

View File

@@ -31,7 +31,7 @@ export function HistoryChartArea({
return <div className="chart-placeholder">Разверните список показателей и выберите серию для графика</div>; return <div className="chart-placeholder">Разверните список показателей и выберите серию для графика</div>;
} }
if (loading) { if (!hasData && loading) {
return <div className="chart-placeholder">Загрузка графика...</div>; return <div className="chart-placeholder">Загрузка графика...</div>;
} }

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQueries } from '@tanstack/react-query'; import { keepPreviousData, useQueries } from '@tanstack/react-query';
import { getHistory } from '../../entities/history/api'; import { getHistory } from '../../entities/history/api';
import type { HistoryPoint } from '../../entities/history/types'; import type { HistoryPoint } from '../../entities/history/types';
@@ -35,6 +35,7 @@ export function useHistoryChartQueries({
queryKey: ['history', edge, tag, from, to, granulate], queryKey: ['history', edge, tag, from, to, granulate],
queryFn: ({ signal }: { signal: AbortSignal }) => getHistory({ edge, tag, from, to, granulate }, signal), queryFn: ({ signal }: { signal: AbortSignal }) => getHistory({ edge, tag, from, to, granulate }, signal),
enabled, enabled,
placeholderData: keepPreviousData,
})), })),
}); });