Tile windowing system

This commit is contained in:
Jack Mechem 2026-05-22 02:19:57 -07:00
parent c6e6c5ca48
commit 43318fb8cd
35 changed files with 4659 additions and 360 deletions

603
app/analytics/page.tsx Normal file
View file

@ -0,0 +1,603 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import {
ResponsiveContainer, LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, CartesianGrid, Legend, Brush, ReferenceArea,
} from "recharts";
import { IconRefresh } from "@tabler/icons-react";
import SideNav from "../components/SideNav";
// ── Types ────────────────────────────────────────────────────────────────────
interface DeviceReading { name: string; watts: number; on: boolean; today_wh: number; month_wh: number; }
interface HistoryEntry { ts: string; devices: DeviceReading[]; }
interface Candle { open: number; close: number; high: number; low: number; }
interface CandleBucket { ts: number; label: string; [device: string]: Candle | number | string; }
type ChartType = "line" | "bar" | "candle";
const COST_PER_KWH = 0.24; // LADWP Northridge CA 2025
const DEVICE_COLORS: Record<string, string> = { server: "#428ce2", desktop: "#a78bfa" };
const CHART_MARGIN = { top: 4, right: 16, bottom: 4, left: 0 };
// ── Helpers ──────────────────────────────────────────────────────────────────
function fmtTs(ts: string, spanH: number): string {
const d = new Date(ts);
if (spanH <= 24) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
return d.toLocaleDateString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
}
function fmtDt(iso: string) {
// "2025-01-15T14:30" → "Jan 15, 2:30 PM"
try { return new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); }
catch { return iso; }
}
function bucketCandles(readings: HistoryEntry[], spanH: number): CandleBucket[] {
const bucketMs = (spanH <= 6 ? 30 : spanH <= 24 ? 60 : spanH <= 168 ? 360 : 1440) * 60_000;
const map = new Map<number, Map<string, number[]>>();
for (const r of readings) {
const key = Math.floor(new Date(r.ts).getTime() / bucketMs) * bucketMs;
if (!map.has(key)) map.set(key, new Map());
for (const d of r.devices) {
if (!map.get(key)!.has(d.name)) map.get(key)!.set(d.name, []);
map.get(key)!.get(d.name)!.push(d.watts);
}
}
return Array.from(map.entries()).sort(([a], [b]) => a - b).map(([ts, devices]) => {
const label = fmtTs(new Date(ts).toISOString(), spanH);
const pt: CandleBucket = { ts, label };
for (const [name, watts] of devices) {
const s = [...watts].sort((a, b) => a - b);
pt[name] = { open: watts[0], close: watts[watts.length - 1], high: s[s.length - 1], low: s[0] };
}
return pt;
});
}
function computeSummary(readings: HistoryEntry[], deviceName: string) {
const pts = readings.map(r => {
const d = r.devices.find(x => x.name === deviceName);
return d ? { ts: new Date(r.ts).getTime(), watts: d.watts, on: d.on } : null;
}).filter(Boolean).sort((a, b) => a!.ts - b!.ts) as { ts: number; watts: number; on: boolean }[];
if (!pts.length) return null;
const avgW = pts.reduce((s, p) => s + p.watts, 0) / pts.length;
const peakW = Math.max(...pts.map(p => p.watts));
const uptimePct = pts.filter(p => p.on).length / pts.length * 100;
let kWh = pts.length >= 2
? pts.slice(1).reduce((s, p, i) => s + (p.watts + pts[i].watts) / 2 * (p.ts - pts[i].ts) / 3_600_000_000, 0)
: pts[0].watts / 1000 * (5 / 60);
return { avgW, peakW, kWh, cost: kWh * COST_PER_KWH, uptimePct };
}
function autoYDomain(data: Record<string, number | string>[], names: string[]): [number, number] | null {
const vals = data.flatMap(pt => names.map(n => pt[n]).filter(v => typeof v === "number")) as number[];
if (!vals.length) return null;
const mn = Math.min(...vals), mx = Math.max(...vals);
const pad = Math.max((mx - mn) * 0.08, 5);
return [Math.max(0, mn - pad), mx + pad];
}
// ── Candle chart ─────────────────────────────────────────────────────────────
function CandleChart({ data, activeDevices, yDomain, onYDomainChange, onBrush }: {
data: CandleBucket[];
activeDevices: string[];
yDomain: [number, number] | null;
onYDomainChange: (d: [number, number]) => void;
onBrush: (start: number, end: number) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(600);
const [tooltip, setTooltip] = useState<{ x: number; y: number; bucket: CandleBucket } | null>(null);
const [selecting, setSelecting] = useState<{ startPx: number; endPx: number } | null>(null);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(e => setWidth(e[0].contentRect.width));
ro.observe(el);
setWidth(el.getBoundingClientRect().width);
return () => ro.disconnect();
}, []);
const H = 300, margin = { top: 10, right: 20, bottom: 28, left: 54 };
const innerW = width - margin.left - margin.right;
const innerH = H - margin.top - margin.bottom;
// Y domain
let yMin = Infinity, yMax = -Infinity;
for (const pt of data) for (const n of activeDevices) {
const c = pt[n] as Candle | undefined;
if (c && typeof c === "object") { yMin = Math.min(yMin, c.low); yMax = Math.max(yMax, c.high); }
}
if (yMin === Infinity) { yMin = 0; yMax = 500; }
const pad = Math.max((yMax - yMin) * 0.08, 5);
const [effMin, effMax] = yDomain ?? [Math.max(0, yMin - pad), yMax + pad];
const yScale = (v: number) => margin.top + innerH - ((v - effMin) / (effMax - effMin)) * innerH;
const yFromPx = (py: number) => effMin + (1 - (py - margin.top) / innerH) * (effMax - effMin);
const slotW = innerW / Math.max(data.length, 1);
const candleW = Math.max(3, Math.min(18, slotW / activeDevices.length - 3));
const xScale = (i: number) => margin.left + (i + 0.5) * slotW;
const xToIdx = (px: number) => Math.max(0, Math.min(data.length - 1, Math.floor((px - margin.left) / slotW)));
const yTicks = Array.from({ length: 5 }, (_, i) => effMin + (effMax - effMin) * i / 4);
const xStep = Math.max(1, Math.floor(data.length / 6));
// Scroll = Y zoom
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15;
const py = e.clientY - el.getBoundingClientRect().top;
const center = yFromPx(py);
const half = (effMax - effMin) / 2 * factor;
onYDomainChange([Math.max(0, center - half), center + half]);
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, [effMin, effMax]);
// Mouse drag = X brush selection
const svgRef = useRef<SVGSVGElement>(null);
const onMouseDown = (e: React.MouseEvent<SVGSVGElement>) => {
const rect = svgRef.current!.getBoundingClientRect();
setSelecting({ startPx: e.clientX - rect.left, endPx: e.clientX - rect.left });
};
const onMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
if (!selecting) return;
const rect = svgRef.current!.getBoundingClientRect();
setSelecting(s => s ? { ...s, endPx: e.clientX - rect.left } : null);
setTooltip(null);
};
const onMouseUp = () => {
if (!selecting) return;
const s = xToIdx(selecting.startPx), e = xToIdx(selecting.endPx);
if (Math.abs(s - e) > 0) onBrush(Math.min(s, e), Math.max(s, e));
setSelecting(null);
};
const selX1 = selecting ? Math.min(selecting.startPx, selecting.endPx) : 0;
const selX2 = selecting ? Math.max(selecting.startPx, selecting.endPx) : 0;
return (
<div ref={containerRef} className="relative w-full select-none" style={{ height: H }}>
<svg ref={svgRef} width={width} height={H}
onMouseDown={onMouseDown} onMouseMove={onMouseMove} onMouseUp={onMouseUp}
onDoubleClick={() => { onYDomainChange(null as any); onBrush(0, data.length - 1); }}
>
{/* Grid */}
{yTicks.map((v, i) => (
<g key={i}>
<line x1={margin.left} y1={yScale(v)} x2={margin.left + innerW} y2={yScale(v)} stroke="currentColor" strokeOpacity={0.08} strokeDasharray="3 3" />
<text x={margin.left - 6} y={yScale(v)} textAnchor="end" dominantBaseline="middle" fontSize={11} fill="currentColor" fillOpacity={0.4}>{Math.round(v)}W</text>
</g>
))}
{data.map((pt, i) => i % xStep !== 0 && i !== data.length - 1 ? null : (
<text key={i} x={xScale(i)} y={margin.top + innerH + 18} textAnchor="middle" fontSize={11} fill="currentColor" fillOpacity={0.4}>{pt.label}</text>
))}
{/* Candles */}
{data.map((pt, i) => {
const cx = xScale(i);
return activeDevices.map((name, di) => {
const c = pt[name] as Candle | undefined;
if (!c || typeof c !== "object") return null;
const off = activeDevices.length > 1 ? (di - (activeDevices.length - 1) / 2) * (candleW + 3) : 0;
const x = cx + off;
const isUp = c.close >= c.open;
const color = isUp ? "#5dd776" : "#ef4444";
const bodyTop = Math.min(yScale(c.open), yScale(c.close));
const bodyH = Math.max(1, Math.abs(yScale(c.close) - yScale(c.open)));
return (
<g key={name}
onMouseEnter={e => {
const r = containerRef.current?.getBoundingClientRect();
if (r) setTooltip({ x: e.clientX - r.left, y: e.clientY - r.top, bucket: pt });
}}
onMouseLeave={() => setTooltip(null)}
>
<line x1={x} y1={yScale(c.high)} x2={x} y2={yScale(c.low)} stroke={color} strokeWidth={1.5} />
<rect x={x - candleW / 2} y={bodyTop} width={candleW} height={bodyH} fill={color} fillOpacity={0.85} />
<rect x={cx - slotW / 2} y={margin.top} width={slotW} height={innerH} fill="transparent" />
</g>
);
});
})}
{/* Drag selection overlay */}
{selecting && selX2 - selX1 > 2 && (
<rect x={selX1} y={margin.top} width={selX2 - selX1} height={innerH}
fill="#428ce2" fillOpacity={0.15} stroke="#428ce2" strokeOpacity={0.5} strokeWidth={1} />
)}
</svg>
{/* Hover tooltip */}
{tooltip && !selecting && (
<div className="pointer-events-none absolute z-10 bg-primary border border-secondary rounded-xl px-3 py-2 text-xs shadow-lg"
style={{ left: Math.min(tooltip.x + 12, width - 160), top: Math.max(0, tooltip.y - 10) }}>
<p className="text-foreground-sec mb-1.5">{tooltip.bucket.label}</p>
{activeDevices.map(name => {
const c = tooltip.bucket[name] as Candle | undefined;
if (!c || typeof c !== "object") return null;
return (
<div key={name} className="flex flex-col gap-0.5 mb-1" style={{ color: DEVICE_COLORS[name] }}>
<span className="font-medium capitalize">{name}</span>
<span className="text-foreground-sec font-mono text-[10px]">
O {c.open.toFixed(1)} H {c.high.toFixed(1)}<br />
L {c.low.toFixed(1)} C {c.close.toFixed(1)} W
</span>
</div>
);
})}
</div>
)}
</div>
);
}
// ── Shared tooltip ────────────────────────────────────────────────────────────
const CustomTooltip = ({ active, payload, label }: any) => {
if (!active || !payload?.length) return null;
return (
<div className="bg-primary border border-secondary rounded-xl px-3 py-2 text-xs shadow-lg">
<p className="text-foreground-sec mb-1">{label}</p>
{payload.map((p: any) => (
<p key={p.name} style={{ color: p.color }} className="font-medium">
{String(p.name).charAt(0).toUpperCase() + String(p.name).slice(1)}: {Number(p.value).toFixed(1)} W
</p>
))}
</div>
);
};
function SummaryCard({ label, value, sub, color }: { label: string; value: string; sub?: string; color?: string }) {
return (
<div className="bg-primary border border-secondary rounded-2xl p-5 flex flex-col gap-1">
<span className="text-xs font-medium text-foreground-sec">{label}</span>
<span className="text-2xl font-medium tracking-tight text-foreground leading-none mt-1" style={color ? { color } : undefined}>{value}</span>
{sub && <span className="text-[0.7rem] text-foreground-sec mt-0.5">{sub}</span>}
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
const PRESETS = [{ label: "6h", h: 6 }, { label: "24h", h: 24 }, { label: "7d", h: 168 }, { label: "30d", h: 720 }];
export default function AnalyticsPage() {
// Time range
const [presetH, setPresetH] = useState(24);
const [showCustom, setShowCustom] = useState(false);
const [customFrom, setCustomFrom] = useState(() => { const d = new Date(); d.setDate(d.getDate() - 1); return d.toISOString().slice(0, 16); });
const [customTo, setCustomTo] = useState(() => new Date().toISOString().slice(0, 16));
const [appliedRange, setAppliedRange] = useState<{ from: string; to: string } | null>(null);
// Data
const [readings, setReadings] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
// Chart
const [chartType, setChartType] = useState<ChartType>("line");
const [activeDevices, setActiveDevices] = useState<Set<string>>(new Set());
// Zoom
const [yDomain, setYDomain] = useState<[number, number] | null>(null);
const [brushIdx, setBrushIdx] = useState<[number, number] | null>(null); // [start, end] indices into flatData
const [candleBrushIdx, setCandleBrushIdx] = useState<[number, number] | null>(null);
const lineBarRef = useRef<HTMLDivElement>(null);
const yDomainRef = useRef<[number, number] | null>(null);
const visibleRef = useRef<Record<string, number | string>[]>([]);
const activeListRef = useRef<string[]>([]);
yDomainRef.current = yDomain;
const effectiveSpanH = appliedRange
? (new Date(appliedRange.to).getTime() - new Date(appliedRange.from).getTime()) / 3_600_000
: presetH;
const fetchHistory = useCallback(async () => {
setLoading(true);
try {
let hoursToFetch = presetH;
if (appliedRange) {
hoursToFetch = Math.ceil((Date.now() - new Date(appliedRange.from).getTime()) / 3_600_000) + 1;
}
const res = await fetch(`/api/power/history?hours=${Math.min(hoursToFetch, 24 * 60)}`);
if (!res.ok) return;
let r: HistoryEntry[] = (await res.json()).readings ?? [];
if (appliedRange) {
const from = new Date(appliedRange.from).getTime();
const to = new Date(appliedRange.to).getTime();
r = r.filter(e => { const t = new Date(e.ts).getTime(); return t >= from && t <= to; });
}
setReadings(r);
setBrushIdx(null);
setCandleBrushIdx(null);
setYDomain(null);
} finally {
setLoading(false);
}
}, [presetH, appliedRange]);
useEffect(() => { fetchHistory(); }, [fetchHistory]);
const deviceNames = Array.from(new Set(readings.flatMap(r => r.devices.map(d => d.name)))).sort();
useEffect(() => {
setActiveDevices(prev => prev.size > 0 ? prev : new Set(deviceNames));
}, [deviceNames.join(",")]);
const activeList = deviceNames.filter(n => activeDevices.has(n));
activeListRef.current = activeList;
// Flat data for line/bar
const flatData = readings.map(r => {
const pt: Record<string, number | string> = { ts: fmtTs(r.ts, effectiveSpanH) };
for (const d of r.devices) if (activeDevices.has(d.name)) pt[d.name] = Number(d.watts.toFixed(1));
return pt;
});
const visibleFlat = brushIdx ? flatData.slice(brushIdx[0], brushIdx[1] + 1) : flatData;
visibleRef.current = visibleFlat;
// Candle data
const candleData = bucketCandles(readings, effectiveSpanH);
const visibleCandle = candleBrushIdx ? candleData.slice(candleBrushIdx[0], candleBrushIdx[1] + 1) : candleData;
// Y domain: scroll-zoomed > auto from visible data > recharts auto
const effectiveYDomain = yDomain ?? autoYDomain(chartType === "candle" ? [] : visibleFlat, activeList);
// Scroll Y zoom for line/bar
useEffect(() => {
const el = lineBarRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
e.preventDefault();
const [curMin, curMax] = yDomainRef.current ?? autoYDomain(visibleRef.current, activeListRef.current) ?? [0, 500];
const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15;
const rect = el.getBoundingClientRect();
const pct = 1 - (e.clientY - rect.top) / rect.height;
const center = curMin + pct * (curMax - curMin);
const half = (curMax - curMin) / 2 * factor;
setYDomain([Math.max(0, center - half), center + half]);
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, []);
const isZoomed = !!(yDomain || brushIdx || candleBrushIdx);
const resetZoom = () => { setYDomain(null); setBrushIdx(null); setCandleBrushIdx(null); };
const toggleDevice = (name: string) => {
setActiveDevices(prev => {
const next = new Set(prev);
if (next.has(name)) { if (next.size > 1) next.delete(name); } else next.add(name);
return next;
});
};
const summaries = deviceNames.map(name => ({ name, summary: computeSummary(readings, name) }));
const totalKWh = summaries.reduce((s, { summary }) => s + (summary?.kWh ?? 0), 0);
const axisProps = { tick: { fontSize: 11, fill: "currentColor", fillOpacity: 0.4 } as any, tickLine: false, axisLine: false };
// Double-click resets all zoom
const onChartDblClick = () => resetZoom();
return (
<div className="w-full h-full bg-primary text-foreground overflow-hidden flex flex-row">
<SideNav online={false} devConsoleOpen={false} onToggleDevConsole={() => {}} />
<div className="flex-1 overflow-y-auto pt-[52px] lg:pt-0 lg:m-[10px_10px_10px_0px] lg:rounded-2xl lg:border lg:border-blue/20 min-w-0">
<div className="max-w-5xl mx-auto px-3 pb-20 pt-8">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Power Analytics</h1>
<p className="text-sm text-foreground-sec mt-1">
{readings.length} readings · LADWP ${COST_PER_KWH}/kWh
{appliedRange && <span> · {fmtDt(appliedRange.from)} {fmtDt(appliedRange.to)}</span>}
</p>
</div>
{/* Range picker */}
<div className="flex flex-col gap-2 items-end">
<div className="flex gap-1 bg-secondary/50 rounded-xl p-1">
{PRESETS.map(({ label, h }) => (
<button key={h} onClick={() => { setPresetH(h); setAppliedRange(null); setShowCustom(false); }}
className={"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer " +
(!appliedRange && presetH === h ? "bg-blue text-white" : "text-foreground-sec hover:text-foreground")}>
{label}
</button>
))}
<button onClick={() => setShowCustom(s => !s)}
className={"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer " +
(showCustom || appliedRange ? "bg-blue text-white" : "text-foreground-sec hover:text-foreground")}>
Custom
</button>
</div>
{/* Custom date range picker */}
{showCustom && (
<div className="flex flex-wrap items-center gap-2 bg-secondary/40 border border-secondary rounded-xl p-2.5 text-xs">
<div className="flex items-center gap-1.5">
<span className="text-foreground-sec">From</span>
<input type="datetime-local" value={customFrom} onChange={e => setCustomFrom(e.target.value)}
className="bg-secondary/60 border border-secondary rounded-lg px-2 py-1 text-foreground text-xs outline-none focus:border-blue/60" />
</div>
<div className="flex items-center gap-1.5">
<span className="text-foreground-sec">To</span>
<input type="datetime-local" value={customTo} onChange={e => setCustomTo(e.target.value)}
className="bg-secondary/60 border border-secondary rounded-lg px-2 py-1 text-foreground text-xs outline-none focus:border-blue/60" />
</div>
<button
onClick={() => { if (customFrom && customTo) { setAppliedRange({ from: customFrom, to: customTo }); setShowCustom(false); } }}
className="px-3 py-1 bg-blue/10 border border-blue/30 text-blue rounded-lg font-medium hover:bg-blue/20 transition-colors cursor-pointer">
Apply
</button>
</div>
)}
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3.5 mb-8">
<SummaryCard label="Total energy" value={`${totalKWh.toFixed(3)} kWh`} sub={`$${(totalKWh * COST_PER_KWH).toFixed(3)}`} />
{summaries.map(({ name, summary }) => (
<SummaryCard key={name} label={`${name[0].toUpperCase() + name.slice(1)} avg`}
value={summary ? `${summary.avgW.toFixed(1)} W` : "—"}
sub={summary ? `Peak ${summary.peakW.toFixed(1)} W` : undefined}
color={DEVICE_COLORS[name]} />
))}
{summaries.map(({ name, summary }) => (
<SummaryCard key={`${name}-u`} label={`${name[0].toUpperCase() + name.slice(1)} uptime`}
value={summary ? `${summary.uptimePct.toFixed(0)}%` : "—"}
sub={summary ? `${summary.kWh.toFixed(3)} kWh` : undefined} />
))}
</div>
{/* Chart card */}
<div className="bg-primary border border-secondary rounded-2xl p-5 mb-8">
{/* Controls row */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-3 flex-wrap">
<h2 className="text-sm font-medium text-foreground">Power over time</h2>
{deviceNames.map(name => {
const active = activeDevices.has(name);
const color = DEVICE_COLORS[name] ?? "#888";
return (
<button key={name} onClick={() => toggleDevice(name)}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer border"
style={{ borderColor: active ? color + "55" : "transparent", background: active ? color + "18" : "transparent", color: active ? color : "var(--color-foreground-sec)" }}>
<span className="w-2 h-2 rounded-full" style={{ background: active ? color : "var(--color-secondary)" }} />
<span className="capitalize">{name}</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2">
{isZoomed && (
<button onClick={resetZoom}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-foreground-sec hover:text-foreground border border-secondary hover:border-secondary/80 transition-colors cursor-pointer">
<IconRefresh size={11} />
Reset zoom
</button>
)}
<div className="flex gap-0.5 bg-secondary/50 rounded-lg p-0.5">
{(["line", "bar", "candle"] as ChartType[]).map(type => (
<button key={type} onClick={() => { setChartType(type); resetZoom(); }}
className={"px-3 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer capitalize " +
(chartType === type ? "bg-primary text-foreground shadow-sm" : "text-foreground-sec hover:text-foreground")}>
{type}
</button>
))}
</div>
</div>
</div>
<p className="text-[11px] text-foreground-sec/50 mb-3">
{chartType === "candle" ? "Drag to zoom X · scroll to zoom Y · double-click to reset" : "Drag brush below chart to zoom X · scroll to zoom Y · double-click to reset"}
</p>
{/* Chart body */}
{loading ? (
<div className="skeleton h-[300px]" />
) : readings.length === 0 ? (
<div className="flex items-center justify-center h-[300px] text-foreground-sec text-sm">
No data yet readings are recorded every 5 minutes.
</div>
) : chartType === "candle" ? (
<CandleChart
data={visibleCandle}
activeDevices={activeList}
yDomain={yDomain}
onYDomainChange={d => setYDomain(d)}
onBrush={(s, e) => {
const base = candleBrushIdx ? candleBrushIdx[0] : 0;
setCandleBrushIdx([base + s, base + e]);
}}
/>
) : (
<div ref={lineBarRef} onDoubleClick={onChartDblClick}>
<ResponsiveContainer width="100%" height={300}>
{chartType === "bar" ? (
<BarChart data={flatData} margin={CHART_MARGIN}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" strokeOpacity={0.08} />
<XAxis dataKey="ts" {...axisProps} interval="preserveStartEnd" />
<YAxis {...axisProps} domain={effectiveYDomain ?? ["auto", "auto"]} tickFormatter={v => `${v}W`} width={50} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} formatter={v => String(v)[0].toUpperCase() + String(v).slice(1)} />
{activeList.map(name => <Bar key={name} dataKey={name} fill={DEVICE_COLORS[name] ?? "#888"} fillOpacity={0.8} radius={[2, 2, 0, 0]} maxBarSize={16} />)}
<Brush dataKey="ts" height={22} stroke="var(--color-secondary)" fill="var(--color-primary)" travellerWidth={6}
onChange={({ startIndex, endIndex }) => {
setBrushIdx([startIndex as number, endIndex as number]);
setYDomain(null);
}} />
</BarChart>
) : (
<LineChart data={flatData} margin={CHART_MARGIN}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" strokeOpacity={0.08} />
<XAxis dataKey="ts" {...axisProps} interval="preserveStartEnd" />
<YAxis {...axisProps} domain={effectiveYDomain ?? ["auto", "auto"]} tickFormatter={v => `${v}W`} width={50} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} formatter={v => String(v)[0].toUpperCase() + String(v).slice(1)} />
{activeList.map(name => (
<Line key={name} type="monotone" dataKey={name} stroke={DEVICE_COLORS[name] ?? "#888"} strokeWidth={1.5} dot={false} activeDot={{ r: 3 }} />
))}
<Brush dataKey="ts" height={22} stroke="var(--color-secondary)" fill="var(--color-primary)" travellerWidth={6}
onChange={({ startIndex, endIndex }) => {
setBrushIdx([startIndex as number, endIndex as number]);
setYDomain(null);
}} />
</LineChart>
)}
</ResponsiveContainer>
</div>
)}
</div>
{/* Device breakdown */}
{summaries.length > 0 && (
<>
<h2 className="text-lg font-medium tracking-tight text-foreground mb-5">Device breakdown</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5">
{summaries.map(({ name, summary }) => {
if (!summary) return null;
const color = DEVICE_COLORS[name] ?? "#888";
return (
<div key={name} className="bg-primary border border-secondary rounded-2xl p-5">
<div className="flex items-center gap-2 mb-4">
<span className="w-2.5 h-2.5 rounded-full" style={{ background: color }} />
<h3 className="text-sm font-medium text-foreground capitalize">{name}</h3>
</div>
<div className="grid grid-cols-3 gap-4">
{[
{ label: "Average", value: `${summary.avgW.toFixed(1)} W` },
{ label: "Peak", value: `${summary.peakW.toFixed(1)} W` },
{ label: "Uptime", value: `${summary.uptimePct.toFixed(1)}%` },
{ label: "Energy", value: `${summary.kWh.toFixed(3)} kWh` },
{ label: "Est. cost", value: `$${summary.cost.toFixed(3)}` },
{ label: "Readings", value: String(readings.filter(r => r.devices.some(d => d.name === name)).length) },
].map(({ label, value }) => (
<div key={label} className="flex flex-col gap-0.5">
<span className="text-xs text-foreground-sec">{label}</span>
<span className="text-sm font-medium text-foreground">{value}</span>
</div>
))}
</div>
</div>
);
})}
</div>
</>
)}
</div>
</div>
</div>
);
}