5 Commits

7 changed files with 90 additions and 12 deletions

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

@@ -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 };