Spaces:
Running
Running
| 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 ( | |
| <button | |
| onClick={onClick} | |
| className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all ${ | |
| active ? "bg-green-600 text-white shadow-lg shadow-green-900/40" : "bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white" | |
| }`} | |
| > | |
| <span className="w-4 h-4">{icon}</span> | |
| <span className="hidden sm:inline">{label}</span> | |
| </button> | |
| ); | |
| } | |
| 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 ( | |
| <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold ${cls}`}> | |
| <Icon className="w-3 h-3" /> | |
| {soh.toFixed(1)}% | |
| </span> | |
| ); | |
| } | |
| export default function GraphPanel() { | |
| const [batteries, setBatteries] = useState<any[]>([]); | |
| const [section, setSection] = useState<Section>("fleet"); | |
| // Single-battery state | |
| const [selectedBat, setSelectedBat] = useState<string>("B0005"); | |
| const [capData, setCapData] = useState<BatteryCapacity | null>(null); | |
| const [loadingSingle, setLoadingSingle] = useState(false); | |
| // Compare state — multi-select up to 5 | |
| const [compareIds, setCompareIds] = useState<string[]>([]); | |
| const [compareData, setCompareData] = useState<Record<string, BatteryCapacity>>({}); | |
| 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: <Layers className="w-4 h-4" /> }, | |
| { key: "single", label: "Single Battery", icon: <Activity className="w-4 h-4" /> }, | |
| { key: "compare", label: "Compare", icon: <GitCompare className="w-4 h-4" /> }, | |
| { key: "temperature", label: "Temperature", icon: <Thermometer className="w-4 h-4" /> }, | |
| ]; | |
| return ( | |
| <div className="space-y-5"> | |
| {/* Header */} | |
| <div className="flex flex-wrap items-center justify-between gap-3"> | |
| <div className="flex flex-wrap gap-2 bg-gray-950 p-2 rounded-xl border border-gray-800"> | |
| {sections.map((s) => ( | |
| <SectionBtn key={s.key} icon={s.icon} label={s.label} active={section === s.key} onClick={() => setSection(s.key)} /> | |
| ))} | |
| </div> | |
| <div className="flex items-center gap-2 text-xs text-gray-400"> | |
| <Zap className="w-3.5 h-3.5" /> | |
| <span>{batteries.length} batteries loaded</span> | |
| </div> | |
| </div> | |
| {/* ── FLEET OVERVIEW ── */} | |
| {section === "fleet" && ( | |
| <div className="space-y-5"> | |
| {/* Fleet status cards */} | |
| <div className="grid grid-cols-3 gap-3"> | |
| <div className="bg-gray-900 rounded-xl p-4 border border-green-800/40"> | |
| <div className="flex items-center gap-2 mb-1"> | |
| <CheckCircle2 className="w-4 h-4 text-green-400" /> | |
| <span className="text-xs text-gray-400">Healthy (≥85%)</span> | |
| </div> | |
| <div className="text-2xl font-bold text-green-400">{fleetStats.healthy}</div> | |
| </div> | |
| <div className="bg-gray-900 rounded-xl p-4 border border-yellow-800/40"> | |
| <div className="flex items-center gap-2 mb-1"> | |
| <AlertTriangle className="w-4 h-4 text-yellow-400" /> | |
| <span className="text-xs text-gray-400">Degraded (70–85%)</span> | |
| </div> | |
| <div className="text-2xl font-bold text-yellow-400">{fleetStats.degraded}</div> | |
| </div> | |
| <div className="bg-gray-900 rounded-xl p-4 border border-red-800/40"> | |
| <div className="flex items-center gap-2 mb-1"> | |
| <TrendingDown className="w-4 h-4 text-red-400" /> | |
| <span className="text-xs text-gray-400">Near EOL (<70%)</span> | |
| </div> | |
| <div className="text-2xl font-bold text-red-400">{fleetStats.eol}</div> | |
| </div> | |
| </div> | |
| {/* Filters */} | |
| <div className="bg-gray-900 rounded-xl p-4 border border-gray-800"> | |
| <div className="flex flex-wrap items-center gap-4"> | |
| <div className="flex items-center gap-2"> | |
| <Filter className="w-4 h-4 text-gray-400" /> | |
| <span className="text-xs text-gray-400">Filters:</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs text-gray-400">Min SOH:</span> | |
| <input | |
| type="range" min={0} max={100} step={5} value={sohMin} | |
| onChange={(e) => setSohMin(+e.target.value)} | |
| className="w-24 accent-green-500" | |
| /> | |
| <span className="text-xs text-green-400 font-mono w-8">{sohMin}%</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs text-gray-400">Temp range:</span> | |
| <input | |
| type="number" value={tempMin} onChange={(e) => setTempMin(+e.target.value)} | |
| className="w-16 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white" | |
| /> | |
| <span className="text-xs text-gray-500">–</span> | |
| <input | |
| type="number" value={tempMax} onChange={(e) => setTempMax(+e.target.value)} | |
| className="w-16 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white" | |
| /> | |
| <span className="text-xs text-gray-400">°C</span> | |
| </div> | |
| <button | |
| onClick={() => { setSohMin(0); setTempMin(0); setTempMax(100); }} | |
| className="flex items-center gap-1 text-xs text-gray-400 hover:text-white transition-colors" | |
| > | |
| <RefreshCcw className="w-3 h-3" /> Reset | |
| </button> | |
| <span className="text-xs text-gray-500">{filteredBatteries.length} / {batteries.length} shown</span> | |
| </div> | |
| </div> | |
| {/* Fleet SOH bar chart */} | |
| <div className="bg-gray-900 rounded-xl p-5 border border-gray-800"> | |
| <div className="flex items-center gap-2 mb-4"> | |
| <BarChart2 className="w-4 h-4 text-green-400" /> | |
| <h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wide">Fleet SOH — Sorted (Top 25)</h3> | |
| </div> | |
| <ResponsiveContainer width="100%" height={300}> | |
| <BarChart data={fleetBarData} margin={{ bottom: 30 }}> | |
| <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" /> | |
| <XAxis dataKey="id" tick={{ fontSize: 9 }} stroke="#6b7280" angle={-45} textAnchor="end" /> | |
| <YAxis domain={[0, 110]} unit="%" stroke="#6b7280" tick={{ fontSize: 10 }} /> | |
| <Tooltip contentStyle={TOOLTIP_STYLE} formatter={(v: any) => [`${v}%`, "SOH"]} /> | |
| <ReferenceLine y={70} stroke="#ef4444" strokeDasharray="4 4" label={{ value: "EOL 70%", fill: "#ef4444", fontSize: 10 }} /> | |
| <ReferenceLine y={85} stroke="#f59e0b" strokeDasharray="4 4" label={{ value: "85%", fill: "#f59e0b", fontSize: 10 }} /> | |
| <Bar dataKey="soh" radius={[3, 3, 0, 0]}> | |
| {fleetBarData.map((d, i) => ( | |
| <Cell key={i} fill={d.soh >= 85 ? "#22c55e" : d.soh >= 70 ? "#f59e0b" : "#ef4444"} /> | |
| ))} | |
| </Bar> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| </div> | |
| {/* Fleet scatter */} | |
| <div className="bg-gray-900 rounded-xl p-5 border border-gray-800"> | |
| <div className="flex items-center gap-2 mb-4"> | |
| <Activity className="w-4 h-4 text-blue-400" /> | |
| <h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wide">SOH vs Cycles (bubble size = temperature)</h3> | |
| </div> | |
| <ResponsiveContainer width="100%" height={300}> | |
| <ScatterChart> | |
| <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" /> | |
| <XAxis dataKey="x" name="Cycles" stroke="#6b7280" label={{ value: "Cycles", position: "insideBottom", offset: -5, fill: "#9ca3af", fontSize: 11 }} tick={{ fontSize: 10 }} /> | |
| <YAxis dataKey="y" name="SOH %" domain={[40, 110]} stroke="#6b7280" label={{ value: "SOH %", angle: -90, position: "insideLeft", fill: "#9ca3af", fontSize: 11 }} tick={{ fontSize: 10 }} /> | |
| <ZAxis dataKey="z" range={[40, 400]} name="Temp °C" /> | |
| <ReferenceLine y={70} stroke="#ef4444" strokeDasharray="4 4" /> | |
| <Tooltip | |
| cursor={{ strokeDasharray: "3 3" }} | |
| contentStyle={TOOLTIP_STYLE} | |
| content={({ payload }) => payload?.length ? ( | |
| <div className="bg-gray-900 border border-gray-700 rounded-lg p-2 text-xs"> | |
| <div className="font-semibold text-white">{payload[0].payload.name}</div> | |
| <div className="text-green-400">SOH: {payload[0].payload.y}%</div> | |
| <div className="text-blue-400">Cycles: {payload[0].payload.x}</div> | |
| <div className="text-orange-400">Temp: {payload[0].payload.z}°C</div> | |
| </div> | |
| ) : null} | |
| /> | |
| <Scatter data={scatterData} fill="#22c55e" fillOpacity={0.75} /> | |
| </ScatterChart> | |
| </ResponsiveContainer> | |
| </div> | |
| {/* Battery table */} | |
| <div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden"> | |
| <div className="p-4 border-b border-gray-800 flex items-center gap-2"> | |
| <Layers className="w-4 h-4 text-gray-400" /> | |
| <span className="text-sm font-semibold text-gray-300">Battery Roster</span> | |
| </div> | |
| <div className="overflow-x-auto max-h-72"> | |
| <table className="w-full text-sm"> | |
| <thead className="sticky top-0 bg-gray-950"> | |
| <tr className="text-gray-500 border-b border-gray-800"> | |
| <th className="py-2 px-3 text-left">ID</th> | |
| <th className="py-2 px-3 text-right">SOH</th> | |
| <th className="py-2 px-3 text-right">Cycles</th> | |
| <th className="py-2 px-3 text-right">Temp °C</th> | |
| <th className="py-2 px-3 text-left">State</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {filteredBatteries.map((b, i) => ( | |
| <tr | |
| key={i} | |
| className="border-b border-gray-800/40 hover:bg-gray-800/40 cursor-pointer transition-colors" | |
| onClick={() => { setSelectedBat(b.battery_id); setSection("single"); }} | |
| > | |
| <td className="py-2 px-3 font-medium text-white">{b.battery_id}</td> | |
| <td className="py-2 px-3 text-right"><SohBadge soh={b.soh_pct} /></td> | |
| <td className="py-2 px-3 text-right text-gray-300">{b.n_cycles ?? "—"}</td> | |
| <td className="py-2 px-3 text-right text-gray-300">{(b.ambient_temperature ?? b.avg_temperature ?? "—")}</td> | |
| <td className="py-2 px-3"> | |
| <span className="text-xs text-gray-400 bg-gray-800 px-2 py-0.5 rounded-full">{b.degradation_state ?? "—"}</span> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* ── SINGLE BATTERY ── */} | |
| {section === "single" && ( | |
| <div className="space-y-5"> | |
| <div className="flex flex-wrap items-center gap-3"> | |
| <label className="text-sm text-gray-400">Battery:</label> | |
| <select | |
| value={selectedBat} | |
| onChange={(e) => setSelectedBat(e.target.value)} | |
| className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white" | |
| > | |
| {batteries.map((b) => ( | |
| <option key={b.battery_id} value={b.battery_id}> | |
| {b.battery_id} — {b.soh_pct?.toFixed(1)}% SOH | |
| </option> | |
| ))} | |
| </select> | |
| <button | |
| onClick={() => setShowEol((s) => !s)} | |
| className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs transition-colors ${showEol ? "bg-red-900/40 text-red-400" : "bg-gray-800 text-gray-400"}`} | |
| > | |
| {showEol ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />} | |
| EOL Line | |
| </button> | |
| {loadingSingle && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-400" />} | |
| </div> | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-5"> | |
| {/* SOH + RUL projection */} | |
| <div className="bg-gray-900 rounded-xl p-5 border border-gray-800"> | |
| <div className="flex items-center gap-2 mb-4"> | |
| <Activity className="w-4 h-4 text-green-400" /> | |
| <h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wide">SOH Trajectory + RUL Projection</h3> | |
| </div> | |
| <ResponsiveContainer width="100%" height={280}> | |
| <LineChart margin={{ right: 10 }}> | |
| <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" /> | |
| <XAxis dataKey="cycle" type="number" domain={["dataMin", "dataMax"]} stroke="#6b7280" tick={{ fontSize: 10 }} /> | |
| <YAxis domain={[45, 110]} stroke="#6b7280" tick={{ fontSize: 10 }} unit="%" /> | |
| <Tooltip contentStyle={TOOLTIP_STYLE} formatter={(v: any, name) => [`${v}%`, name]} /> | |
| <Legend wrapperStyle={{ fontSize: 11 }} /> | |
| {showEol && <ReferenceLine y={70} stroke="#ef4444" strokeDasharray="4 4" label={{ value: "EOL", fill: "#ef4444", fontSize: 10 }} />} | |
| <Line data={singleChart} type="monotone" dataKey="soh" stroke="#22c55e" dot={false} strokeWidth={2} name="Measured SOH" /> | |
| {rulProjection.length > 0 && ( | |
| <Line data={rulProjection} type="monotone" dataKey="projected" stroke="#f59e0b" dot={false} strokeWidth={1.5} strokeDasharray="6 3" name="Projected RUL" /> | |
| )} | |
| </LineChart> | |
| </ResponsiveContainer> | |
| </div> | |
| {/* Capacity fade */} | |
| <div className="bg-gray-900 rounded-xl p-5 border border-gray-800"> | |
| <div className="flex items-center gap-2 mb-4"> | |
| <TrendingDown className="w-4 h-4 text-blue-400" /> | |
| <h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wide">Capacity Fade (Ah)</h3> | |
| </div> | |
| <ResponsiveContainer width="100%" height={280}> | |
| <AreaChart data={singleChart}> | |
| <defs> | |
| <linearGradient id="capGrad" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} /> | |
| <stop offset="95%" stopColor="#3b82f6" stopOpacity={0} /> | |
| </linearGradient> | |
| </defs> | |
| <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" /> | |
| <XAxis dataKey="cycle" stroke="#6b7280" tick={{ fontSize: 10 }} /> | |
| <YAxis domain={[1.0, 2.2]} stroke="#6b7280" tick={{ fontSize: 10 }} unit="Ah" /> | |
| <Tooltip contentStyle={TOOLTIP_STYLE} formatter={(v: any) => [`${v} Ah`, "Capacity"]} /> | |
| {showEol && <ReferenceLine y={1.4} stroke="#ef4444" strokeDasharray="4 4" label={{ value: "EOL 1.4Ah", fill: "#ef4444", fontSize: 10 }} />} | |
| <Area type="monotone" dataKey="capacity" stroke="#3b82f6" fill="url(#capGrad)" strokeWidth={2} dot={false} /> | |
| </AreaChart> | |
| </ResponsiveContainer> | |
| </div> | |
| {/* Degradation rate */} | |
| <div className="lg:col-span-2 bg-gray-900 rounded-xl p-5 border border-gray-800"> | |
| <div className="flex items-center gap-2 mb-4"> | |
| <BarChart2 className="w-4 h-4 text-orange-400" /> | |
| <h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wide">Degradation Rate (SOH %/cycle, smoothed)</h3> | |
| </div> | |
| <ResponsiveContainer width="100%" height={220}> | |
| <AreaChart data={degRateChart}> | |
| <defs> | |
| <linearGradient id="rateGrad" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="5%" stopColor="#f97316" stopOpacity={0.4} /> | |
| <stop offset="95%" stopColor="#f97316" stopOpacity={0} /> | |
| </linearGradient> | |
| </defs> | |
| <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" /> | |
| <XAxis dataKey="cycle" stroke="#6b7280" tick={{ fontSize: 10 }} /> | |
| <YAxis stroke="#6b7280" tick={{ fontSize: 10 }} unit="%" /> | |
| <Tooltip contentStyle={TOOLTIP_STYLE} formatter={(v: any) => [`${v}%/cyc`, "Deg Rate"]} /> | |
| <Area type="monotone" dataKey="rate" stroke="#f97316" fill="url(#rateGrad)" strokeWidth={2} dot={false} /> | |
| </AreaChart> | |
| </ResponsiveContainer> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* ── COMPARE ── */} | |
| {section === "compare" && ( | |
| <div className="space-y-5"> | |
| <div className="bg-gray-900 rounded-xl p-4 border border-gray-800"> | |
| <div className="flex items-center gap-2 mb-3"> | |
| <GitCompare className="w-4 h-4 text-purple-400" /> | |
| <span className="text-sm font-semibold text-gray-300">Select up to 5 batteries to compare</span> | |
| {loadingCompare && <div className="animate-spin rounded-full h-3 w-3 border-b-2 border-purple-400" />} | |
| </div> | |
| <div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto"> | |
| {batteries.map((b) => { | |
| const selected = compareIds.includes(b.battery_id); | |
| return ( | |
| <button | |
| key={b.battery_id} | |
| onClick={() => toggleCompare(b.battery_id)} | |
| className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${ | |
| selected | |
| ? "text-white shadow-lg" | |
| : "bg-gray-800 text-gray-400 hover:bg-gray-700" | |
| }`} | |
| style={selected ? { | |
| backgroundColor: PALETTE[compareIds.indexOf(b.battery_id)] + "33", | |
| borderColor: PALETTE[compareIds.indexOf(b.battery_id)], | |
| border: "1px solid", | |
| color: PALETTE[compareIds.indexOf(b.battery_id)], | |
| } : undefined} | |
| > | |
| {b.battery_id} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| {compareIds.length === 0 ? ( | |
| <div className="text-center py-16 text-gray-500"> | |
| <GitCompare className="w-10 h-10 mx-auto mb-3 opacity-30" /> | |
| <p>Select batteries above to compare</p> | |
| </div> | |
| ) : ( | |
| <div className="space-y-5"> | |
| {/* SOH overlay */} | |
| <div className="bg-gray-900 rounded-xl p-5 border border-gray-800"> | |
| <div className="flex items-center gap-2 mb-4"> | |
| <Activity className="w-4 h-4 text-green-400" /> | |
| <h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wide">SOH Comparison Overlay</h3> | |
| </div> | |
| <ResponsiveContainer width="100%" height={320}> | |
| <LineChart> | |
| <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" /> | |
| <XAxis dataKey="cycle" type="number" stroke="#6b7280" tick={{ fontSize: 10 }} /> | |
| <YAxis domain={[50, 110]} stroke="#6b7280" tick={{ fontSize: 10 }} unit="%" /> | |
| <Tooltip contentStyle={TOOLTIP_STYLE} /> | |
| <Legend wrapperStyle={{ fontSize: 11 }} /> | |
| <ReferenceLine y={70} stroke="#ef4444" strokeDasharray="4 4" /> | |
| {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 ( | |
| <Line | |
| key={id} | |
| data={lineData} | |
| type="monotone" | |
| dataKey="soh" | |
| stroke={PALETTE[i]} | |
| dot={false} | |
| strokeWidth={2} | |
| name={id} | |
| /> | |
| ); | |
| })} | |
| </LineChart> | |
| </ResponsiveContainer> | |
| </div> | |
| {/* Capacity overlay */} | |
| <div className="bg-gray-900 rounded-xl p-5 border border-gray-800"> | |
| <div className="flex items-center gap-2 mb-4"> | |
| <TrendingDown className="w-4 h-4 text-blue-400" /> | |
| <h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wide">Capacity Fade Comparison</h3> | |
| </div> | |
| <ResponsiveContainer width="100%" height={280}> | |
| <LineChart> | |
| <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" /> | |
| <XAxis dataKey="cycle" type="number" stroke="#6b7280" tick={{ fontSize: 10 }} /> | |
| <YAxis domain={[1.0, 2.2]} stroke="#6b7280" tick={{ fontSize: 10 }} unit="Ah" /> | |
| <Tooltip contentStyle={TOOLTIP_STYLE} /> | |
| <Legend wrapperStyle={{ fontSize: 11 }} /> | |
| <ReferenceLine y={1.4} stroke="#ef4444" strokeDasharray="4 4" /> | |
| {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 ( | |
| <Line | |
| key={id} | |
| data={lineData} | |
| type="monotone" | |
| dataKey="capacity" | |
| stroke={PALETTE[i]} | |
| dot={false} | |
| strokeWidth={2} | |
| name={id} | |
| /> | |
| ); | |
| })} | |
| </LineChart> | |
| </ResponsiveContainer> | |
| </div> | |
| {/* Summary comparison table */} | |
| <div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden"> | |
| <div className="p-4 border-b border-gray-800"> | |
| <span className="text-sm font-semibold text-gray-300">Comparison Summary</span> | |
| </div> | |
| <table className="w-full text-sm"> | |
| <thead> | |
| <tr className="text-gray-500 border-b border-gray-800 bg-gray-950/50"> | |
| <th className="py-2 px-3 text-left">Battery</th> | |
| <th className="py-2 px-3 text-right">Final SOH</th> | |
| <th className="py-2 px-3 text-right">Cycles</th> | |
| <th className="py-2 px-3 text-right">Min Capacity</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {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 ( | |
| <tr key={id} className="border-b border-gray-800/40 hover:bg-gray-800/40"> | |
| <td className="py-2 px-3"> | |
| <span className="inline-flex items-center gap-2"> | |
| <span className="w-2.5 h-2.5 rounded-full inline-block" style={{ backgroundColor: PALETTE[i] }} /> | |
| <span className="font-medium text-white">{id}</span> | |
| </span> | |
| </td> | |
| <td className="py-2 px-3 text-right"> | |
| {lastSoh != null ? <SohBadge soh={lastSoh} /> : "—"} | |
| </td> | |
| <td className="py-2 px-3 text-right text-gray-300">{d?.cycles.length ?? "—"}</td> | |
| <td className="py-2 px-3 text-right text-blue-400">{minCap != null ? `${minCap.toFixed(3)} Ah` : "—"}</td> | |
| </tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* ── TEMPERATURE ANALYSIS ── */} | |
| {section === "temperature" && ( | |
| <div className="space-y-5"> | |
| <div className="bg-gray-900 rounded-xl p-5 border border-gray-800"> | |
| <div className="flex items-center gap-2 mb-4"> | |
| <Thermometer className="w-4 h-4 text-orange-400" /> | |
| <h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wide">Temperature vs Final SOH</h3> | |
| </div> | |
| <ResponsiveContainer width="100%" height={320}> | |
| <ScatterChart> | |
| <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" /> | |
| <XAxis dataKey="temp" name="Temp °C" stroke="#6b7280" label={{ value: "Temperature (°C)", position: "insideBottom", offset: -5, fill: "#9ca3af", fontSize: 11 }} tick={{ fontSize: 10 }} /> | |
| <YAxis dataKey="soh" name="SOH %" stroke="#6b7280" label={{ value: "SOH %", angle: -90, position: "insideLeft", fill: "#9ca3af", fontSize: 11 }} tick={{ fontSize: 10 }} domain={[40, 110]} /> | |
| <ZAxis range={[50, 50]} /> | |
| <ReferenceLine y={70} stroke="#ef4444" strokeDasharray="4 4" /> | |
| <Tooltip | |
| cursor={{ strokeDasharray: "3 3" }} | |
| contentStyle={TOOLTIP_STYLE} | |
| content={({ payload }) => payload?.length ? ( | |
| <div className="bg-gray-900 border border-gray-700 rounded-lg p-2 text-xs"> | |
| <div className="font-semibold text-white">{payload[0].payload.name}</div> | |
| <div className="text-orange-400">Temp: {payload[0].payload.temp}°C</div> | |
| <div className="text-green-400">SOH: {payload[0].payload.soh}%</div> | |
| </div> | |
| ) : null} | |
| /> | |
| <Scatter data={tempScatter} fill="#f97316" fillOpacity={0.75} /> | |
| </ScatterChart> | |
| </ResponsiveContainer> | |
| </div> | |
| {/* Temperature distribution histogram */} | |
| <div className="bg-gray-900 rounded-xl p-5 border border-gray-800"> | |
| <div className="flex items-center gap-2 mb-4"> | |
| <BarChart2 className="w-4 h-4 text-cyan-400" /> | |
| <h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wide">Temperature Distribution</h3> | |
| </div> | |
| <ResponsiveContainer width="100%" height={220}> | |
| <BarChart data={(() => { | |
| const bins: Record<string, number> = {}; | |
| 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 })); | |
| })()}> | |
| <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" /> | |
| <XAxis dataKey="temp" stroke="#6b7280" tick={{ fontSize: 10 }} /> | |
| <YAxis stroke="#6b7280" tick={{ fontSize: 10 }} /> | |
| <Tooltip contentStyle={TOOLTIP_STYLE} /> | |
| <Bar dataKey="count" fill="#06b6d4" radius={[4, 4, 0, 0]} name="Batteries" /> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |