7 Commits

Author SHA1 Message Date
Первов Артем
e8c5dbc489 BUR-41 MetricWidget обогощен информацией единицы измерения, идентификатором тега, а также выставлены акценты 2026-07-05 23:26:13 +03:00
cf1dea2767 Merge pull request 'BUR-61 добавлена возможность сохранять и отображать current.value = null значений' (#22) from bur-61 into dev
Reviewed-on: #22
2026-07-05 19:16:02 +00:00
Первов Артем
3d395aaa8d BUR-61 добавлена возможность сохранять и отображать current.value = null значений 2026-07-05 21:43:32 +03:00
e396fbbb8a Merge pull request 'BUR-39 Добавлена возможность вертикального и горизонтального зума без эффекта друг дргуа' (#21) from bur-39 into dev
Reviewed-on: #21
2026-07-05 18:15:29 +00:00
Первов Артем
c6e073c5dd BUR-39 Добавлена возможность вертикального и горизонтального зума без эффекта друг дргуа 2026-07-05 21:12:07 +03:00
2750f0efaf Merge pull request 'BUR-58 Добавлена оптимальная конфигурация для потока видео' (#20) from bur-58 into dev
Reviewed-on: #20
2026-07-05 14:46:19 +00:00
Первов Артем
f84b15b2d0 BUR-58 Добавлена оптимальная конфигурация для потока видео 2026-07-05 17:41:34 +03:00
10 changed files with 176 additions and 29 deletions

View File

@@ -6,6 +6,21 @@ type CameraViewProps = {
wsUrl: string;
};
const CAMERA_PLAYER_CONFIG = {
enableWorker: true,
enableStashBuffer: true,
stashInitialSize: 512,
autoCleanupSourceBuffer: true,
autoCleanupMaxBackwardDuration: 10,
autoCleanupMinBackwardDuration: 5,
liveBufferLatencyChasing: true,
liveBufferLatencyMaxLatency: 5.0,
liveBufferLatencyMinRemain: 2.0,
liveSync: false,
lazyLoad: false,
deferLoadAfterSourceOpen: false,
};
export function CameraView({ title = 'Камера', wsUrl }: CameraViewProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -13,6 +28,7 @@ export function CameraView({ title = 'Камера', wsUrl }: CameraViewProps) {
useEffect(() => {
const video = videoRef.current;
let player: ReturnType<typeof mpegts.createPlayer> | null = null;
const handlePlayerError = () => setError('Ошибка видеопотока');
if (!video) {
return;
@@ -30,20 +46,22 @@ export function CameraView({ title = 'Камера', wsUrl }: CameraViewProps) {
}
player = mpegts.createPlayer(
{ type: 'mpegts', isLive: true, url: wsUrl },
{ liveBufferLatencyChasing: true, liveBufferLatencyMaxLatency: 1.5 },
{ type: 'mpegts', isLive: true, url: wsUrl, hasAudio: false },
CAMERA_PLAYER_CONFIG,
);
player.on(mpegts.Events.ERROR, handlePlayerError);
player.attachMediaElement(video);
player.load();
const playResult = player.play();
if (playResult instanceof Promise) {
void playResult.then(() => setError(null)).catch(() => setError('Не удалось запустить воспроизведение'));
void playResult.then(() => setError(null)).catch(() => undefined);
}
return () => {
video.removeEventListener('playing', clearError);
player?.off(mpegts.Events.ERROR, handlePlayerError);
player?.pause();
player?.unload();
player?.detachMediaElement();

View File

@@ -6,12 +6,17 @@ type MetricWidgetProps = {
};
export function MetricWidget({ item }: MetricWidgetProps) {
const title = item.name?.trim() || item.tag;
const name = item.name?.trim() || item.tag;
const unit = item.unitOfMeasurement?.trim();
return (
<article className="metric-widget" title={`${title} (${item.tag})`}>
<span className="metric-widget__tag">{title}</span>
<strong className="metric-widget__value">{formatNumber(item.value)}</strong>
<article className="metric-widget" title={`${name} (${item.tag})`}>
<span className="metric-widget__id">{item.tag}</span>
<span className="metric-widget__name">{name}</span>
<span className="metric-widget__reading">
<strong className="metric-widget__value">{formatNumber(item.value)}</strong>
{unit ? <span className="metric-widget__unit">{unit}</span> : null}
</span>
</article>
);
}

View File

@@ -1,7 +1,7 @@
export type CurrentItem = {
edge: string;
tag: string;
value: number;
value: number | null;
createdAt: string;
updatedAt: string;
time: string;

View File

@@ -5,7 +5,7 @@ import { CalendarDays, CalendarClock, Check, ChevronDown, Clock3, DatabaseZap, S
import type { CurrentItem } from '../../../entities/current/types';
import { HistoryChart } from '../../../features/history-chart/HistoryChart';
import { RANGE_PRESETS, createRange, type DateRangeState } from '../../../features/history/dateRange';
import { toIsoFromInput } from '../../../utils/format';
import { formatNumber, toIsoFromInput } from '../../../utils/format';
import type { HistoryGranularity } from '../../../utils/historyGranularity';
type ArchiveViewProps = {
@@ -191,7 +191,7 @@ export function ArchiveView({
{label !== item.tag ? <small>{item.tag}</small> : null}
</span>
<span className="tag-select-item__side">
<strong>{item.value.toLocaleString('ru-RU', { maximumFractionDigits: item.precision ?? 3 })}</strong>
<strong>{formatNumber(item.value)}</strong>
{selected ? <Check size={15} /> : null}
</span>
</button>

View File

@@ -29,8 +29,8 @@ export function ElectricalNode({ liveValues, node, ownerEdgeId }: ElectricalNode
? getLiveValue(liveValues, node.edgeId || ownerEdgeId, node.tagId)
: getLiveValue(liveValues, bindingEdgeId, bindings?.stateTagId);
const alarmValue = node.kind === 'decoration' ? getLiveValue(liveValues, bindingEdgeId, bindings?.alarmTagId) : undefined;
const alarmActive = Boolean(alarmValue && Number(alarmValue.value) !== 0);
const stateActive = Boolean(mainValue && Number(mainValue.value) !== 0);
const alarmActive = Boolean(alarmValue && alarmValue.value !== null && alarmValue.value !== 0);
const stateActive = Boolean(mainValue && mainValue.value !== null && mainValue.value !== 0);
const title = getNodeTitle(node, mainValue);
const style: CSSProperties = {
left: node.position.x,

View File

@@ -1,6 +1,7 @@
import type { EChartsOption } from 'echarts';
import ReactEChartsCore from 'echarts-for-react/esm/core.js';
import { useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { WheelEvent } from 'react';
import type { DataZoomEventBatch } from './chartTypes';
import { echarts } from './historyChartEcharts';
@@ -12,6 +13,21 @@ type HistoryChartAreaProps = {
onDataZoom: (event: DataZoomEventBatch) => void;
};
/** Включает или выключает нативный X-зум колесом, чтобы Shift + колесо работал только по Y. */
function setXAxisWheelZoom(chart: ReturnType<ReactEChartsCore['getEchartsInstance']>, enabled: boolean): void {
chart.setOption(
{
dataZoom: [
{
id: 'history-x-inside',
zoomOnMouseWheel: enabled,
},
],
},
{ lazyUpdate: true },
);
}
/** Отвечает только за визуальную область графика: placeholder или ECharts canvas. */
export function HistoryChartArea({
hasData,
@@ -20,12 +36,55 @@ export function HistoryChartArea({
option,
onDataZoom,
}: HistoryChartAreaProps) {
const chartRef = useRef<InstanceType<typeof ReactEChartsCore> | null>(null);
const xWheelZoomEnabledRef = useRef(true);
const onChartEvents = useMemo(
() => ({
datazoom: onDataZoom,
}),
[onDataZoom],
);
const updateXAxisWheelZoom = useCallback((enabled: boolean) => {
const chart = chartRef.current?.getEchartsInstance();
if (!chart || xWheelZoomEnabledRef.current === enabled) {
return;
}
xWheelZoomEnabledRef.current = enabled;
setXAxisWheelZoom(chart, enabled);
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
updateXAxisWheelZoom(false);
}
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
updateXAxisWheelZoom(true);
}
};
const handleBlur = () => updateXAxisWheelZoom(true);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', handleBlur);
};
}, [updateXAxisWheelZoom]);
const handleWheelCapture = useCallback(
(event: WheelEvent<HTMLDivElement>) => {
updateXAxisWheelZoom(!event.shiftKey);
},
[updateXAxisWheelZoom],
);
if (!hasSelection) {
return <div className="chart-placeholder">Разверните список показателей и выберите серию для графика</div>;
@@ -39,5 +98,16 @@ export function HistoryChartArea({
return <div className="chart-placeholder">Нет данных для выбранного диапазона</div>;
}
return <ReactEChartsCore echarts={echarts} option={option} className="history-chart" onEvents={onChartEvents} lazyUpdate />;
return (
<div className="history-chart-shell" onWheelCapture={handleWheelCapture}>
<ReactEChartsCore
ref={chartRef}
echarts={echarts}
option={option}
className="history-chart"
onEvents={onChartEvents}
lazyUpdate
/>
</div>
);
}

View File

@@ -1,8 +1,14 @@
.history-chart-shell,
.history-chart {
width: 100%;
height: 440px;
}
.history-chart-shell .history-chart {
height: 100%;
}
.chart-section--archive .history-chart-shell,
.chart-section--archive .history-chart {
flex: 1;
min-height: 520px;
@@ -67,6 +73,7 @@
}
@media (max-width: 680px) {
.chart-section--archive .history-chart-shell,
.chart-section--archive .history-chart,
.chart-section--archive .chart-placeholder {
min-height: 420px;

View File

@@ -15,7 +15,7 @@
height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
gap: 5px;
padding: 12px;
border: 1px solid rgba(88, 103, 121, 0.42);
border-radius: 8px;
@@ -27,28 +27,74 @@
0 6px 14px rgba(0, 0, 0, 0.28);
}
.metric-widget__tag {
flex: 0 0 auto;
.metric-widget__id,
.metric-widget__name,
.metric-widget__reading,
.metric-widget__value {
min-width: 0;
color: #cbd5e1;
font-size: 0.74rem;
font-weight: 600;
}
.metric-widget__id,
.metric-widget__name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.metric-widget__id {
flex: 0 0 auto;
color: #64748b;
font-size: 0.62rem;
font-weight: 650;
letter-spacing: 0.02em;
line-height: 1.2;
white-space: nowrap;
}
.metric-widget__name {
flex: 0 0 auto;
color: #e5e7eb;
font-size: 0.82rem;
font-weight: 760;
line-height: 1.35;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.metric-widget__value {
.metric-widget__reading {
margin-top: auto;
align-self: flex-end;
min-width: 0;
display: inline-flex;
align-items: baseline;
justify-content: flex-end;
gap: 5px;
max-width: 100%;
text-align: right;
}
.metric-widget__value {
color: #f8fafc;
font-size: 1.1rem;
font-weight: 700;
font-size: 1.22rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1.2;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-widget__unit {
flex: 0 1 auto;
min-width: 0;
color: #94a3b8;
font-size: 0.72rem;
font-weight: 650;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-mosaic {

View File

@@ -1,7 +1,7 @@
/** Форматирует числовое значение показателя для карточек и мини-плиток. */
export function formatNumber(value: number): string {
if (!Number.isFinite(value)) {
return '-';
export function formatNumber(value: number | null): string {
if (value === null || !Number.isFinite(value)) {
return '';
}
return new Intl.NumberFormat('ru-RU', {

View File

@@ -16,8 +16,9 @@ function isConfiguredLimit(value: number | null): value is number {
export function getMetricStatus(item: CurrentItem, now = Date.now()): MetricStatusInfo {
const measuredAt = new Date(item.time).getTime();
const ageSeconds = Number.isFinite(measuredAt) ? Math.max(0, Math.round((now - measuredAt) / 1000)) : Number.POSITIVE_INFINITY;
const belowMin = isConfiguredLimit(item.min) && item.value < item.min;
const aboveMax = isConfiguredLimit(item.max) && item.value > item.max;
const value = item.value;
const belowMin = isConfiguredLimit(value) && isConfiguredLimit(item.min) && value < item.min;
const aboveMax = isConfiguredLimit(value) && isConfiguredLimit(item.max) && value > item.max;
if (belowMin || aboveMax) {
return { status: 'critical', label: 'за пределами уставки', ageSeconds };