import { useEffect, useState, useMemo, useCallback } from "react"; import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, CartesianGrid, ScatterChart, Scatter, ZAxis, AreaChart, Area, BarChart, Bar, Cell, ReferenceLine, } from "recharts"; import { Activity, BarChart2, TrendingDown, Thermometer, Zap, GitCompare, Filter, RefreshCcw, Eye, EyeOff, Layers, AlertTriangle, CheckCircle2, } from "lucide-react"; import { fetchBatteries, fetchBatteryCapacity, BatteryCapacity } from "../api"; const PALETTE = [ "#22c55e", "#3b82f6", "#f59e0b", "#ef4444", "#8b5cf6", "#06b6d4", "#ec4899", "#84cc16", "#f97316", "#6366f1", "#14b8a6", "#e879f9", "#fb923c", "#a3e635", "#38bdf8", ]; const TOOLTIP_STYLE = { backgroundColor: "#111827", border: "1px solid #374151", borderRadius: "8px", fontSize: 12, }; type Section = "fleet" | "single" | "compare" | "temperature"; interface BatteryChartData { cycle: number; capacity: number; soh: number; degRate?: number; } function SectionBtn({ icon, label, active, onClick }: { icon: React.ReactNode; label: string; active: boolean; onClick: () => void; }) { return ( ); } function SohBadge({ soh }: { soh: number }) { const cls = soh >= 85 ? "text-green-400 bg-green-900/30" : soh >= 70 ? "text-yellow-400 bg-yellow-900/30" : "text-red-400 bg-red-900/30"; const Icon = soh >= 85 ? CheckCircle2 : soh >= 70 ? AlertTriangle : TrendingDown; return ( {soh.toFixed(1)}% ); } export default function GraphPanel() { const [batteries, setBatteries] = useState([]); const [section, setSection] = useState
("fleet"); // Single-battery state const [selectedBat, setSelectedBat] = useState("B0005"); const [capData, setCapData] = useState(null); const [loadingSingle, setLoadingSingle] = useState(false); // Compare state — multi-select up to 5 const [compareIds, setCompareIds] = useState([]); const [compareData, setCompareData] = useState>({}); const [loadingCompare, setLoadingCompare] = useState(false); // Filters const [showEol, setShowEol] = useState(true); const [tempMin, setTempMin] = useState(0); const [tempMax, setTempMax] = useState(100); const [sohMin, setSohMin] = useState(0); useEffect(() => { fetchBatteries().then((bs) => { setBatteries(bs); if (bs.length > 0) setSelectedBat(bs[0].battery_id); }).catch(console.error); }, []); useEffect(() => { if (!selectedBat) return; setLoadingSingle(true); fetchBatteryCapacity(selectedBat) .then(setCapData) .catch(console.error) .finally(() => setLoadingSingle(false)); }, [selectedBat]); const loadCompare = useCallback(async (ids: string[]) => { setLoadingCompare(true); const missing = ids.filter((id) => !compareData[id]); await Promise.all( missing.map((id) => fetchBatteryCapacity(id).then((d) => { setCompareData((prev) => ({ ...prev, [id]: d })); }) ) ); setLoadingCompare(false); }, [compareData]); const toggleCompare = (id: string) => { setCompareIds((prev) => { const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id].slice(-5); loadCompare(next); return next; }); }; const singleChart: BatteryChartData[] = useMemo(() => { if (!capData) return []; return capData.cycles.map((c, i) => ({ cycle: c, capacity: capData.capacity_ah[i], soh: capData.soh_pct[i], degRate: i > 0 ? Math.abs(capData.soh_pct[i] - capData.soh_pct[i - 1]) : 0, })); }, [capData]); // Degradation rate smoothed const degRateChart = useMemo(() => { const window = 10; return singleChart.map((d, i) => { const slice = singleChart.slice(Math.max(0, i - window), i + 1); const avg = slice.reduce((s, r) => s + (r.degRate ?? 0), 0) / slice.length; return { cycle: d.cycle, rate: +avg.toFixed(4) }; }); }, [singleChart]); // RUL projection const rulProjection = useMemo(() => { if (singleChart.length < 10) return []; const last20 = singleChart.slice(-20); const n = last20.length; const xMean = last20.reduce((s, d) => s + d.cycle, 0) / n; const yMean = last20.reduce((s, d) => s + d.soh, 0) / n; const slope = last20.reduce((s, d) => s + (d.cycle - xMean) * (d.soh - yMean), 0) / last20.reduce((s, d) => s + (d.cycle - xMean) ** 2, 0); const intercept = yMean - slope * xMean; const lastCycle = singleChart[singleChart.length - 1].cycle; const eolCycle = (70 - intercept) / slope; const points: { cycle: number; projected: number }[] = []; for (let c = lastCycle; c <= eolCycle + 20; c += Math.ceil((eolCycle - lastCycle) / 30)) { const soh = slope * c + intercept; if (soh < 50) break; points.push({ cycle: Math.round(c), projected: +soh.toFixed(2) }); } return points; }, [singleChart]); // Fleet stats const fleetStats = useMemo(() => { if (!batteries.length) return { healthy: 0, degraded: 0, eol: 0 }; return { healthy: batteries.filter((b) => b.soh_pct >= 85).length, degraded: batteries.filter((b) => b.soh_pct >= 70 && b.soh_pct < 85).length, eol: batteries.filter((b) => b.soh_pct < 70).length, }; }, [batteries]); const filteredBatteries = useMemo(() => batteries.filter( (b) => (b.ambient_temperature ?? b.avg_temperature ?? 25) >= tempMin && (b.ambient_temperature ?? b.avg_temperature ?? 25) <= tempMax && b.soh_pct >= sohMin ), [batteries, tempMin, tempMax, sohMin]); // Fleet bar data (sorted by SOH) const fleetBarData = useMemo(() => [...filteredBatteries] .sort((a, b) => b.soh_pct - a.soh_pct) .slice(0, 25) .map((b) => ({ id: b.battery_id, soh: +b.soh_pct.toFixed(1), temp: b.ambient_temperature ?? b.avg_temperature ?? 25, })), [filteredBatteries]); // Scatter: SOH vs cycles (temp as size) const scatterData = useMemo(() => filteredBatteries.map((b) => ({ x: b.n_cycles, y: b.soh_pct, z: b.ambient_temperature ?? b.avg_temperature ?? 25, name: b.battery_id, })), [filteredBatteries]); // Temp vs SOH scatter const tempScatter = useMemo(() => batteries.map((b) => ({ temp: b.ambient_temperature ?? b.avg_temperature ?? 25, soh: b.soh_pct, name: b.battery_id, })), [batteries]); // Compare overlay data const compareOverlay = useMemo(() => { if (!compareIds.length) return []; const maxLen = Math.max(...compareIds.map((id) => compareData[id]?.cycles?.length ?? 0)); return Array.from({ length: maxLen }, (_, i) => { const row: any = { idx: i }; compareIds.forEach((id) => { const d = compareData[id]; if (d && i < d.cycles.length) { row[`cycle_${id}`] = d.cycles[i]; row[`soh_${id}`] = +d.soh_pct[i].toFixed(2); } }); return row; }); }, [compareIds, compareData]); const sections: { key: Section; label: string; icon: React.ReactNode }[] = [ { key: "fleet", label: "Fleet Overview", icon: }, { key: "single", label: "Single Battery", icon: }, { key: "compare", label: "Compare", icon: }, { key: "temperature", label: "Temperature", icon: }, ]; return (
{/* Header */}
{sections.map((s) => ( setSection(s.key)} /> ))}
{batteries.length} batteries loaded
{/* ── FLEET OVERVIEW ── */} {section === "fleet" && (
{/* Fleet status cards */}
Healthy (≥85%)
{fleetStats.healthy}
Degraded (70–85%)
{fleetStats.degraded}
Near EOL (<70%)
{fleetStats.eol}
{/* Filters */}
Filters:
Min SOH: setSohMin(+e.target.value)} className="w-24 accent-green-500" /> {sohMin}%
Temp range: setTempMin(+e.target.value)} className="w-16 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white" /> setTempMax(+e.target.value)} className="w-16 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white" /> °C
{filteredBatteries.length} / {batteries.length} shown
{/* Fleet SOH bar chart */}

