/** * EnergyCharts — self-contained Recharts wrapper for Modbus device trend charts. * * Design decisions (M5 decision 11): * - Recharts is imported ONLY in this file (isolation, easy to swap later). * - The component is fully self-contained: it owns its own data fetching via * useReadings / useMetrics hooks and renders loading/error/empty states. * - Time-range selection is internal; readings are always fetched with a window * + limit cap — never a full-table pull. * - Metric labels and units come from GET /metrics; missing keys in payload are * tolerated (a null data point is emitted so the line simply has a gap). */ // --------------------------------------------------------------------------- // Recharts imports — keep ALL recharts imports inside this file. // --------------------------------------------------------------------------- import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, Legend, ResponsiveContainer, } from 'recharts' import { useState, useMemo } from 'react' import { Stack, Group, Text, Loader, Alert, SegmentedControl, Paper, Title, Badge, Box, } from '@mantine/core' import { useReadings, useMetrics } from './hooks' import type { MetricInfo } from './hooks' // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Must match the limit passed to useReadings below. */ const CHART_READINGS_LIMIT = 1000 // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface EnergyChartsProps { /** UUID of the Modbus device to chart. */ uuid: string /** Friendly name for the chart title. */ deviceName: string } // --------------------------------------------------------------------------- // Time-range presets // --------------------------------------------------------------------------- interface TimePreset { label: string value: string /** How many milliseconds of history to show. */ spanMs: number } const TIME_PRESETS: TimePreset[] = [ { label: '1 h', value: '1h', spanMs: 60 * 60 * 1000 }, { label: '6 h', value: '6h', spanMs: 6 * 60 * 60 * 1000 }, { label: '24 h', value: '24h', spanMs: 24 * 60 * 60 * 1000 }, ] const DEFAULT_PRESET = '1h' /** Derive ISO start/end strings for a given preset. */ function presetWindow(spanMs: number): { start: string; end: string } { const end = new Date() const start = new Date(end.getTime() - spanMs) return { start: start.toISOString(), end: end.toISOString() } } // --------------------------------------------------------------------------- // Metric colour palette — cycles through a fixed set // --------------------------------------------------------------------------- const LINE_COLORS = [ '#4c9cdb', '#f59f00', '#51cf66', '#f03e3e', '#cc5de8', '#20c997', '#fd7e14', '#74c0fc', ] function lineColor(index: number): string { return LINE_COLORS[index % LINE_COLORS.length] } // --------------------------------------------------------------------------- // Helper: format a recorded_at timestamp for the X-axis tick // --------------------------------------------------------------------------- function formatTimeTick(isoString: string): string { try { const d = new Date(isoString) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } catch { return isoString } } // --------------------------------------------------------------------------- // Helper: safe numeric read from payload — returns null if key absent/non-numeric // --------------------------------------------------------------------------- function safePayloadValue( payload: Record | null | undefined, key: string, ): number | null { if (!payload) return null const raw = payload[key] if (raw === null || raw === undefined) return null const n = Number(raw) return Number.isFinite(n) ? n : null } // --------------------------------------------------------------------------- // EnergyCharts component // --------------------------------------------------------------------------- export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) { const [activePreset, setActivePreset] = useState(DEFAULT_PRESET) const selectedPreset = TIME_PRESETS.find((p) => p.value === activePreset) ?? TIME_PRESETS[0] const { start, end } = useMemo( () => presetWindow(selectedPreset.spanMs), // Recompute whenever the preset changes; intentionally excludes Date.now() // so the window doesn't drift on every render — it only resets on tab change. // eslint-disable-next-line react-hooks/exhaustive-deps [activePreset], ) const readingsQuery = useReadings(uuid, { start, end, limit: CHART_READINGS_LIMIT }) const metricsQuery = useMetrics(uuid) // ------------------------------------------------------------------------- // Derive chart data: [{recorded_at, voltage: 230.2, current: 1.3, ...}, ...] // ------------------------------------------------------------------------- const metricsData = metricsQuery.data?.metrics const chartData = useMemo(() => { const metrics: MetricInfo[] = metricsData ?? [] const readings = readingsQuery.data?.items ?? [] return readings.map((row) => { const point: Record = { recorded_at: row.recorded_at, } for (const m of metrics) { // Tolerate missing keys — null produces a gap in the line, not a crash. point[m.key] = safePayloadValue(row.payload as Record, m.key) } return point }) }, [readingsQuery.data, metricsData]) const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? [] // ------------------------------------------------------------------------- // States: loading / error / empty // ------------------------------------------------------------------------- const isLoading = readingsQuery.isLoading || metricsQuery.isLoading const isError = readingsQuery.isError || metricsQuery.isError /** * True when the returned row count equals the limit cap, meaning the window * contains more data than was fetched. The backend returns the most-recent N * rows in this case, so the chart shows the latest segment — but a hint is * shown so the user knows the full window is not displayed. */ const isTruncated = !isLoading && !isError && (readingsQuery.data?.items.length ?? 0) >= CHART_READINGS_LIMIT return ( {/* Header row */} {deviceName} ({ value: p.value, label: p.label }))} data-testid={`energy-charts-preset-${uuid}`} /> {/* Loading */} {isLoading && ( Loading readings… )} {/* Error */} {!isLoading && isError && ( Failed to load readings or metrics. Please try again. )} {/* Empty */} {!isLoading && !isError && chartData.length === 0 && ( No readings in this time window. )} {/* Truncation notice — shown when window has more data than the fetch limit */} {isTruncated && ( 显示最近 {CHART_READINGS_LIMIT} 条;完整长时段走势待降采样 )} {/* Chart */} {!isLoading && !isError && chartData.length > 0 && ( {/* Render one chart per metric for clarity (avoids mixed Y-axis units) */} {metrics.map((metric, idx) => { // Check if this metric has any non-null data points; skip if all null. const hasData = chartData.some((pt) => pt[metric.key] !== null) if (!hasData) return null return ( {metric.label} {metric.unit && ( {metric.unit} )} metric.unit ? `${v} ${metric.unit}` : String(v) } /> { const n = value as number | null | undefined if (n == null) return ['–', metric.label] as [string, string] return [ `${n}${metric.unit ? ' ' + metric.unit : ''}`, metric.label, ] as [string, string] }} labelFormatter={(label) => { try { return new Date(String(label)).toLocaleString() } catch { return String(label) } }} /> ) })} )} ) }