6 Commits

4 changed files with 152 additions and 44 deletions

View File

@@ -1,3 +1,4 @@
import mpegts from 'mpegts.js';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
type CameraViewProps = { type CameraViewProps = {
@@ -11,8 +12,7 @@ export function CameraView({ title = 'Камера', wsUrl }: CameraViewProps) {
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
let player: import('mpegts.js').default.Player | null = null; let player: ReturnType<typeof mpegts.createPlayer> | null = null;
let cancelled = false;
if (!video) { if (!video) {
return; return;
@@ -22,14 +22,11 @@ export function CameraView({ title = 'Камера', wsUrl }: CameraViewProps) {
video.addEventListener('playing', clearError); video.addEventListener('playing', clearError);
setError(null); setError(null);
void import('mpegts.js').then(({ default: mpegts }) => {
if (cancelled) {
return;
}
if (!mpegts.isSupported()) { if (!mpegts.isSupported()) {
setError('Браузер не поддерживает MPEG-TS поток'); setError('Браузер не поддерживает MPEG-TS поток');
return; return () => {
video.removeEventListener('playing', clearError);
};
} }
player = mpegts.createPlayer( player = mpegts.createPlayer(
@@ -44,10 +41,8 @@ export function CameraView({ title = 'Камера', wsUrl }: CameraViewProps) {
if (playResult instanceof Promise) { if (playResult instanceof Promise) {
void playResult.then(() => setError(null)).catch(() => setError('Не удалось запустить воспроизведение')); void playResult.then(() => setError(null)).catch(() => setError('Не удалось запустить воспроизведение'));
} }
});
return () => { return () => {
cancelled = true;
video.removeEventListener('playing', clearError); video.removeEventListener('playing', clearError);
player?.pause(); player?.pause();
player?.unload(); player?.unload();

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { HistoryAxisLabelFormat } from '../../utils/historyGranularity'; import { getHistoryGranularity, type HistoryAxisLabelFormat } from '../../utils/historyGranularity';
import type { DataZoomEventBatch, DataZoomState } from './chartTypes'; import type { DataZoomEventBatch, DataZoomState, HistoryZoomRange } from './chartTypes';
import { HistoryChartArea } from './HistoryChartArea'; import { HistoryChartArea } from './HistoryChartArea';
import { createHistoryChartOptions } from './historyChartOptions'; import { createHistoryChartOptions } from './historyChartOptions';
import { useHistoryChartQueries } from './useHistoryChartQueries'; import { useHistoryChartQueries } from './useHistoryChartQueries';
@@ -17,11 +17,75 @@ type HistoryChartProps = {
tagLabels?: Record<string, string>; tagLabels?: Record<string, string>;
}; };
/** Контейнер истории: загружает выбранные теги и передает готовые серии в область ECharts. */ const ZOOM_REQUEST_DELAY_MS = 350;
function isXAxisDataZoom(state: DataZoomState): boolean { function isXAxisDataZoom(state: DataZoomState): boolean {
return state.dataZoomId?.startsWith('history-x-') || state.dataZoomIndex === 0 || state.dataZoomIndex === 1; return state.dataZoomId?.startsWith('history-x-') || state.dataZoomIndex === 0 || state.dataZoomIndex === 1;
} }
/** Собирает начальный zoom-диапазон из внешних props графика. */
function createInitialZoomRange({
from,
granulate,
labelFormat,
tickIntervalMs,
to,
}: Pick<HistoryChartProps, 'from' | 'granulate' | 'labelFormat' | 'tickIntervalMs' | 'to'>): 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({ export function HistoryChart({
edge, edge,
from, from,
@@ -32,24 +96,54 @@ export function HistoryChart({
to, to,
tagLabels = {}, tagLabels = {},
}: HistoryChartProps) { }: HistoryChartProps) {
const dataZoomRef = useRef<DataZoomState | null>(null); const [zoomRange, setZoomRange] = useState(() =>
const lines = useHistoryChartQueries({ edge, from, granulate, tags, to, tagLabels }); createInitialZoomRange({ from, granulate, labelFormat, tickIntervalMs, to }),
const series = useHistoryChartSeries(lines, granulate); );
const zoomRangeRef = useRef(zoomRange);
const zoomTimerRef = useRef<ReturnType<typeof setTimeout> | 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 legendData = useMemo(() => lines.map((line) => line.label), [lines]);
const loading = lines.some((line) => line.loading); 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( const option = useMemo(
() => () =>
createHistoryChartOptions({ createHistoryChartOptions({
dataZoomState: dataZoomRef.current ?? {}, dataZoomState: { start: 0, end: 100 },
from, from: zoomRange.from,
to, to: zoomRange.to,
labelFormat, labelFormat: zoomRange.labelFormat,
legendData, legendData,
series, series,
tickIntervalMs, tickIntervalMs: zoomRange.tickIntervalMs,
}), }),
[from, labelFormat, legendData, series, tickIntervalMs, to], [legendData, series, zoomRange],
); );
const handleDataZoom = useCallback((event: DataZoomEventBatch) => { const handleDataZoom = useCallback((event: DataZoomEventBatch) => {
@@ -59,12 +153,21 @@ export function HistoryChart({
return; return;
} }
dataZoomRef.current = { const currentRange = zoomRangeRef.current;
start: state.start, const nextRange = getZoomRangeFromEvent(state, currentRange);
end: state.end,
startValue: state.startValue, if (!nextRange || isSameZoomRange(nextRange, currentRange)) {
endValue: state.endValue, return;
}; }
if (zoomTimerRef.current) {
clearTimeout(zoomTimerRef.current);
}
zoomTimerRef.current = setTimeout(() => {
zoomRangeRef.current = nextRange;
setZoomRange(nextRange);
}, ZOOM_REQUEST_DELAY_MS);
}, []); }, []);
return ( return (

View File

@@ -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 AvgPointValue = [time: number, avg: number, min: number, max: number, count: number];
export type HistoryBucketValue = [...AvgPointValue, slotMs: number]; export type HistoryBucketValue = [...AvgPointValue, slotMs: number];
export type HistoryZoomRange = {
from: string;
to: string;
granulate: string;
tickIntervalMs?: number;
labelFormat?: HistoryAxisLabelFormat;
};
export type TooltipParam = { export type TooltipParam = {
color?: string; color?: string;
marker?: string; marker?: string;

View File

@@ -60,7 +60,7 @@ function formatChartValue(value: number): string {
}).format(value); }).format(value);
} }
/** Показывает одно значение, если min/avg/max визуально совпадают. */ /** Форматирует значение tooltip: одно число для min=avg=max или среднее с диапазоном. */
function formatTooltipValue(value: AvgPointValue): string { function formatTooltipValue(value: AvgPointValue): string {
const avg = formatChartValue(value[1]); const avg = formatChartValue(value[1]);
const min = formatChartValue(value[2]); const min = formatChartValue(value[2]);
@@ -70,7 +70,7 @@ function formatTooltipValue(value: AvgPointValue): string {
return avg; return avg;
} }
return `min ${min} · avg ${avg} · max ${max}`; return `сред. ${avg} (${min} .. ${max})`;
} }
function isAvgPointValue(value: unknown): value is AvgPointValue { function isAvgPointValue(value: unknown): value is AvgPointValue {