Fleet SOH — Sorted (Top 25)

[`${v}%`, "SOH"]} /> {fleetBarData.map((d, i) => ( = 85 ? "#22c55e" : d.soh >= 70 ? "#f59e0b" : "#ef4444"} /> ))}
{/* Fleet scatter */}

SOH vs Cycles (bubble size = temperature)

payload?.length ? (
{payload[0].payload.name}
SOH: {payload[0].payload.y}%
Cycles: {payload[0].payload.x}
Temp: {payload[0].payload.z}°C
) : null} />
{/* Battery table */}
Battery Roster
{filteredBatteries.map((b, i) => ( { setSelectedBat(b.battery_id); setSection("single"); }} > ))}
ID SOH Cycles Temp °C State
{b.battery_id} {b.n_cycles ?? "—"} {(b.ambient_temperature ?? b.avg_temperature ?? "—")} {b.degradation_state ?? "—"}
)} {/* ── SINGLE BATTERY ── */} {section === "single" && (
{loadingSingle &&
}
{/* SOH + RUL projection */}

SOH Trajectory + RUL Projection

[`${v}%`, name]} /> {showEol && } {rulProjection.length > 0 && ( )}
{/* Capacity fade */}

Capacity Fade (Ah)

[`${v} Ah`, "Capacity"]} /> {showEol && }
{/* Degradation rate */}

