diff --git a/src/components/MetricWidget.tsx b/src/components/MetricWidget.tsx index 1718bd9..c8e3f49 100644 --- a/src/components/MetricWidget.tsx +++ b/src/components/MetricWidget.tsx @@ -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 ( -
- {title} - {formatNumber(item.value)} +
+ {item.tag} + {name} + + {formatNumber(item.value)} + {unit ? {unit} : null} +
); } diff --git a/src/entities/current/types.ts b/src/entities/current/types.ts index bfa31cf..bf97b7c 100644 --- a/src/entities/current/types.ts +++ b/src/entities/current/types.ts @@ -1,7 +1,7 @@ export type CurrentItem = { edge: string; tag: string; - value: number; + value: number | null; createdAt: string; updatedAt: string; time: string; diff --git a/src/features/edge-detail/components/ArchiveView.tsx b/src/features/edge-detail/components/ArchiveView.tsx index 9c5909d..ce4dbfd 100644 --- a/src/features/edge-detail/components/ArchiveView.tsx +++ b/src/features/edge-detail/components/ArchiveView.tsx @@ -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 ? {item.tag} : null} - {item.value.toLocaleString('ru-RU', { maximumFractionDigits: item.precision ?? 3 })} + {formatNumber(item.value)} {selected ? : null} diff --git a/src/features/electrical-schematics/ElectricalNode.tsx b/src/features/electrical-schematics/ElectricalNode.tsx index 3b51f56..bf9dfb7 100644 --- a/src/features/electrical-schematics/ElectricalNode.tsx +++ b/src/features/electrical-schematics/ElectricalNode.tsx @@ -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, diff --git a/src/features/history-chart/HistoryChartArea.tsx b/src/features/history-chart/HistoryChartArea.tsx index 2649a14..1130542 100644 --- a/src/features/history-chart/HistoryChartArea.tsx +++ b/src/features/history-chart/HistoryChartArea.tsx @@ -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, 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 | 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) => { + updateXAxisWheelZoom(!event.shiftKey); + }, + [updateXAxisWheelZoom], + ); if (!hasSelection) { return
Разверните список показателей и выберите серию для графика
; @@ -39,5 +98,16 @@ export function HistoryChartArea({ return
Нет данных для выбранного диапазона
; } - return ; + return ( +
+ +
+ ); } diff --git a/src/features/history-chart/chartTypes.ts b/src/features/history-chart/chartTypes.ts index a9ae1f2..f190479 100644 --- a/src/features/history-chart/chartTypes.ts +++ b/src/features/history-chart/chartTypes.ts @@ -1,8 +1,8 @@ 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, slotMs?: number]; -export type HistoryBucketValue = [...AvgPointValue, slotMs: number]; +export type HistoryBucketValue = [time: number, avg: number, min: number, max: number, count: number, slotMs: number]; export type HistoryZoomRange = { from: string; diff --git a/src/features/history-chart/historyChartFormat.ts b/src/features/history-chart/historyChartFormat.ts index 5e2b047..c7dd9ce 100644 --- a/src/features/history-chart/historyChartFormat.ts +++ b/src/features/history-chart/historyChartFormat.ts @@ -60,6 +60,58 @@ function formatChartValue(value: number): string { }).format(value); } +/** Форматирует полную дату и время для начала/конца интервала в tooltip. */ +function formatTooltipDateTime(value: number): string { + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(value)); +} + +/** Форматирует только время, когда конец интервала находится в тот же день. */ +function formatTooltipTime(value: number): string { + return new Intl.DateTimeFormat('ru-RU', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(value)); +} + +/** Проверяет, можно ли показать конец интервала без повторения даты. */ +function isSameDate(first: number, second: number): boolean { + const firstDate = new Date(first); + const secondDate = new Date(second); + + return ( + firstDate.getFullYear() === secondDate.getFullYear() + && firstDate.getMonth() === secondDate.getMonth() + && firstDate.getDate() === secondDate.getDate() + ); +} + +/** Показывает bucket графика как интервал текущей грануляции, а не как одну точку времени. */ +function formatTooltipInterval(value: AvgPointValue): string { + const time = value[0]; + const slotMs = value[5]; + + if (!slotMs) { + return formatTooltipDateTime(time); + } + + const from = time - slotMs / 2; + const to = time + slotMs / 2; + + if (isSameDate(from, to)) { + return `${formatTooltipDateTime(from)} — ${formatTooltipTime(to)}`; + } + + return `${formatTooltipDateTime(from)} — ${formatTooltipDateTime(to)}`; +} + /** Форматирует значение tooltip: одно число для min=avg=max или среднее с диапазоном. */ function formatTooltipValue(value: AvgPointValue): string { const avg = formatChartValue(value[1]); @@ -89,14 +141,7 @@ export function formatTooltip(params: unknown): string { return ''; } - const header = new Intl.DateTimeFormat('ru-RU', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }).format(new Date(firstValue[0])); + const header = formatTooltipInterval(firstValue); const rows = avgItems.map((item) => { const value = item.value as AvgPointValue; diff --git a/src/features/history-chart/historyChartSeries.ts b/src/features/history-chart/historyChartSeries.ts index d35fb15..d4daccc 100644 --- a/src/features/history-chart/historyChartSeries.ts +++ b/src/features/history-chart/historyChartSeries.ts @@ -27,6 +27,22 @@ type CartesianCoordSys = { height: number; }; +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function createAvgGradientStops(color: string, avgOffset: number) { + const softZone = 0.18; + + return [ + { offset: 0, color: `${color}00` }, + { offset: clamp(avgOffset - softZone, 0, 1), color: `${color}22` }, + { offset: avgOffset, color: `${color}70` }, + { offset: clamp(avgOffset + softZone, 0, 1), color: `${color}22` }, + { offset: 1, color: `${color}00` }, + ].sort((first, second) => first.offset - second.offset); +} + function isFinitePoint(point: HistoryPoint): boolean { return [point.avg_value, point.min_value, point.max_value].every(Number.isFinite); } @@ -86,6 +102,8 @@ function renderBucket(color: string) { return null; } + const avgOffset = clamp((avgY - rangeShape.y) / rangeShape.height, 0, 1); + return { type: 'group', children: [ @@ -93,11 +111,7 @@ function renderBucket(color: string) { type: 'rect', shape: rangeShape, style: { - fill: new graphic.LinearGradient(0, 0, 0, 1, [ - { offset: 0, color: `${color}00` }, - { offset: 0.5, color: `${color}66` }, - { offset: 1, color: `${color}00` }, - ]), + fill: new graphic.LinearGradient(0, 0, 0, 1, createAvgGradientStops(color, avgOffset)), }, }, ...(avgShape ? [{ diff --git a/src/styles/charts.css b/src/styles/charts.css index 46f8a77..6b8d3b2 100644 --- a/src/styles/charts.css +++ b/src/styles/charts.css @@ -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; diff --git a/src/styles/components/metrics.css b/src/styles/components/metrics.css index 1cd3504..8d91f76 100644 --- a/src/styles/components/metrics.css +++ b/src/styles/components/metrics.css @@ -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 { diff --git a/src/utils/format.ts b/src/utils/format.ts index 2f27483..e5e81f2 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -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', { diff --git a/src/utils/metricStatus.ts b/src/utils/metricStatus.ts index 10be0bd..6c246d4 100644 --- a/src/utils/metricStatus.ts +++ b/src/utils/metricStatus.ts @@ -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 };