server-dash/app/components/panels/AnalyticsPanel.tsx

1044 lines
48 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.

"use client";
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { createPortal } from "react-dom";
import {
IconRefresh, IconBolt, IconClock, IconCalendarStats,
IconEye, IconChevronDown, IconChartBar,
} from "@tabler/icons-react";
// ── 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; }
type ChartType = "line" | "bar" | "candle";
type Metric = "watts" | "energy" | "cost";
type GroupBy = "auto" | "hour" | "day" | "month" | "year";
type CandleInterval = "auto" | "1m" | "5m" | "15m" | "30m" | "1h" | "4h" | "1d" | "1w";
type FlyoutId = "metric" | "range" | "groupby" | "devices" | "interval";
// ── Constants ─────────────────────────────────────────────────────────────────
const COST_PER_KWH = 0.24;
const PRESETS = [
{ label: "1h", h: 1 },
{ label: "6h", h: 6 },
{ label: "12h", h: 12 },
{ label: "24h", h: 24 },
{ label: "3d", h: 72 },
{ label: "7d", h: 168 },
{ label: "30d", h: 720 },
];
const METRICS: { id: Metric; label: string }[] = [
{ id: "watts", label: "Power" },
{ id: "energy", label: "Energy" },
{ id: "cost", label: "Cost" },
];
const GROUP_BY_OPTIONS: { id: GroupBy; label: string }[] = [
{ id: "auto", label: "Auto" },
{ id: "hour", label: "Hour" },
{ id: "day", label: "Day" },
{ id: "month", label: "Month" },
{ id: "year", label: "Year" },
];
const CANDLE_INTERVALS: { id: CandleInterval; label: string; ms: number }[] = [
{ id: "auto", label: "Auto", ms: 0 },
{ id: "1m", label: "1m", ms: 60_000 },
{ id: "5m", label: "5m", ms: 300_000 },
{ id: "15m", label: "15m", ms: 900_000 },
{ id: "30m", label: "30m", ms: 1_800_000 },
{ id: "1h", label: "1h", ms: 3_600_000 },
{ id: "4h", label: "4h", ms: 14_400_000 },
{ id: "1d", label: "1d", ms: 86_400_000 },
{ id: "1w", label: "1w", ms: 604_800_000 },
];
const CHART_PALETTE = [
"#60a5fa", "#f87171", "#34d399", "#fbbf24", "#a78bfa",
"#f472b6", "#22d3ee", "#a3e635", "#fb923c", "#818cf8",
];
function chartColor(i: number) { return CHART_PALETTE[i % CHART_PALETTE.length]; }
// ── Helpers ───────────────────────────────────────────────────────────────────
function getBucketKey(d: Date, groupBy: GroupBy): number {
const n = new Date(d);
if (groupBy === "hour") { n.setMinutes(0, 0, 0); }
else if (groupBy === "day") { n.setHours(0, 0, 0, 0); }
else if (groupBy === "month") { n.setDate(1); n.setHours(0, 0, 0, 0); }
else if (groupBy === "year") { n.setMonth(0, 1); n.setHours(0, 0, 0, 0); }
return n.getTime();
}
function fmtTs(ts: string, groupBy: GroupBy, spanH: number): string {
const d = new Date(ts);
if (groupBy === "year") return String(d.getFullYear());
if (groupBy === "month") return d.toLocaleDateString([], { month: "short", year: "2-digit" });
if (groupBy === "day") return d.toLocaleDateString([], { month: "short", day: "numeric" });
if (groupBy === "hour") return spanH > 24
? d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit" })
: d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
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 fmtMetricVal(v: number, metric: Metric): string {
if (metric === "watts") return `${v.toFixed(1)} W`;
if (metric === "energy") return v >= 1000 ? `${(v / 1000).toFixed(3)} kWh` : `${v.toFixed(1)} Wh`;
return `$${v.toFixed(4)}`;
}
function fmtMetricTick(v: number, metric: Metric): string {
if (metric === "watts") return `${v}W`;
if (metric === "energy") return v >= 1000 ? `${(v / 1000).toFixed(1)}kWh` : `${v}Wh`;
return `$${v.toFixed(2)}`;
}
function computeYTicks(maxVal: number): number[] {
if (maxVal <= 0) return [0];
const rawStep = maxVal / 5;
const mag = Math.pow(10, Math.floor(Math.log10(Math.max(rawStep, 1))));
const n = rawStep / mag;
const niceStep = n <= 1 ? mag : n <= 2 ? mag * 2 : n <= 5 ? mag * 5 : mag * 10;
const ticks: number[] = [];
for (let v = 0; v <= maxVal * 1.001 + niceStep; v += niceStep) {
ticks.push(v);
if (ticks.length >= 7) break;
}
return ticks;
}
function catmullRomPath(pts: [number, number][], yFloor: number): string {
if (pts.length === 0) return "";
const cy = (y: number) => Math.min(y, yFloor);
const atFloor = (y: number) => y >= yFloor - 0.5;
let d = `M ${pts[0][0].toFixed(1)} ${cy(pts[0][1]).toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) {
const p1 = pts[i], p2 = pts[i + 1];
if (atFloor(p1[1]) && atFloor(p2[1])) { d += ` L ${p2[0].toFixed(1)} ${yFloor.toFixed(1)}`; continue; }
const p0 = pts[Math.max(0, i - 1)];
const p3 = pts[Math.min(pts.length - 1, i + 2)];
const cp1x = Math.max(p1[0], Math.min(p2[0], p1[0] + (p2[0] - p0[0]) / 6));
const cp1y = cy(p1[1] + (p2[1] - p0[1]) / 6);
const cp2x = Math.max(p1[0], Math.min(p2[0], p2[0] - (p3[0] - p1[0]) / 6));
const cp2y = cy(p2[1] - (p3[1] - p1[1]) / 6);
d += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)} ${cp2x.toFixed(1)} ${cp2y.toFixed(1)} ${p2[0].toFixed(1)} ${cy(p2[1]).toFixed(1)}`;
}
return d;
}
function aggregateReadings(readings: HistoryEntry[], groupBy: GroupBy): HistoryEntry[] {
if (groupBy === "auto") return readings;
const buckets = new Map<number, Map<string, { watts: number[]; on: boolean }>>();
for (const r of readings) {
const key = getBucketKey(new Date(r.ts), groupBy);
if (!buckets.has(key)) buckets.set(key, new Map());
const b = buckets.get(key)!;
for (const d of r.devices) {
if (!b.has(d.name)) b.set(d.name, { watts: [], on: d.on });
b.get(d.name)!.watts.push(d.watts);
b.get(d.name)!.on = d.on;
}
}
return [...buckets.entries()].sort(([a], [b]) => a - b).map(([key, devMap]) => ({
ts: new Date(key).toISOString(),
devices: [...devMap.entries()].map(([name, { watts, on }]) => ({
name,
watts: watts.reduce((s, w) => s + w, 0) / watts.length,
on, today_wh: 0, month_wh: 0,
})),
}));
}
function buildSeriesData(readings: HistoryEntry[], deviceNames: string[], metric: Metric): Map<string, number[]> {
const watts = new Map<string, number[]>();
for (const n of deviceNames) watts.set(n, new Array(readings.length).fill(0));
readings.forEach((r, i) => {
for (const d of r.devices) {
if (watts.has(d.name)) watts.get(d.name)![i] = d.watts;
}
});
if (metric === "watts") return watts;
const result = new Map<string, number[]>();
for (const [name, w] of watts) {
const cum = new Array(readings.length).fill(0);
for (let i = 1; i < readings.length; i++) {
const dtH = (new Date(readings[i].ts).getTime() - new Date(readings[i - 1].ts).getTime()) / 3_600_000;
cum[i] = cum[i - 1] + (w[i] + w[i - 1]) / 2 * dtH;
}
result.set(name, metric === "cost" ? cum.map(wh => wh / 1000 * COST_PER_KWH) : cum);
}
return result;
}
// ── Flyout ────────────────────────────────────────────────────────────────────
interface FlyoutPos { x: number; y: number; alignRight: boolean; anchor: HTMLElement; }
function Flyout({ pos, onClose, title, children }: {
pos: FlyoutPos;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (
ref.current && !ref.current.contains(e.target as Node) &&
!pos.anchor.contains(e.target as Node)
) onClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [pos.anchor, onClose]);
return createPortal(
<div ref={ref} style={{
position: "fixed",
[pos.alignRight ? "right" : "left"]: pos.x,
top: pos.y,
zIndex: 99999,
background: "var(--color-primary)",
border: "1px solid var(--color-secondary)",
borderRadius: 14,
padding: "10px 8px 8px",
boxShadow: "0 12px 32px rgba(0,0,0,0.45)",
minWidth: 180,
}}>
<p style={{ fontSize: 9, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em", color: "var(--color-foreground-sec)", margin: "0 4px 8px" }}>{title}</p>
{children}
</div>,
document.body
);
}
function FlyoutOpt({ label, selected, color, onClick }: {
label: string; selected: boolean; color?: string; onClick: () => void;
}) {
return (
<button onClick={onClick} style={{
width: "100%", display: "flex", alignItems: "center", gap: 8,
padding: "7px 10px", borderRadius: 8, border: "none",
background: selected ? "color-mix(in srgb, var(--color-blue) 14%, transparent)" : "transparent",
color: selected ? "var(--color-blue)" : "var(--color-foreground)",
cursor: "pointer", fontSize: 12, fontWeight: selected ? 600 : 400, textAlign: "left",
transition: "background 100ms",
}}>
<span style={{
width: 7, height: 7, borderRadius: "50%", flexShrink: 0,
background: selected ? (color ?? "var(--color-blue)") : "var(--color-secondary)",
boxShadow: selected ? `0 0 0 2px color-mix(in srgb, ${color ?? "var(--color-blue)"} 25%, transparent)` : "none",
}} />
{label}
</button>
);
}
// ── Toolbar button ────────────────────────────────────────────────────────────
function CtrlBtn({ icon, label, isOpen, onClick }: {
icon: React.ReactNode; label: string; isOpen: boolean;
onClick: (rect: DOMRect, el: HTMLButtonElement) => void;
}) {
const btnRef = useRef<HTMLButtonElement>(null);
return (
<button ref={btnRef} onClick={() => {
if (btnRef.current) onClick(btnRef.current.getBoundingClientRect(), btnRef.current);
}} style={{
display: "flex", alignItems: "center", gap: 5,
padding: "5px 10px", borderRadius: 8, cursor: "pointer",
border: `1px solid ${isOpen ? "var(--color-blue)" : "var(--color-secondary)"}`,
background: isOpen ? "color-mix(in srgb, var(--color-blue) 10%, transparent)" : "transparent",
color: isOpen ? "var(--color-blue)" : "var(--color-foreground-sec)",
fontSize: 11, fontWeight: 500, transition: "all 150ms",
}}>
{icon}
<span>{label}</span>
<IconChevronDown size={10} style={{
opacity: 0.5, marginLeft: 1,
transform: isOpen ? "rotate(180deg)" : "none",
transition: "transform 150ms",
}} />
</button>
);
}
// ── Tooltip ───────────────────────────────────────────────────────────────────
function ChartTooltip({ label, mouseX, mouseY, children }: {
label: string; mouseX: number; mouseY: number; children: React.ReactNode;
}) {
const flipX = mouseX > window.innerWidth - 190;
return (
<div style={{
position: "fixed",
left: flipX ? mouseX - 16 : mouseX + 16,
top: mouseY,
transform: flipX ? "translate(-100%, -50%)" : "translateY(-50%)",
pointerEvents: "none", zIndex: 99999,
background: "var(--color-primary)", border: "1px solid var(--color-secondary)",
borderRadius: 10, padding: "8px 12px",
boxShadow: "0 4px 16px rgba(0,0,0,0.3)", minWidth: 120, fontSize: 11,
}}>
{label && <div style={{ color: "var(--color-foreground-sec)", marginBottom: 6, fontSize: 10, fontWeight: 500 }}>{label}</div>}
{children}
</div>
);
}
// ── Line Chart ────────────────────────────────────────────────────────────────
const LM = { top: 8, right: 16, bottom: 64, left: 56 };
function LineChart({ readings, deviceNames, colors, visible, hours, metric, groupBy }: {
readings: HistoryEntry[]; deviceNames: string[]; colors: Map<string, string>;
visible: Set<string>; hours: number; metric: Metric; groupBy: GroupBy;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [uid] = useState(() => `lc${Math.random().toString(36).slice(2, 7)}`);
const [size, setSize] = useState({ w: 0, h: 0 });
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null);
const [mouse, setMouse] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState({ sx: 1, px: 0, sy: 1 });
const [dragging, setDragging] = useState(false);
const zoomRef = useRef(zoom); zoomRef.current = zoom;
const isDrag = useRef(false);
const dragStart = useRef({ x: 0, pan: 0 });
const innerWRef = useRef(0);
const lastMx = useRef(0);
useEffect(() => {
const el = containerRef.current; if (!el) return;
const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight }));
ro.observe(el); setSize({ w: el.clientWidth, h: el.clientHeight });
return () => ro.disconnect();
}, []);
useEffect(() => { setZoom({ sx: 1, px: 0, sy: 1 }); }, [hours, groupBy]);
useEffect(() => {
const el = containerRef.current; if (!el) return;
const handler = (e: WheelEvent) => {
e.preventDefault();
const f = e.deltaY < 0 ? 1.2 : 1 / 1.2;
if (e.shiftKey) {
setZoom(p => ({ ...p, sy: Math.max(0.05, Math.min(20, p.sy * f)) }));
} else {
const mx = lastMx.current, iW = innerWRef.current;
const { sx, px } = zoomRef.current;
const nsx = Math.max(1, sx * f);
const npx = Math.min(0, Math.max(iW * (1 - nsx), mx - (mx - px) * (nsx / sx)));
setZoom(p => ({ ...p, sx: nsx, px: npx }));
}
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, []);
useEffect(() => {
const onUp = () => { if (!isDrag.current) return; isDrag.current = false; setDragging(false); };
window.addEventListener("mouseup", onUp);
return () => window.removeEventListener("mouseup", onUp);
}, []);
const timestamps = useMemo(() => readings.map(r => r.ts), [readings]);
const seriesData = useMemo(() => buildSeriesData(readings, deviceNames, metric), [readings, deviceNames, metric]);
if (timestamps.length === 0) return <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--color-foreground-sec)" }}>No data</div>;
const { w, h } = size;
const iW = Math.max(0, w - LM.left - LM.right);
const iH = Math.max(0, h - LM.top - LM.bottom);
innerWRef.current = iW;
const visEntries = [...seriesData.entries()].filter(([n]) => visible.has(n));
const allVals = visEntries.flatMap(([, v]) => v);
const maxVal = Math.max(...allVals, 0.001);
const yMaxShown = maxVal / zoom.sy;
const yTicks = computeYTicks(yMaxShown);
const yMax = yTicks[yTicks.length - 1];
const xBase = (i: number) => timestamps.length <= 1 ? iW / 2 : (i / (timestamps.length - 1)) * iW;
const xS = (i: number) => xBase(i) * zoom.sx + zoom.px;
const yS = (v: number) => iH - (v / Math.max(yMax, 0.001)) * iH;
const viXIdx = timestamps.map((_, i) => i).filter(i => xS(i) >= -20 && xS(i) <= iW + 20);
const step = Math.max(1, Math.floor(viXIdx.length / 8));
const labelIdx = viXIdx.filter((_, j) => j % step === 0);
const onMD = (e: React.MouseEvent) => {
isDrag.current = true; setDragging(true);
dragStart.current = { x: e.clientX, pan: zoomRef.current.px };
setHoverIdx(null);
};
const onMM = (e: React.MouseEvent<SVGRectElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
lastMx.current = mx;
if (isDrag.current) {
const dx = e.clientX - dragStart.current.x;
const { sx } = zoomRef.current;
setZoom(p => ({ ...p, px: Math.min(0, Math.max(iW * (1 - sx), dragStart.current.pan + dx)) }));
} else {
setHoverPos({ x: mx, y: my });
const raw = ((mx - zoomRef.current.px) / (iW * zoomRef.current.sx)) * (timestamps.length - 1);
setHoverIdx(Math.max(0, Math.min(timestamps.length - 1, Math.round(raw))));
setMouse({ x: e.clientX, y: e.clientY });
}
};
const onML = () => { if (!isDrag.current) { setHoverIdx(null); setHoverPos(null); } };
const cursor = dragging ? "grabbing" : zoom.sx > 1 ? "grab" : "crosshair";
const tipItems = hoverIdx !== null && !dragging
? visEntries.map(([n, v]) => ({ name: n, val: v[hoverIdx] ?? 0, color: colors.get(n) ?? "#888" })).filter(x => x.val > 0)
: [];
return (
<div ref={containerRef} style={{ width: "100%", height: "100%", position: "relative" }}>
{w > 0 && h > 0 && (
<svg width={w} height={h} style={{ display: "block", cursor }}>
<defs>
<clipPath id={`${uid}clip`}><rect x={0} y={0} width={iW} height={iH} /></clipPath>
{visEntries.map(([n]) => {
const c = colors.get(n) ?? "#888";
return (
<linearGradient key={n} id={`${uid}g${n}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={c} stopOpacity="0.22" />
<stop offset="100%" stopColor={c} stopOpacity="0" />
</linearGradient>
);
})}
</defs>
<g transform={`translate(${LM.left},${LM.top})`}>
{yTicks.map(v => (
<g key={v}>
<line x1={0} x2={iW} y1={yS(v)} y2={yS(v)} stroke="var(--color-secondary)" strokeDasharray="3,3" strokeWidth={1} />
<text x={-6} y={yS(v)} textAnchor="end" dominantBaseline="middle" fill="var(--color-foreground-sec)" fontSize={10}>{fmtMetricTick(v, metric)}</text>
</g>
))}
{labelIdx.map(i => (
<text key={i} textAnchor="end" transform={`translate(${xS(i)},${iH + 8}) rotate(-45)`} fill="var(--color-foreground-sec)" fontSize={10}>
{fmtTs(timestamps[i], groupBy, hours)}
</text>
))}
{(zoom.sx !== 1 || zoom.sy !== 1) && (() => {
const bs: string[] = [];
if (zoom.sx !== 1) bs.push(`${zoom.sx.toFixed(1)}×`);
if (zoom.sy !== 1) bs.push(`${zoom.sy.toFixed(2)}×`);
const bw = 64, bh = 17, gap = 4;
return <g>{bs.map((b, j) => (
<g key={b} transform={`translate(${iW - (bs.length - j) * (bw + gap)}, 2)`}>
<rect width={bw} height={bh} rx={4} fill="var(--color-primary)" stroke="var(--color-secondary)" strokeWidth={1} />
<text x={bw / 2} y={bh / 2 + 1} textAnchor="middle" dominantBaseline="middle" fill="var(--color-foreground-sec)" fontSize={10}>{b}</text>
</g>
))}</g>;
})()}
<g clipPath={`url(#${uid}clip)`}>
{visEntries.map(([n, v]) => {
const c = colors.get(n) ?? "#888";
const pts: [number, number][] = v.map((val, i) => [xS(i), yS(val)]);
const linePath = catmullRomPath(pts, iH);
if (!linePath || pts.length < 2) return null;
const fillPath = `${linePath} L ${pts[pts.length - 1][0].toFixed(1)} ${iH.toFixed(1)} L ${pts[0][0].toFixed(1)} ${iH.toFixed(1)} Z`;
return (
<g key={n}>
<path d={fillPath} fill={`url(#${uid}g${n})`} />
<path d={linePath} fill="none" stroke={c} strokeWidth={2.5} strokeLinejoin="round" strokeLinecap="round" />
</g>
);
})}
{hoverIdx !== null && !dragging && visEntries.map(([n, v]) => (
<circle key={`d${n}`} cx={xS(hoverIdx)} cy={yS(v[hoverIdx] ?? 0)} r={4} fill={colors.get(n) ?? "#888"} stroke="var(--color-primary)" strokeWidth={2} />
))}
</g>
{hoverPos && !dragging && (
<>
<line x1={hoverPos.x} x2={hoverPos.x} y1={0} y2={iH} stroke="var(--color-foreground-sec)" strokeWidth={1} strokeDasharray="4,2" opacity={0.5} />
<line x1={0} x2={iW} y1={hoverPos.y} y2={hoverPos.y} stroke="var(--color-foreground-sec)" strokeWidth={1} strokeDasharray="4,2" opacity={0.5} />
</>
)}
<rect x={0} y={0} width={iW} height={iH} fill="transparent"
onMouseDown={onMD} onMouseMove={onMM} onMouseLeave={onML}
onDoubleClick={() => setZoom({ sx: 1, px: 0, sy: 1 })} />
</g>
</svg>
)}
{hoverIdx !== null && !dragging && tipItems.length > 0 && typeof document !== "undefined" && createPortal(
<ChartTooltip label={fmtTs(timestamps[hoverIdx], groupBy, hours)} mouseX={mouse.x} mouseY={mouse.y}>
{tipItems.map(item => (
<div key={item.name} style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 2 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: item.color, flexShrink: 0 }} />
<span style={{ color: "var(--color-foreground)", fontWeight: 500, textTransform: "capitalize", flex: 1 }}>{item.name}</span>
<span style={{ color: item.color, fontWeight: 700 }}>{fmtMetricVal(item.val, metric)}</span>
</div>
))}
</ChartTooltip>,
document.body
)}
</div>
);
}
// ── Bar Chart ─────────────────────────────────────────────────────────────────
const BM = { top: 16, right: 16, bottom: 56, left: 64 };
function BarChart({ readings, deviceNames, colors, visible, metric }: {
readings: HistoryEntry[]; deviceNames: string[]; colors: Map<string, string>;
visible: Set<string>; metric: Metric;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [uid] = useState(() => `bc${Math.random().toString(36).slice(2, 7)}`);
const [size, setSize] = useState({ w: 0, h: 0 });
const [hover, setHover] = useState<string | null>(null);
const [mouse, setMouse] = useState({ x: 0, y: 0 });
useEffect(() => {
const el = containerRef.current; if (!el) return;
const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight }));
ro.observe(el); setSize({ w: el.clientWidth, h: el.clientHeight });
return () => ro.disconnect();
}, []);
const deviceVals = useMemo(() => {
const map = new Map<string, number>();
for (const name of deviceNames) {
const pts = readings
.map(r => { const d = r.devices.find(x => x.name === name); return d ? { ts: new Date(r.ts).getTime(), w: d.watts } : null; })
.filter(Boolean).sort((a, b) => a!.ts - b!.ts) as { ts: number; w: number }[];
let wh = 0;
for (let i = 1; i < pts.length; i++) {
wh += (pts[i].w + pts[i - 1].w) / 2 * (pts[i].ts - pts[i - 1].ts) / 3_600_000;
}
const avgW = pts.length ? pts.reduce((s, p) => s + p.w, 0) / pts.length : 0;
map.set(name, metric === "watts" ? avgW : metric === "energy" ? wh : wh / 1000 * COST_PER_KWH);
}
return map;
}, [readings, deviceNames, metric]);
const visDev = deviceNames.filter(n => visible.has(n));
if (visDev.length === 0 || readings.length === 0) return <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--color-foreground-sec)" }}>No data</div>;
const { w, h } = size;
const iW = Math.max(0, w - BM.left - BM.right);
const iH = Math.max(0, h - BM.top - BM.bottom);
const maxVal = Math.max(...visDev.map(n => deviceVals.get(n) ?? 0), 0.001);
const yTicks = computeYTicks(maxVal);
const yMax = yTicks[yTicks.length - 1];
const yS = (v: number) => iH - (v / Math.max(yMax, 0.001)) * iH;
const gap = Math.max(6, iW * 0.1 / visDev.length);
const barW = Math.max(6, (iW - gap * (visDev.length + 1)) / visDev.length);
return (
<div ref={containerRef} style={{ width: "100%", height: "100%", position: "relative" }}>
{w > 0 && h > 0 && (
<svg width={w} height={h} style={{ display: "block" }}>
<defs>
{visDev.map(n => {
const c = colors.get(n) ?? "#888";
return (
<linearGradient key={n} id={`${uid}g${n}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={c} stopOpacity="0.9" />
<stop offset="100%" stopColor={c} stopOpacity="0.6" />
</linearGradient>
);
})}
</defs>
<g transform={`translate(${BM.left},${BM.top})`}>
{yTicks.map(v => (
<g key={v}>
<line x1={0} x2={iW} y1={yS(v)} y2={yS(v)} stroke="var(--color-secondary)" strokeDasharray="3,3" strokeWidth={1} />
<text x={-6} y={yS(v)} textAnchor="end" dominantBaseline="middle" fill="var(--color-foreground-sec)" fontSize={10}>{fmtMetricTick(v, metric)}</text>
</g>
))}
{visDev.map((n, i) => {
const val = deviceVals.get(n) ?? 0;
const x = gap + i * (barW + gap);
return (
<g key={n}
onMouseEnter={e => { setHover(n); setMouse({ x: e.clientX, y: e.clientY }); }}
onMouseMove={e => setMouse({ x: e.clientX, y: e.clientY })}
onMouseLeave={() => setHover(null)}
style={{ cursor: "default" }}
>
<rect x={x} y={yS(val)} width={barW} height={Math.max(0, iH - yS(val))} fill={`url(#${uid}g${n})`} opacity={hover === n ? 1 : 0.85} rx={3} />
<text transform={`translate(${x + barW / 2},${iH + 8}) rotate(-35)`} textAnchor="end" fill="var(--color-foreground-sec)" fontSize={10} style={{ textTransform: "capitalize" }}>{n}</text>
</g>
);
})}
<line x1={0} x2={iW} y1={iH} y2={iH} stroke="var(--color-secondary)" strokeWidth={1} />
</g>
</svg>
)}
{hover !== null && typeof document !== "undefined" && createPortal(
<ChartTooltip label="" mouseX={mouse.x} mouseY={mouse.y}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: colors.get(hover) ?? "#888", flexShrink: 0 }} />
<span style={{ color: "var(--color-foreground)", fontWeight: 500, textTransform: "capitalize" }}>{hover}</span>
<span style={{ color: colors.get(hover) ?? "#888", fontWeight: 700 }}>{fmtMetricVal(deviceVals.get(hover) ?? 0, metric)}</span>
</div>
</ChartTooltip>,
document.body
)}
</div>
);
}
// ── Candle Chart ──────────────────────────────────────────────────────────────
const CM = { top: 8, right: 16, bottom: 40, left: 52 };
function getAutoCandleMs(spanH: number): number {
if (spanH <= 2) return 60_000;
if (spanH <= 6) return 300_000;
if (spanH <= 24) return 1_800_000;
if (spanH <= 72) return 3_600_000;
if (spanH <= 168) return 14_400_000;
if (spanH <= 720) return 86_400_000;
return 604_800_000;
}
function fmtCandleLabel(ts: number, candleMs: number, spanH: number): string {
const d = new Date(ts);
if (candleMs < 3_600_000) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
if (candleMs < 86_400_000) return spanH > 24
? d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })
: d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
return d.toLocaleDateString([], { month: "short", day: "numeric" });
}
function bucketCandles(readings: HistoryEntry[], spanH: number, candleInterval: CandleInterval) {
const entry = CANDLE_INTERVALS.find(c => c.id === candleInterval);
const ms = entry && entry.ms > 0 ? entry.ms : getAutoCandleMs(spanH);
const bucketKey = (d: Date) => Math.floor(d.getTime() / ms) * ms;
const map = new Map<number, Map<string, number[]>>();
for (const r of readings) {
const key = bucketKey(new Date(r.ts));
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 [...map.entries()].sort(([a], [b]) => a - b).map(([ts, devWatts]) => {
const label = fmtCandleLabel(ts, ms, spanH);
const candles: Record<string, Candle> = {};
for (const [name, watts] of devWatts) {
const s = [...watts].sort((a, b) => a - b);
candles[name] = { open: watts[0], close: watts[watts.length - 1], high: s[s.length - 1], low: s[0] };
}
return { ts, label, candles };
});
}
function CandleChart({ readings, deviceNames, colors, visible, hours, candleInterval }: {
readings: HistoryEntry[]; deviceNames: string[]; colors: Map<string, string>;
visible: Set<string>; hours: number; candleInterval: CandleInterval;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 0, h: 0 });
const [tooltip, setTooltip] = useState<{ x: number; y: number; bucket: ReturnType<typeof bucketCandles>[0] } | null>(null);
useEffect(() => {
const el = containerRef.current; if (!el) return;
const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight }));
ro.observe(el); setSize({ w: el.clientWidth, h: el.clientHeight });
return () => ro.disconnect();
}, []);
const buckets = useMemo(() => bucketCandles(readings, hours, candleInterval), [readings, hours, candleInterval]);
const visDev = deviceNames.filter(n => visible.has(n));
if (buckets.length === 0) return <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--color-foreground-sec)" }}>No data</div>;
const { w, h } = size;
const iW = Math.max(0, w - CM.left - CM.right);
const iH = Math.max(0, h - CM.top - CM.bottom);
let yMin = Infinity, yMax = -Infinity;
for (const b of buckets) for (const n of visDev) {
const c = b.candles[n]; if (c) { 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 = Math.max(0, yMin - pad), effMax = yMax + pad;
const yS = (v: number) => CM.top + iH - ((v - effMin) / (effMax - effMin)) * iH;
const yTicks = Array.from({ length: 5 }, (_, i) => effMin + (effMax - effMin) * i / 4);
const slotW = iW / Math.max(buckets.length, 1);
const candleW = Math.max(3, Math.min(18, slotW / Math.max(visDev.length, 1) - 3));
const xScale = (i: number) => CM.left + (i + 0.5) * slotW;
const xStep = Math.max(1, Math.floor(buckets.length / 6));
return (
<div ref={containerRef} style={{ width: "100%", height: "100%", position: "relative", userSelect: "none" }}>
{w > 0 && h > 0 && (
<svg width={w} height={h} style={{ display: "block" }}>
{yTicks.map((v, i) => (
<g key={i}>
<line x1={CM.left} y1={yS(v)} x2={CM.left + iW} y2={yS(v)} stroke="var(--color-secondary)" strokeDasharray="3,3" strokeWidth={1} />
<text x={CM.left - 6} y={yS(v)} textAnchor="end" dominantBaseline="middle" fill="var(--color-foreground-sec)" fontSize={10}>{Math.round(v)}W</text>
</g>
))}
{buckets.map((b, i) => i % xStep !== 0 && i !== buckets.length - 1 ? null : (
<text key={i} x={xScale(i)} y={CM.top + iH + 16} textAnchor="middle" fill="var(--color-foreground-sec)" fontSize={10}>{b.label}</text>
))}
{buckets.map((b, i) => {
const cx = xScale(i);
return visDev.map((n, di) => {
const c = b.candles[n]; if (!c) return null;
const off = visDev.length > 1 ? (di - (visDev.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(yS(c.open), yS(c.close));
const bodyH = Math.max(1, Math.abs(yS(c.close) - yS(c.open)));
return (
<g key={`${i}-${n}`}
onMouseEnter={e => setTooltip({ x: e.clientX, y: e.clientY, bucket: b })}
onMouseMove={e => setTooltip(t => t ? { ...t, x: e.clientX, y: e.clientY } : t)}
onMouseLeave={() => setTooltip(null)}
>
<line x1={x} y1={yS(c.high)} x2={x} y2={yS(c.low)} stroke={color} strokeWidth={1.5} />
<rect x={x - candleW / 2} y={bodyTop} width={candleW} height={bodyH} fill={color} fillOpacity={0.85} rx={1} />
<rect x={cx - slotW / 2} y={CM.top} width={slotW} height={iH} fill="transparent" />
</g>
);
});
})}
<line x1={CM.left} y1={CM.top + iH} x2={CM.left + iW} y2={CM.top + iH} stroke="var(--color-secondary)" strokeWidth={1} />
</svg>
)}
{tooltip && typeof document !== "undefined" && createPortal(
<ChartTooltip label={tooltip.bucket.label} mouseX={tooltip.x} mouseY={tooltip.y}>
{visDev.map(n => {
const c = tooltip.bucket.candles[n]; if (!c) return null;
return (
<div key={n} style={{ marginBottom: 4 }}>
<div style={{ display: "flex", alignItems: "center", gap: 5, marginBottom: 2 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: colors.get(n) ?? "#888", flexShrink: 0 }} />
<span style={{ color: "var(--color-foreground)", fontWeight: 600, textTransform: "capitalize", fontSize: 11 }}>{n}</span>
</div>
<div style={{ color: "var(--color-foreground-sec)", fontSize: 10, fontFamily: "monospace", paddingLeft: 13 }}>
O {c.open.toFixed(1)} H {c.high.toFixed(1)} L {c.low.toFixed(1)} C {c.close.toFixed(1)}W
</div>
</div>
);
})}
</ChartTooltip>,
document.body
)}
</div>
);
}
// ── AnalyticsPanel ────────────────────────────────────────────────────────────
export default function AnalyticsPanel({ chartType, readOnly = false, defaultHours = 24 }: { chartType: ChartType; readOnly?: boolean; defaultHours?: number }) {
const [hoursInput, setHoursInput] = useState(String(defaultHours));
const [loading, setLoading] = useState(true);
const [readings, setReadings] = useState<HistoryEntry[]>([]);
const [visible, setVisible] = useState<Set<string>>(new Set());
const [metric, setMetric] = useState<Metric>("watts");
const [groupBy, setGroupBy] = useState<GroupBy>("auto");
const [candleInterval, setCandleInterval] = useState<CandleInterval>("auto");
const [flyout, setFlyout] = useState<(FlyoutPos & { id: FlyoutId }) | null>(null);
const hours = Math.max(1, parseInt(hoursInput, 10) || 24);
const matchedPreset = PRESETS.find(p => p.h === hours);
const openFlyout = useCallback((id: FlyoutId, rect: DOMRect, el: HTMLButtonElement) => {
if (flyout?.id === id) { setFlyout(null); return; }
const alignRight = rect.left > window.innerWidth * 0.55;
setFlyout({
id, anchor: el,
x: alignRight ? window.innerWidth - rect.right : rect.left,
y: rect.bottom + 6,
alignRight,
});
}, [flyout?.id]);
const closeFlyout = useCallback(() => setFlyout(null), []);
const fetchHistory = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/power/history?hours=${hours}`);
if (!res.ok) return;
setReadings((await res.json()).readings ?? []);
} finally { setLoading(false); }
}, [hours]);
useEffect(() => { fetchHistory(); }, [fetchHistory]);
const deviceNames = useMemo(
() => [...new Set(readings.flatMap(r => r.devices.map(d => d.name)))].sort(),
[readings],
);
useEffect(() => {
setVisible(prev => prev.size > 0 ? new Set([...prev].filter(n => deviceNames.includes(n))) : new Set(deviceNames));
}, [deviceNames.join(",")]);
const colors = useMemo(() => {
const map = new Map<string, string>();
deviceNames.forEach((n, i) => map.set(n, chartColor(i)));
return map;
}, [deviceNames]);
const toggleDevice = (name: string) => {
setVisible(prev => {
const next = new Set(prev);
if (next.has(name)) { if (next.size > 1) next.delete(name); } else next.add(name);
return next;
});
};
const displayReadings = useMemo(() => aggregateReadings(readings, groupBy), [readings, groupBy]);
const visibleDevices = useMemo(() => deviceNames.filter(n => visible.has(n)), [deviceNames, visible]);
const deviceStats = useMemo(() => {
const map = new Map<string, { wh: number; avgW: number; cost: number }>();
for (const name of visibleDevices) {
const pts = readings
.map(r => { const d = r.devices.find(x => x.name === name); return d ? { ts: new Date(r.ts).getTime(), w: d.watts } : null; })
.filter(Boolean).sort((a, b) => a!.ts - b!.ts) as { ts: number; w: number }[];
let wh = 0;
for (let i = 1; i < pts.length; i++) {
wh += (pts[i].w + pts[i - 1].w) / 2 * (pts[i].ts - pts[i - 1].ts) / 3_600_000;
}
const avgW = pts.length ? pts.reduce((s, p) => s + p.w, 0) / pts.length : 0;
map.set(name, { wh, avgW, cost: wh / 1000 * COST_PER_KWH });
}
return map;
}, [readings, visibleDevices]);
const totalStats = useMemo(() => {
let wh = 0, cost = 0, avgW = 0;
for (const s of deviceStats.values()) { wh += s.wh; cost += s.cost; avgW += s.avgW; }
return { wh, cost, avgW };
}, [deviceStats]);
const fmtSummaryVal = (name: string) => {
const s = deviceStats.get(name);
if (!s) return "—";
if (metric === "watts") return `${s.avgW.toFixed(1)} W`;
if (metric === "energy") return s.wh >= 1000 ? `${(s.wh / 1000).toFixed(3)} kWh` : `${s.wh.toFixed(1)} Wh`;
return `$${s.cost.toFixed(4)}`;
};
const fmtTotalVal = () => {
if (metric === "watts") return `${totalStats.avgW.toFixed(1)} W`;
if (metric === "energy") return totalStats.wh >= 1000 ? `${(totalStats.wh / 1000).toFixed(3)} kWh` : `${totalStats.wh.toFixed(1)} Wh`;
return `$${totalStats.cost.toFixed(4)}`;
};
const totalSubLabel = metric === "watts" ? "avg power" : metric === "energy" ? `$${totalStats.cost.toFixed(4)}` : `${totalStats.wh >= 1000 ? (totalStats.wh / 1000).toFixed(3) + " kWh" : totalStats.wh.toFixed(1) + " Wh"}`;
const deviceSubLabel = (name: string) => {
const s = deviceStats.get(name); if (!s) return "";
if (metric === "watts") return `${readings.filter(r => r.devices.some(d => d.name === name)).length} readings`;
if (metric === "energy") return `$${s.cost.toFixed(4)}`;
return `${s.wh >= 1000 ? (s.wh / 1000).toFixed(3) + " kWh" : s.wh.toFixed(1) + " Wh"}`;
};
// Derived labels for toolbar buttons
const metricLabel = METRICS.find(m => m.id === metric)?.label ?? "Power";
const rangeLabel = matchedPreset ? matchedPreset.label : `${hours}h`;
const groupLabel = GROUP_BY_OPTIONS.find(g => g.id === groupBy)?.label ?? "Auto";
const intervalLabel = CANDLE_INTERVALS.find(c => c.id === candleInterval)?.label ?? "Auto";
const devLabel = visible.size === deviceNames.length ? "All" : `${visible.size}`;
const refreshBtn = (
<button onClick={fetchHistory} disabled={loading} style={{
display: "flex", alignItems: "center", padding: "5px 8px", borderRadius: 8,
background: "transparent", border: "1px solid var(--color-secondary)",
color: "var(--color-foreground-sec)", cursor: "pointer",
}}>
<IconRefresh size={13} className={loading ? "animate-spin" : ""} />
</button>
);
// Only render flyouts when open and on client
const activeFlyout = typeof document !== "undefined" ? flyout : null;
return (
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{!readOnly && (chartType === "candle" ? (
<>
{/* ── Candlestick toolbar ── */}
<div style={{ flexShrink: 0, display: "flex", alignItems: "center", gap: 6, padding: "10px 14px 8px", borderBottom: "1px solid color-mix(in srgb, var(--color-secondary) 60%, transparent)" }}>
<CtrlBtn icon={<IconClock size={13} />} label={rangeLabel} isOpen={flyout?.id === "range"} onClick={(rect, el) => openFlyout("range", rect, el)} />
<CtrlBtn icon={<IconChartBar size={13} />} label={intervalLabel} isOpen={flyout?.id === "interval"} onClick={(rect, el) => openFlyout("interval", rect, el)} />
{deviceNames.length > 0 && (
<CtrlBtn icon={<IconEye size={13} />} label={devLabel} isOpen={flyout?.id === "devices"} onClick={(rect, el) => openFlyout("devices", rect, el)} />
)}
<div style={{ marginLeft: "auto" }}>{refreshBtn}</div>
</div>
{activeFlyout?.id === "range" && (
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Time Range">
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 4, marginBottom: 8 }}>
{PRESETS.map(({ label, h }) => (
<button key={h} onClick={() => { setHoursInput(String(h)); closeFlyout(); }} style={{
padding: "5px 4px", borderRadius: 7, fontSize: 11, fontWeight: 500, cursor: "pointer",
border: `1px solid ${matchedPreset?.h === h ? "var(--color-blue)" : "var(--color-secondary)"}`,
background: matchedPreset?.h === h ? "color-mix(in srgb, var(--color-blue) 18%, transparent)" : "transparent",
color: matchedPreset?.h === h ? "var(--color-blue)" : "var(--color-foreground-sec)",
transition: "all 120ms",
}}>{label}</button>
))}
</div>
<div style={{ height: 1, background: "var(--color-secondary)", margin: "4px 2px 10px" }} />
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 2px" }}>
<span style={{ fontSize: 11, color: "var(--color-foreground-sec)", fontWeight: 500 }}>Custom</span>
<input type="text" inputMode="numeric" pattern="[0-9]*" value={hoursInput}
onChange={e => setHoursInput(e.target.value.replace(/[^0-9]/g, ""))}
style={{ flex: 1, padding: "5px 8px", borderRadius: 7, fontSize: 11, background: "color-mix(in srgb, var(--color-secondary) 50%, transparent)", border: `1px solid ${!matchedPreset ? "var(--color-blue)" : "var(--color-secondary)"}`, color: "var(--color-foreground)", outline: "none" }} />
<span style={{ fontSize: 11, color: "var(--color-foreground-sec)" }}>h</span>
</div>
</Flyout>
)}
{activeFlyout?.id === "interval" && (
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Candle Period">
{CANDLE_INTERVALS.map(c => (
<FlyoutOpt key={c.id}
label={c.id === "auto" ? `Auto (${CANDLE_INTERVALS.find(x => x.ms === getAutoCandleMs(hours))?.label ?? "auto"})` : c.label}
selected={candleInterval === c.id}
onClick={() => { setCandleInterval(c.id); closeFlyout(); }} />
))}
</Flyout>
)}
{activeFlyout?.id === "devices" && (
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Devices">
{deviceNames.map(name => (
<FlyoutOpt key={name} label={name} selected={visible.has(name)} color={colors.get(name)}
onClick={() => toggleDevice(name)} />
))}
</Flyout>
)}
</>
) : (
<>
{/* ── Line / bar toolbar ── */}
<div style={{ flexShrink: 0, display: "flex", alignItems: "center", gap: 6, padding: "10px 14px 8px", borderBottom: "1px solid color-mix(in srgb, var(--color-secondary) 60%, transparent)" }}>
<CtrlBtn icon={<IconBolt size={13} />} label={metricLabel} isOpen={flyout?.id === "metric"} onClick={(rect, el) => openFlyout("metric", rect, el)} />
<CtrlBtn icon={<IconClock size={13} />} label={rangeLabel} isOpen={flyout?.id === "range"} onClick={(rect, el) => openFlyout("range", rect, el)} />
<CtrlBtn icon={<IconCalendarStats size={13} />} label={groupLabel} isOpen={flyout?.id === "groupby"} onClick={(rect, el) => openFlyout("groupby", rect, el)} />
{deviceNames.length > 0 && (
<CtrlBtn icon={<IconEye size={13} />} label={devLabel} isOpen={flyout?.id === "devices"} onClick={(rect, el) => openFlyout("devices", rect, el)} />
)}
<div style={{ marginLeft: "auto" }}>{refreshBtn}</div>
</div>
{activeFlyout?.id === "metric" && (
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Metric">
{METRICS.map(m => (
<FlyoutOpt key={m.id} label={m.label} selected={metric === m.id} onClick={() => { setMetric(m.id); closeFlyout(); }} />
))}
</Flyout>
)}
{activeFlyout?.id === "range" && (
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Time Range">
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 4, marginBottom: 8 }}>
{PRESETS.map(({ label, h }) => (
<button key={h} onClick={() => { setHoursInput(String(h)); closeFlyout(); }} style={{
padding: "5px 4px", borderRadius: 7, fontSize: 11, fontWeight: 500, cursor: "pointer",
border: `1px solid ${matchedPreset?.h === h ? "var(--color-blue)" : "var(--color-secondary)"}`,
background: matchedPreset?.h === h ? "color-mix(in srgb, var(--color-blue) 18%, transparent)" : "transparent",
color: matchedPreset?.h === h ? "var(--color-blue)" : "var(--color-foreground-sec)",
transition: "all 120ms",
}}>{label}</button>
))}
</div>
<div style={{ height: 1, background: "var(--color-secondary)", margin: "4px 2px 10px" }} />
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 2px" }}>
<span style={{ fontSize: 11, color: "var(--color-foreground-sec)", fontWeight: 500 }}>Custom</span>
<input type="text" inputMode="numeric" pattern="[0-9]*" value={hoursInput}
onChange={e => setHoursInput(e.target.value.replace(/[^0-9]/g, ""))}
style={{ flex: 1, padding: "5px 8px", borderRadius: 7, fontSize: 11, background: "color-mix(in srgb, var(--color-secondary) 50%, transparent)", border: `1px solid ${!matchedPreset ? "var(--color-blue)" : "var(--color-secondary)"}`, color: "var(--color-foreground)", outline: "none" }} />
<span style={{ fontSize: 11, color: "var(--color-foreground-sec)" }}>h</span>
</div>
</Flyout>
)}
{activeFlyout?.id === "groupby" && (
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Group By">
{GROUP_BY_OPTIONS.map(g => (
<FlyoutOpt key={g.id} label={g.label} selected={groupBy === g.id} onClick={() => { setGroupBy(g.id); closeFlyout(); }} />
))}
</Flyout>
)}
{activeFlyout?.id === "devices" && (
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Devices">
{deviceNames.map(name => (
<FlyoutOpt key={name} label={name} selected={visible.has(name)} color={colors.get(name)}
onClick={() => toggleDevice(name)} />
))}
</Flyout>
)}
</>
))}
{/* ── Summary strip (line/bar only) ── */}
{!readOnly && chartType !== "candle" && (
<div style={{ flexShrink: 0, display: "grid", gridTemplateColumns: `repeat(${1 + visibleDevices.length}, 1fr)`, gap: 8, padding: "8px 14px" }}>
<div style={{ border: "1px solid var(--color-secondary)", borderRadius: 10, padding: "8px 14px" }}>
<p style={{ fontSize: 9, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--color-foreground-sec)", margin: "0 0 3px" }}>
Total {metric === "watts" ? "power" : metric === "energy" ? "energy" : "cost"}
</p>
<p style={{ fontSize: 14, fontWeight: 700, color: "var(--color-foreground)", margin: 0 }}>{fmtTotalVal()}</p>
<p style={{ fontSize: 9, color: "var(--color-foreground-sec)", margin: 0 }}>{totalSubLabel}</p>
</div>
{visibleDevices.map(name => {
const color = colors.get(name) ?? "#888";
return (
<div key={name} style={{ border: "1px solid var(--color-secondary)", borderRadius: 10, padding: "8px 14px" }}>
<p style={{ fontSize: 9, fontWeight: 600, letterSpacing: "0.06em", color: "var(--color-foreground-sec)", margin: "0 0 3px", textTransform: "capitalize" }}>
{name} {metric === "watts" ? "avg" : metric === "energy" ? "energy" : "cost"}
</p>
<p style={{ fontSize: 14, fontWeight: 700, color, margin: 0 }}>{fmtSummaryVal(name)}</p>
<p style={{ fontSize: 9, color: "var(--color-foreground-sec)", margin: 0 }}>{deviceSubLabel(name)}</p>
</div>
);
})}
</div>
)}
{/* ── Hint ── */}
{!readOnly && (
<div style={{ flexShrink: 0, padding: "0 14px 4px" }}>
<p style={{ fontSize: 9, color: "var(--color-foreground-sec)", opacity: 0.5, margin: 0 }}>
{chartType === "candle"
? "Hover candle for OHLC · price is watts"
: "Scroll to zoom X · Shift+scroll Y · drag to pan · double-click to reset"}
</p>
</div>
)}
{/* ── Chart ── */}
<div style={{ flex: 1, minHeight: 0, padding: "0 8px 12px" }}>
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--color-foreground-sec)", gap: 8 }}>
<IconRefresh size={16} className="animate-spin" /> Loading
</div>
) : readings.length === 0 ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--color-foreground-sec)" }}>
No readings available.
</div>
) : chartType === "line" ? (
<LineChart readings={displayReadings} deviceNames={deviceNames} colors={colors} visible={visible} hours={hours} metric={metric} groupBy={groupBy} />
) : chartType === "bar" ? (
<BarChart readings={readings} deviceNames={deviceNames} colors={colors} visible={visible} metric={metric} />
) : (
<CandleChart readings={readings} deviceNames={deviceNames} colors={colors} visible={visible} hours={hours} candleInterval={candleInterval} />
)}
</div>
</div>
);
}