Degradation Rate (SOH %/cycle, smoothed)

[`${v}%/cyc`, "Deg Rate"]} />
)} {/* ── COMPARE ── */} {section === "compare" && (
Select up to 5 batteries to compare {loadingCompare &&
}
{batteries.map((b) => { const selected = compareIds.includes(b.battery_id); return ( ); })}
{compareIds.length === 0 ? (

Select batteries above to compare

) : (
{/* SOH overlay */}

SOH Comparison Overlay

{compareIds.map((id, i) => { const d = compareData[id]; if (!d) return null; const lineData = d.cycles.map((c, j) => ({ cycle: c, soh: d.soh_pct[j] })); return ( ); })}
{/* Capacity overlay */}

Capacity Fade Comparison

{compareIds.map((id, i) => { const d = compareData[id]; if (!d) return null; const lineData = d.cycles.map((c, j) => ({ cycle: c, capacity: d.capacity_ah[j] })); return ( ); })}
{/* Summary comparison table */}
Comparison Summary
{compareIds.map((id, i) => { const d = compareData[id]; const lastSoh = d?.soh_pct[d.soh_pct.length - 1]; const minCap = d ? Math.min(...d.capacity_ah) : null; return ( ); })}
Battery Final SOH Cycles Min Capacity
{id} {lastSoh != null ? : "—"} {d?.cycles.length ?? "—"} {minCap != null ? `${minCap.toFixed(3)} Ah` : "—"}
)}
)} {/* ── TEMPERATURE ANALYSIS ── */} {section === "temperature" && (

Temperature vs Final SOH

payload?.length ? (
{payload[0].payload.name}
Temp: {payload[0].payload.temp}°C
SOH: {payload[0].payload.soh}%
) : null} />
{/* Temperature distribution histogram */}

Temperature Distribution

{ const bins: Record = {}; batteries.forEach((b) => { const t = Math.round((b.ambient_temperature ?? b.avg_temperature ?? 25) / 5) * 5; bins[t] = (bins[t] ?? 0) + 1; }); return Object.entries(bins).sort(([a], [b]) => +a - +b).map(([t, count]) => ({ temp: `${t}°C`, count })); })()}>
)}
); }