Files
home-automation/frontend/src/energy/EnergyCharts.tsx
T

334 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown> | 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<string>(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<string, string | number | null> = {
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<string, unknown>, 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 (
<Paper withBorder p="md" data-testid={`energy-charts-${uuid}`}>
<Stack gap="sm">
{/* Header row */}
<Group justify="space-between" align="center" wrap="nowrap">
<Title order={4} data-testid={`energy-charts-title-${uuid}`}>
{deviceName}
</Title>
<SegmentedControl
size="xs"
value={activePreset}
onChange={setActivePreset}
data={TIME_PRESETS.map((p) => ({ value: p.value, label: p.label }))}
data-testid={`energy-charts-preset-${uuid}`}
/>
</Group>
{/* Loading */}
{isLoading && (
<Group justify="center" py="lg" data-testid={`energy-charts-loading-${uuid}`}>
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading readings
</Text>
</Group>
)}
{/* Error */}
{!isLoading && isError && (
<Alert color="red" data-testid={`energy-charts-error-${uuid}`}>
Failed to load readings or metrics. Please try again.
</Alert>
)}
{/* Empty */}
{!isLoading && !isError && chartData.length === 0 && (
<Text
size="sm"
c="dimmed"
ta="center"
py="lg"
data-testid={`energy-charts-empty-${uuid}`}
>
No readings in this time window.
</Text>
)}
{/* Truncation notice — shown when window has more data than the fetch limit */}
{isTruncated && (
<Text
size="xs"
c="dimmed"
ta="right"
data-testid={`energy-charts-truncated-${uuid}`}
>
显示最近 {CHART_READINGS_LIMIT} 条;完整长时段走势待降采样
</Text>
)}
{/* Chart */}
{!isLoading && !isError && chartData.length > 0 && (
<Box data-testid={`energy-charts-chart-${uuid}`}>
{/* 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 (
<Box key={metric.key} mb="md">
<Group gap="xs" mb={4}>
<Text size="xs" fw={600} c="dimmed">
{metric.label}
</Text>
{metric.unit && (
<Badge size="xs" variant="outline" color="gray">
{metric.unit}
</Badge>
)}
</Group>
<ResponsiveContainer width="100%" height={160}>
<LineChart
data={chartData}
margin={{ top: 4, right: 8, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" opacity={0.4} />
<XAxis
dataKey="recorded_at"
tickFormatter={formatTimeTick}
tick={{ fontSize: 10 }}
minTickGap={40}
/>
<YAxis
tick={{ fontSize: 10 }}
width={50}
tickFormatter={(v: number) =>
metric.unit ? `${v} ${metric.unit}` : String(v)
}
/>
<RechartsTooltip
formatter={(value) => {
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)
}
}}
/>
<Legend />
<Line
type="monotone"
dataKey={metric.key}
name={metric.label}
stroke={lineColor(idx)}
dot={false}
isAnimationActive={false}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
</Box>
)
})}
</Box>
)}
</Stack>
</Paper>
)
}