bar graph fix
This commit is contained in:
parent
43318fb8cd
commit
8c3d749197
15 changed files with 973 additions and 401 deletions
|
|
@ -4,8 +4,14 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
|||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
IconRefresh, IconBolt, IconClock, IconCalendarStats,
|
||||
IconEye, IconChevronDown, IconChartBar,
|
||||
IconEye, IconChevronDown, IconChartBar, IconArrowsHorizontal,
|
||||
} from "@tabler/icons-react";
|
||||
import HelpTooltip from "../HelpTooltip";
|
||||
|
||||
const ctrlLabelStyle: React.CSSProperties = {
|
||||
fontSize: 10, fontWeight: 500,
|
||||
color: "var(--color-foreground-sec)", paddingLeft: 2,
|
||||
};
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -16,7 +22,9 @@ 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";
|
||||
type FlyoutId = "metric" | "range" | "groupby" | "devices" | "interval" | "xaxis";
|
||||
type BarXAxis = "device" | "hour-of-day" | "day-of-week" | "day-of-month" | "month-of-year" | "year" | "custom";
|
||||
type BarXAxisUnit = "minute" | "hour" | "day" | "week" | "month";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -58,6 +66,26 @@ const CANDLE_INTERVALS: { id: CandleInterval; label: string; ms: number }[] = [
|
|||
{ id: "1w", label: "1w", ms: 604_800_000 },
|
||||
];
|
||||
|
||||
const BAR_X_AXIS_OPTIONS: { id: BarXAxis; label: string }[] = [
|
||||
{ id: "device", label: "Device" },
|
||||
{ id: "hour-of-day", label: "Hour of day" },
|
||||
{ id: "day-of-week", label: "Day of week" },
|
||||
{ id: "day-of-month", label: "Day of month" },
|
||||
{ id: "month-of-year", label: "Month of year" },
|
||||
{ id: "year", label: "Year" },
|
||||
{ id: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
const BAR_X_UNIT_MS: Record<BarXAxisUnit, number> = {
|
||||
minute: 60_000, hour: 3_600_000, day: 86_400_000,
|
||||
week: 604_800_000, month: 30 * 86_400_000,
|
||||
};
|
||||
const BAR_X_UNIT_SHORT: Record<BarXAxisUnit, string> = {
|
||||
minute: "min", hour: "hr", day: "d", week: "wk", month: "mo",
|
||||
};
|
||||
const BAR_X_DOM_LABELS = Array.from({ length: 31 }, (_, i) => String(i + 1));
|
||||
const BAR_X_MONTH_LABELS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
|
||||
const CHART_PALETTE = [
|
||||
"#60a5fa", "#f87171", "#34d399", "#fbbf24", "#a78bfa",
|
||||
"#f472b6", "#22d3ee", "#a3e635", "#fb923c", "#818cf8",
|
||||
|
|
@ -66,6 +94,17 @@ function chartColor(i: number) { return CHART_PALETTE[i % CHART_PALETTE.length];
|
|||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function getBarXMinHours(xAxis: BarXAxis, customUnit: BarXAxisUnit): number {
|
||||
if (xAxis === "device" || xAxis === "hour-of-day") return 1;
|
||||
if (xAxis === "day-of-week" || xAxis === "day-of-month") return 24;
|
||||
if (xAxis === "month-of-year" || xAxis === "year") return 720;
|
||||
// custom
|
||||
if (customUnit === "minute" || customUnit === "hour") return 1;
|
||||
if (customUnit === "day") return 24;
|
||||
if (customUnit === "week") return 168;
|
||||
return 720; // month
|
||||
}
|
||||
|
||||
function getBucketKey(d: Date, groupBy: GroupBy): number {
|
||||
const n = new Date(d);
|
||||
if (groupBy === "hour") { n.setMinutes(0, 0, 0); }
|
||||
|
|
@ -411,7 +450,7 @@ function LineChart({ readings, deviceNames, colors, visible, hours, metric, grou
|
|||
{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">
|
||||
<linearGradient key={n} id={`${uid}g${n.replace(/\W/g, '_')}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={c} stopOpacity="0.22" />
|
||||
<stop offset="100%" stopColor={c} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
|
|
@ -451,7 +490,7 @@ function LineChart({ readings, deviceNames, colors, visible, hours, metric, grou
|
|||
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={fillPath} fill={`url(#${uid}g${n.replace(/\W/g, '_')})`} />
|
||||
<path d={linePath} fill="none" stroke={c} strokeWidth={2.5} strokeLinejoin="round" strokeLinecap="round" />
|
||||
</g>
|
||||
);
|
||||
|
|
@ -491,17 +530,21 @@ function LineChart({ readings, deviceNames, colors, visible, hours, metric, grou
|
|||
// ── Bar Chart ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const BM = { top: 16, right: 16, bottom: 56, left: 64 };
|
||||
const BAR_X_HOUR_LABELS = Array.from({ length: 24 }, (_, h) => `${h.toString().padStart(2, "0")}:00`);
|
||||
const BAR_X_DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
function BarChart({ readings, deviceNames, colors, visible, metric }: {
|
||||
function BarChart({ readings, deviceNames, colors, visible, metric, xAxis, xAxisCustomN, xAxisCustomUnit }: {
|
||||
readings: HistoryEntry[]; deviceNames: string[]; colors: Map<string, string>;
|
||||
visible: Set<string>; metric: Metric;
|
||||
visible: Set<string>; metric: Metric; xAxis: BarXAxis;
|
||||
xAxisCustomN: number; xAxisCustomUnit: BarXAxisUnit;
|
||||
}) {
|
||||
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 [hover, setHover] = useState<{ label: string; name: string } | null>(null);
|
||||
const [mouse, setMouse] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Always render the container div so ResizeObserver fires on mount
|
||||
useEffect(() => {
|
||||
const el = containerRef.current; if (!el) return;
|
||||
const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight }));
|
||||
|
|
@ -509,44 +552,130 @@ function BarChart({ readings, deviceNames, colors, visible, metric }: {
|
|||
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 groups = useMemo((): Array<{ label: string; bars: Array<{ name: string; val: number }> }> => {
|
||||
const vis = deviceNames.filter(n => visible.has(n));
|
||||
|
||||
if (xAxis === "device") {
|
||||
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);
|
||||
}
|
||||
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 vis.map(name => ({ label: name, bars: [{ name, val: map.get(name) ?? 0 }] }));
|
||||
}
|
||||
return map;
|
||||
}, [readings, deviceNames, metric]);
|
||||
|
||||
// Cyclical grouping helper: group by a repeating key (hour 0-23, dow 0-6, etc.)
|
||||
const makeCyclical = (keyFn: (ts: string) => number, allLabels: string[], keyOffset = 0) => {
|
||||
const buckets = new Map<number, Map<string, number[]>>();
|
||||
for (const r of readings) {
|
||||
const key = keyFn(r.ts);
|
||||
if (!buckets.has(key)) buckets.set(key, new Map());
|
||||
for (const d of r.devices) {
|
||||
if (!visible.has(d.name)) continue;
|
||||
if (!buckets.get(key)!.has(d.name)) buckets.get(key)!.set(d.name, []);
|
||||
buckets.get(key)!.get(d.name)!.push(d.watts);
|
||||
}
|
||||
}
|
||||
return allLabels
|
||||
.map((label, i) => ({
|
||||
label,
|
||||
bars: vis.map(name => {
|
||||
const vals = buckets.get(i + keyOffset)?.get(name) ?? [];
|
||||
return { name, val: vals.length ? vals.reduce((s, v) => s + v, 0) / vals.length : 0 };
|
||||
}),
|
||||
}))
|
||||
.filter(g => g.bars.some(b => b.val > 0));
|
||||
};
|
||||
|
||||
// Absolute grouping helper: group by a computed key, sort by time
|
||||
const makeAbsolute = (keyFn: (ts: string) => number, labelFn: (key: number) => string) => {
|
||||
const buckets = new Map<number, Map<string, number[]>>();
|
||||
for (const r of readings) {
|
||||
const key = keyFn(r.ts);
|
||||
if (!buckets.has(key)) buckets.set(key, new Map());
|
||||
for (const d of r.devices) {
|
||||
if (!visible.has(d.name)) continue;
|
||||
if (!buckets.get(key)!.has(d.name)) buckets.get(key)!.set(d.name, []);
|
||||
buckets.get(key)!.get(d.name)!.push(d.watts);
|
||||
}
|
||||
}
|
||||
return [...buckets.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([key, devMap]) => ({
|
||||
label: labelFn(key),
|
||||
bars: vis.map(name => {
|
||||
const vals = devMap.get(name) ?? [];
|
||||
return { name, val: vals.length ? vals.reduce((s, v) => s + v, 0) / vals.length : 0 };
|
||||
}),
|
||||
}))
|
||||
.filter(g => g.bars.some(b => b.val > 0));
|
||||
};
|
||||
|
||||
if (xAxis === "hour-of-day") return makeCyclical(ts => new Date(ts).getHours(), BAR_X_HOUR_LABELS);
|
||||
if (xAxis === "day-of-week") return makeCyclical(ts => new Date(ts).getDay(), BAR_X_DOW_LABELS);
|
||||
if (xAxis === "day-of-month") return makeCyclical(ts => new Date(ts).getDate(), BAR_X_DOM_LABELS, 1);
|
||||
if (xAxis === "month-of-year") return makeCyclical(ts => new Date(ts).getMonth(), BAR_X_MONTH_LABELS);
|
||||
if (xAxis === "year") return makeAbsolute(ts => new Date(ts).getFullYear(), key => String(key));
|
||||
|
||||
// Custom: absolute bucket of N units
|
||||
const bucketMs = Math.max(60_000, xAxisCustomN * BAR_X_UNIT_MS[xAxisCustomUnit]);
|
||||
return makeAbsolute(
|
||||
ts => Math.floor(new Date(ts).getTime() / bucketMs) * bucketMs,
|
||||
key => {
|
||||
const d = new Date(key);
|
||||
if (xAxisCustomUnit === "minute" || xAxisCustomUnit === "hour")
|
||||
return d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
if (xAxisCustomUnit === "day" || xAxisCustomUnit === "week")
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
return d.toLocaleDateString([], { month: "short", year: "2-digit" });
|
||||
}
|
||||
);
|
||||
}, [readings, deviceNames, metric, xAxis, xAxisCustomN, xAxisCustomUnit, visible]);
|
||||
|
||||
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 noData = visDev.length === 0 || readings.length === 0;
|
||||
const effectiveMetric: Metric = xAxis !== "device" ? "watts" : metric;
|
||||
|
||||
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 allVals = groups.flatMap(g => g.bars.map(b => b.val));
|
||||
const maxVal = Math.max(...allVals, 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);
|
||||
|
||||
const numGroups = Math.max(groups.length, 1);
|
||||
const barsPerGroup = groups[0]?.bars.length ?? Math.max(visDev.length, 1);
|
||||
const groupW = iW / numGroups;
|
||||
const groupPad = Math.max(4, groupW * 0.15);
|
||||
const barSlot = (groupW - groupPad) / Math.max(barsPerGroup, 1);
|
||||
const barGap = Math.max(1, barSlot * 0.1);
|
||||
const barW = Math.max(4, barSlot - barGap);
|
||||
|
||||
const hoverVal = hover
|
||||
? groups.find(g => g.label === hover.label)?.bars.find(b => b.name === hover.name)?.val ?? 0
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: "100%", height: "100%", position: "relative" }}>
|
||||
{w > 0 && h > 0 && (
|
||||
{noData ? (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--color-foreground-sec)" }}>No data</div>
|
||||
) : 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">
|
||||
<linearGradient key={n} id={`${uid}g${n.replace(/\W/g, '_')}`} 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>
|
||||
|
|
@ -557,34 +686,49 @@ function BarChart({ readings, deviceNames, colors, visible, metric }: {
|
|||
{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>
|
||||
<text x={-6} y={yS(v)} textAnchor="end" dominantBaseline="middle" fill="var(--color-foreground-sec)" fontSize={10}>{fmtMetricTick(v, effectiveMetric)}</text>
|
||||
</g>
|
||||
))}
|
||||
{visDev.map((n, i) => {
|
||||
const val = deviceVals.get(n) ?? 0;
|
||||
const x = gap + i * (barW + gap);
|
||||
{groups.map((g, gi) => {
|
||||
const gx = gi * groupW + groupPad / 2;
|
||||
const groupCenter = gi * groupW + groupW / 2;
|
||||
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 key={g.label}>
|
||||
{g.bars.map((bar, bi) => {
|
||||
const bx = gx + bi * barSlot;
|
||||
const bh = Math.max(0, iH - yS(bar.val));
|
||||
const isHovered = hover?.label === g.label && hover?.name === bar.name;
|
||||
return (
|
||||
<g key={bar.name}
|
||||
onMouseEnter={e => { setHover({ label: g.label, name: bar.name }); setMouse({ x: e.clientX, y: e.clientY }); }}
|
||||
onMouseMove={e => setMouse({ x: e.clientX, y: e.clientY })}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
style={{ cursor: "default" }}
|
||||
>
|
||||
<rect x={bx} y={yS(bar.val)} width={barW} height={bh}
|
||||
fill={`url(#${uid}g${bar.name.replace(/\W/g, '_')})`}
|
||||
opacity={isHovered ? 1 : 0.85} rx={3} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<text
|
||||
transform={`translate(${groupCenter},${iH + 8}) rotate(-35)`}
|
||||
textAnchor="end" fill="var(--color-foreground-sec)" fontSize={10}
|
||||
style={{ textTransform: "capitalize" }}
|
||||
>{g.label}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line x1={0} x2={iW} y1={iH} y2={iH} stroke="var(--color-secondary)" strokeWidth={1} />
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
) : null}
|
||||
{hover !== null && typeof document !== "undefined" && createPortal(
|
||||
<ChartTooltip label="" mouseX={mouse.x} mouseY={mouse.y}>
|
||||
<ChartTooltip label={hover.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>
|
||||
<span style={{ width: 8, height: 8, borderRadius: "50%", background: colors.get(hover.name) ?? "#888", flexShrink: 0 }} />
|
||||
<span style={{ color: "var(--color-foreground)", fontWeight: 500, textTransform: "capitalize" }}>{hover.name}</span>
|
||||
<span style={{ color: colors.get(hover.name) ?? "#888", fontWeight: 700 }}>{fmtMetricVal(hoverVal, effectiveMetric)}</span>
|
||||
</div>
|
||||
</ChartTooltip>,
|
||||
document.body
|
||||
|
|
@ -750,11 +894,28 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou
|
|||
const [metric, setMetric] = useState<Metric>("watts");
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>("auto");
|
||||
const [candleInterval, setCandleInterval] = useState<CandleInterval>("auto");
|
||||
const [barXAxis, setBarXAxis] = useState<BarXAxis>("device");
|
||||
const [barXAxisCustomN, setBarXAxisCustomN] = useState(1);
|
||||
const [barXAxisCustomUnit, setBarXAxisCustomUnit] = useState<BarXAxisUnit>("hour");
|
||||
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 barXMinHours = chartType === "bar" ? getBarXMinHours(barXAxis, barXAxisCustomUnit) : 1;
|
||||
const filteredPresets = PRESETS.filter(p => p.h >= barXMinHours);
|
||||
|
||||
// Auto-bump time range when X axis changes to require more data
|
||||
useEffect(() => {
|
||||
if (chartType !== "bar") return;
|
||||
const min = getBarXMinHours(barXAxis, barXAxisCustomUnit);
|
||||
const currentH = Math.max(1, parseInt(hoursInput, 10) || 24);
|
||||
if (currentH < min) {
|
||||
const firstValid = PRESETS.find(p => p.h >= min);
|
||||
if (firstValid) setHoursInput(String(firstValid.h));
|
||||
}
|
||||
}, [barXAxis, barXAxisCustomUnit, chartType]);
|
||||
|
||||
const openFlyout = useCallback((id: FlyoutId, rect: DOMRect, el: HTMLButtonElement) => {
|
||||
if (flyout?.id === id) { setFlyout(null); return; }
|
||||
const alignRight = rect.left > window.innerWidth * 0.55;
|
||||
|
|
@ -850,11 +1011,14 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou
|
|||
};
|
||||
|
||||
// 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 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 barXAxisLabel = barXAxis === "custom"
|
||||
? `Every ${barXAxisCustomN}${BAR_X_UNIT_SHORT[barXAxisCustomUnit]}`
|
||||
: BAR_X_AXIS_OPTIONS.find(o => o.id === barXAxis)?.label ?? "Device";
|
||||
|
||||
const refreshBtn = (
|
||||
<button onClick={fetchHistory} disabled={loading} style={{
|
||||
|
|
@ -875,18 +1039,38 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou
|
|||
{!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)} />
|
||||
<div style={{ flexShrink: 0, display: "flex", alignItems: "flex-end", gap: 6, padding: "6px 14px 8px", borderBottom: "1px solid color-mix(in srgb, var(--color-secondary) 60%, transparent)" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={ctrlLabelStyle}>Time Range</span>
|
||||
<HelpTooltip text="How far back to load data. Pick a preset or type a custom number of hours.">
|
||||
<CtrlBtn icon={<IconClock size={13} />} label={rangeLabel} isOpen={flyout?.id === "range"} onClick={(rect, el) => openFlyout("range", rect, el)} />
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={ctrlLabelStyle}>Candle Period</span>
|
||||
<HelpTooltip text="The time interval each candlestick represents. Auto picks the best size for the chosen range.">
|
||||
<CtrlBtn icon={<IconChartBar size={13} />} label={intervalLabel} isOpen={flyout?.id === "interval"} onClick={(rect, el) => openFlyout("interval", rect, el)} />
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
{deviceNames.length > 0 && (
|
||||
<CtrlBtn icon={<IconEye size={13} />} label={devLabel} isOpen={flyout?.id === "devices"} onClick={(rect, el) => openFlyout("devices", rect, el)} />
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={ctrlLabelStyle}>Devices</span>
|
||||
<HelpTooltip text="Toggle individual devices on or off to focus the chart on specific plugs.">
|
||||
<CtrlBtn icon={<IconEye size={13} />} label={devLabel} isOpen={flyout?.id === "devices"} onClick={(rect, el) => openFlyout("devices", rect, el)} />
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginLeft: "auto" }}>{refreshBtn}</div>
|
||||
<div style={{ marginLeft: "auto", display: "flex", flexDirection: "column", gap: 2, alignItems: "flex-end" }}>
|
||||
<span style={ctrlLabelStyle}>Refresh</span>
|
||||
<HelpTooltip text="Reload chart data from the server.">
|
||||
{refreshBtn}
|
||||
</HelpTooltip>
|
||||
</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 }) => (
|
||||
{filteredPresets.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)"}`,
|
||||
|
|
@ -928,26 +1112,122 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou
|
|||
) : (
|
||||
<>
|
||||
{/* ── 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={{ flexShrink: 0, display: "flex", alignItems: "flex-end", gap: 6, padding: "6px 14px 8px", borderBottom: "1px solid color-mix(in srgb, var(--color-secondary) 60%, transparent)" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={ctrlLabelStyle}>Y axis</span>
|
||||
<HelpTooltip text="What to measure: Power (live watts), Energy (kWh consumed), or estimated Cost in dollars.">
|
||||
<CtrlBtn icon={<IconBolt size={13} />} label={metricLabel} isOpen={flyout?.id === "metric"} onClick={(rect, el) => openFlyout("metric", rect, el)} />
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
{chartType === "bar" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={ctrlLabelStyle}>X axis</span>
|
||||
<HelpTooltip text="What to use as the X axis: one bar per device, or group readings by hour of day or day of week.">
|
||||
<CtrlBtn icon={<IconArrowsHorizontal size={13} />} label={barXAxisLabel} isOpen={flyout?.id === "xaxis"} onClick={(rect, el) => openFlyout("xaxis", rect, el)} />
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginLeft: "auto" }}>{refreshBtn}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={ctrlLabelStyle}>Time Range</span>
|
||||
<HelpTooltip text="How far back to load data. Pick a preset or type a custom number of hours.">
|
||||
<CtrlBtn icon={<IconClock size={13} />} label={rangeLabel} isOpen={flyout?.id === "range"} onClick={(rect, el) => openFlyout("range", rect, el)} />
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={ctrlLabelStyle}>Group By</span>
|
||||
<HelpTooltip text="How to bucket data points in time. Auto selects the best interval for the chosen range.">
|
||||
<CtrlBtn icon={<IconCalendarStats size={13} />} label={groupLabel} isOpen={flyout?.id === "groupby"} onClick={(rect, el) => openFlyout("groupby", rect, el)} />
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
{deviceNames.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={ctrlLabelStyle}>Devices</span>
|
||||
<HelpTooltip text="Toggle individual devices on or off to focus the chart on specific plugs.">
|
||||
<CtrlBtn icon={<IconEye size={13} />} label={devLabel} isOpen={flyout?.id === "devices"} onClick={(rect, el) => openFlyout("devices", rect, el)} />
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginLeft: "auto", display: "flex", flexDirection: "column", gap: 2, alignItems: "flex-end" }}>
|
||||
<span style={ctrlLabelStyle}>Refresh</span>
|
||||
<HelpTooltip text="Reload chart data from the server.">
|
||||
{refreshBtn}
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
</div>
|
||||
{activeFlyout?.id === "metric" && (
|
||||
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Metric">
|
||||
<Flyout pos={activeFlyout} onClose={closeFlyout} title="Y Axis">
|
||||
{METRICS.map(m => (
|
||||
<FlyoutOpt key={m.id} label={m.label} selected={metric === m.id} onClick={() => { setMetric(m.id); closeFlyout(); }} />
|
||||
))}
|
||||
</Flyout>
|
||||
)}
|
||||
{activeFlyout?.id === "xaxis" && (
|
||||
<Flyout pos={activeFlyout} onClose={closeFlyout} title="X Axis">
|
||||
<FlyoutOpt label="Device" selected={barXAxis === "device"} onClick={() => { setBarXAxis("device"); closeFlyout(); }} />
|
||||
<div style={{ height: 1, background: "var(--color-secondary)", margin: "6px 2px 2px" }} />
|
||||
<p style={{ fontSize: 9, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em", color: "var(--color-foreground-sec)", margin: "4px 4px 2px" }}>Time</p>
|
||||
<p style={{ fontSize: 9, fontWeight: 600, color: "var(--color-foreground-sec)", margin: "4px 10px 2px", opacity: 0.75 }}>Hour</p>
|
||||
<FlyoutOpt label="Hour of day" selected={barXAxis === "hour-of-day"} onClick={() => { setBarXAxis("hour-of-day"); closeFlyout(); }} />
|
||||
<p style={{ fontSize: 9, fontWeight: 600, color: "var(--color-foreground-sec)", margin: "6px 10px 2px", opacity: 0.75 }}>Day</p>
|
||||
<FlyoutOpt label="Day of week" selected={barXAxis === "day-of-week"} onClick={() => { setBarXAxis("day-of-week"); closeFlyout(); }} />
|
||||
<FlyoutOpt label="Day of month" selected={barXAxis === "day-of-month"} onClick={() => { setBarXAxis("day-of-month"); closeFlyout(); }} />
|
||||
<p style={{ fontSize: 9, fontWeight: 600, color: "var(--color-foreground-sec)", margin: "6px 10px 2px", opacity: 0.75 }}>Month</p>
|
||||
<FlyoutOpt label="Month of year" selected={barXAxis === "month-of-year"} onClick={() => { setBarXAxis("month-of-year"); closeFlyout(); }} />
|
||||
<p style={{ fontSize: 9, fontWeight: 600, color: "var(--color-foreground-sec)", margin: "6px 10px 2px", opacity: 0.75 }}>Year</p>
|
||||
<FlyoutOpt label="Year" selected={barXAxis === "year"} onClick={() => { setBarXAxis("year"); closeFlyout(); }} />
|
||||
<div style={{ height: 1, background: "var(--color-secondary)", margin: "6px 2px 4px" }} />
|
||||
<p style={{ fontSize: 9, fontWeight: 600, color: "var(--color-foreground-sec)", margin: "2px 10px 4px", opacity: 0.75 }}>Custom</p>
|
||||
<button
|
||||
onClick={() => setBarXAxis("custom")}
|
||||
style={{
|
||||
width: "100%", display: "flex", alignItems: "center", gap: 7,
|
||||
padding: "7px 10px", borderRadius: 8, border: "none",
|
||||
background: barXAxis === "custom" ? "color-mix(in srgb, var(--color-blue) 14%, transparent)" : "transparent",
|
||||
color: barXAxis === "custom" ? "var(--color-blue)" : "var(--color-foreground)",
|
||||
cursor: "pointer", fontSize: 12, fontWeight: barXAxis === "custom" ? 600 : 400,
|
||||
transition: "background 100ms",
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 7, height: 7, borderRadius: "50%", flexShrink: 0,
|
||||
background: barXAxis === "custom" ? "var(--color-blue)" : "var(--color-secondary)",
|
||||
boxShadow: barXAxis === "custom" ? "0 0 0 2px color-mix(in srgb, var(--color-blue) 25%, transparent)" : "none",
|
||||
}} />
|
||||
Every
|
||||
<input
|
||||
type="text" inputMode="numeric" pattern="[0-9]*" value={barXAxisCustomN}
|
||||
onClick={e => { e.stopPropagation(); setBarXAxis("custom"); }}
|
||||
onChange={e => { setBarXAxis("custom"); setBarXAxisCustomN(Math.max(1, parseInt(e.target.value.replace(/\D/g, "")) || 1)); }}
|
||||
style={{
|
||||
width: 38, padding: "2px 4px", borderRadius: 5, fontSize: 11, textAlign: "center",
|
||||
background: "color-mix(in srgb, var(--color-secondary) 70%, transparent)",
|
||||
border: "1px solid var(--color-secondary)", color: "var(--color-foreground)", outline: "none",
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={barXAxisCustomUnit}
|
||||
onClick={e => { e.stopPropagation(); setBarXAxis("custom"); }}
|
||||
onChange={e => { setBarXAxis("custom"); setBarXAxisCustomUnit(e.target.value as BarXAxisUnit); }}
|
||||
style={{
|
||||
padding: "2px 4px", borderRadius: 5, fontSize: 11,
|
||||
background: "color-mix(in srgb, var(--color-secondary) 70%, transparent)",
|
||||
border: "1px solid var(--color-secondary)", color: "var(--color-foreground)",
|
||||
outline: "none", cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<option value="minute">min</option>
|
||||
<option value="hour">hour</option>
|
||||
<option value="day">day</option>
|
||||
<option value="week">week</option>
|
||||
<option value="month">month</option>
|
||||
</select>
|
||||
</button>
|
||||
</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 }) => (
|
||||
{filteredPresets.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)"}`,
|
||||
|
|
@ -1034,7 +1314,7 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou
|
|||
) : 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} />
|
||||
<BarChart readings={readings} deviceNames={deviceNames} colors={colors} visible={visible} metric={metric} xAxis={barXAxis} xAxisCustomN={barXAxisCustomN} xAxisCustomUnit={barXAxisCustomUnit} />
|
||||
) : (
|
||||
<CandleChart readings={readings} deviceNames={deviceNames} colors={colors} visible={visible} hours={hours} candleInterval={candleInterval} />
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue