Compare commits
1 Commits
main
...
sl-webui/i
| Author | SHA1 | Date | |
|---|---|---|---|
| 6be9cfa0b1 |
@ -30,6 +30,7 @@ import { NavModeSelector } from './components/NavModeSelector.jsx';
|
||||
// Telemetry panels
|
||||
import { ImuPanel } from './components/ImuPanel.jsx';
|
||||
import { BatteryPanel } from './components/BatteryPanel.jsx';
|
||||
import { BatteryHistory } from './components/BatteryHistory.jsx';
|
||||
import { MotorPanel } from './components/MotorPanel.jsx';
|
||||
import { MapViewer } from './components/MapViewer.jsx';
|
||||
import { ControlMode } from './components/ControlMode.jsx';
|
||||
@ -208,7 +209,12 @@ export default function App() {
|
||||
{activeTab === 'navigation' && <NavModeSelector subscribe={subscribe} publish={publishFn} />}
|
||||
|
||||
{activeTab === 'imu' && <ImuPanel subscribe={subscribe} />}
|
||||
{activeTab === 'battery' && <BatteryPanel subscribe={subscribe} />}
|
||||
{activeTab === 'battery' && (
|
||||
<div className="space-y-6">
|
||||
<BatteryPanel subscribe={subscribe} />
|
||||
<BatteryHistory subscribe={subscribe} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'motors' && <MotorPanel subscribe={subscribe} />}
|
||||
{activeTab === 'map' && <MapViewer subscribe={subscribe} />}
|
||||
{activeTab === 'control' && <ControlMode subscribe={subscribe} />}
|
||||
|
||||
336
ui/social-bot/src/components/BatteryHistory.jsx
Normal file
336
ui/social-bot/src/components/BatteryHistory.jsx
Normal file
@ -0,0 +1,336 @@
|
||||
/**
|
||||
* BatteryHistory.jsx — 24-hour battery history chart
|
||||
*
|
||||
* Displays:
|
||||
* - Voltage (left axis, cyan)
|
||||
* - State of Charge % (right axis, green)
|
||||
* - Charge cycles marked with green vertical bands
|
||||
*
|
||||
* Topics:
|
||||
* /saltybot/battery/history (custom msg with timestamps, voltages, soc, cycles)
|
||||
*
|
||||
* Auto-refreshes every 30 seconds.
|
||||
* Canvas-based dual-axis sparkline visualization.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const LIPO_4S_MIN = 12.0;
|
||||
const LIPO_4S_MAX = 16.8;
|
||||
const HISTORY_24H_MAX = 1440; // 24h at 1 min granularity
|
||||
|
||||
function DualAxisChart({
|
||||
voltages = [],
|
||||
socValues = [],
|
||||
chargeCycles = [],
|
||||
width = 1000,
|
||||
height = 200,
|
||||
title = '24h Battery History',
|
||||
}) {
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
if (!canvas || voltages.length < 2) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
const padding = { top: 20, bottom: 30, left: 50, right: 50 };
|
||||
const graphW = W - padding.left - padding.right;
|
||||
const graphH = H - padding.top - padding.bottom;
|
||||
|
||||
// Clear and background
|
||||
ctx.fillStyle = '#020208';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Voltage range (left axis)
|
||||
const minV = Math.min(LIPO_4S_MIN, ...voltages);
|
||||
const maxV = Math.max(LIPO_4S_MAX, ...voltages);
|
||||
const rangeV = maxV - minV || 1;
|
||||
|
||||
// SoC range (right axis)
|
||||
const minSoC = Math.min(...socValues, 0);
|
||||
const maxSoC = Math.max(...socValues, 100);
|
||||
const rangeSoC = maxSoC - minSoC || 1;
|
||||
|
||||
// Helper: screen coordinates
|
||||
const toScreenX = (idx) =>
|
||||
padding.left + (idx / (voltages.length - 1)) * graphW;
|
||||
const toScreenYVolt = (v) =>
|
||||
padding.top + graphH - ((v - minV) / rangeV) * graphH * 0.9 - graphH * 0.05;
|
||||
const toScreenYSoC = (s) =>
|
||||
padding.top + graphH - ((s - minSoC) / rangeSoC) * graphH * 0.9 - graphH * 0.05;
|
||||
|
||||
// Draw charge cycles (green vertical bands)
|
||||
if (chargeCycles && chargeCycles.length > 0) {
|
||||
ctx.fillStyle = 'rgba(34, 197, 94, 0.15)'; // green with transparency
|
||||
chargeCycles.forEach((cycle) => {
|
||||
if (cycle.startIdx !== undefined && cycle.endIdx !== undefined) {
|
||||
const x1 = toScreenX(cycle.startIdx);
|
||||
const x2 = toScreenX(cycle.endIdx);
|
||||
ctx.fillRect(x1, padding.top, x2 - x1, graphH);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Grid lines at time intervals (4 lines for 24h = every 6h)
|
||||
ctx.strokeStyle = 'rgba(0, 255, 255, 0.05)';
|
||||
ctx.lineWidth = 0.5;
|
||||
const gridCount = 4;
|
||||
for (let i = 1; i < gridCount; i++) {
|
||||
const x = padding.left + (i / gridCount) * graphW;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padding.top);
|
||||
ctx.lineTo(x, padding.top + graphH);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Grid lines for voltage (left)
|
||||
for (let v = Math.ceil(minV); v <= maxV; v += 0.5) {
|
||||
const y = toScreenYVolt(v);
|
||||
ctx.strokeStyle = 'rgba(0, 255, 255, 0.03)';
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
ctx.lineTo(padding.left + graphW, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Voltage line (cyan, left axis)
|
||||
ctx.strokeStyle = '#06b6d4';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
voltages.forEach((v, i) => {
|
||||
const x = toScreenX(i);
|
||||
const y = toScreenYVolt(v);
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
// Fill under voltage curve
|
||||
ctx.lineTo(
|
||||
padding.left + graphW,
|
||||
toScreenYVolt(voltages[voltages.length - 1])
|
||||
);
|
||||
ctx.lineTo(padding.left + graphW, padding.top + graphH);
|
||||
ctx.lineTo(padding.left, padding.top + graphH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(6, 182, 212, 0.1)';
|
||||
ctx.fill();
|
||||
|
||||
// SoC line (green, right axis)
|
||||
ctx.strokeStyle = '#22c55e';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
socValues.forEach((s, i) => {
|
||||
const x = toScreenX(i);
|
||||
const y = toScreenYSoC(s);
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
// Fill under SoC curve
|
||||
ctx.lineTo(
|
||||
padding.left + graphW,
|
||||
toScreenYSoC(socValues[socValues.length - 1])
|
||||
);
|
||||
ctx.lineTo(padding.left + graphW, padding.top + graphH);
|
||||
ctx.lineTo(padding.left, padding.top + graphH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(34, 197, 94, 0.1)';
|
||||
ctx.fill();
|
||||
|
||||
// Left axis (voltage) labels
|
||||
ctx.fillStyle = '#06b6d4';
|
||||
ctx.font = 'bold 9px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
for (let v = Math.ceil(minV); v <= maxV; v += 0.5) {
|
||||
const y = toScreenYVolt(v);
|
||||
ctx.fillText(`${v.toFixed(1)}V`, padding.left - 5, y + 3);
|
||||
}
|
||||
|
||||
// Right axis (SoC) labels
|
||||
ctx.fillStyle = '#22c55e';
|
||||
ctx.font = 'bold 9px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
for (let s = 0; s <= 100; s += 25) {
|
||||
const y = toScreenYSoC(s);
|
||||
ctx.fillText(`${s}%`, padding.left + graphW + 5, y + 3);
|
||||
}
|
||||
|
||||
// Bottom axis (time labels: 0h, 6h, 12h, 18h, 24h)
|
||||
ctx.fillStyle = '#999999';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
const timeLabels = ['0h', '6h', '12h', '18h', '24h'];
|
||||
for (let i = 0; i < timeLabels.length; i++) {
|
||||
const x = padding.left + (i / (timeLabels.length - 1)) * graphW;
|
||||
ctx.fillText(timeLabels[i], x, H - 8);
|
||||
}
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = '#4b5563';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(
|
||||
padding.left,
|
||||
padding.top,
|
||||
graphW,
|
||||
graphH
|
||||
);
|
||||
}, [voltages, socValues, chargeCycles]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={ref}
|
||||
width={width}
|
||||
height={height}
|
||||
className="w-full rounded border border-gray-800 block"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BatteryHistory({ subscribe }) {
|
||||
const [voltages, setVoltages] = useState([]);
|
||||
const [socValues, setSocValues] = useState([]);
|
||||
const [chargeCycles, setChargeCycles] = useState([]);
|
||||
const [lastUpdate, setLastUpdate] = useState(null);
|
||||
const refreshInterval = useRef(null);
|
||||
|
||||
// Subscribe to /saltybot/battery/history
|
||||
useEffect(() => {
|
||||
const unsub = subscribe(
|
||||
'/saltybot/battery/history',
|
||||
'saltybot_social_msgs/BatteryHistory',
|
||||
(msg) => {
|
||||
try {
|
||||
// Expect msg structure:
|
||||
// {
|
||||
// timestamps: [epoch_ms, ...],
|
||||
// voltages_v: [v1, v2, ...],
|
||||
// soc_pct: [s1, s2, ...],
|
||||
// charge_cycles: [{ start_idx, end_idx }, ...]
|
||||
// }
|
||||
if (msg.voltages_v && msg.soc_pct) {
|
||||
setVoltages(msg.voltages_v);
|
||||
setSocValues(msg.soc_pct);
|
||||
setChargeCycles(msg.charge_cycles || []);
|
||||
setLastUpdate(Date.now());
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('BatteryHistory: failed to parse message', e);
|
||||
}
|
||||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [subscribe]);
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
useEffect(() => {
|
||||
const refreshData = () => {
|
||||
// Trigger a re-fetch by publishing a request or re-subscribing
|
||||
// For now, just mark that refresh is pending
|
||||
setLastUpdate(Date.now());
|
||||
};
|
||||
refreshInterval.current = setInterval(refreshData, 30000);
|
||||
return () => clearInterval(refreshInterval.current);
|
||||
}, []);
|
||||
|
||||
const hasData = voltages.length >= 2;
|
||||
const latestV = voltages[voltages.length - 1];
|
||||
const latestSoC = socValues[socValues.length - 1];
|
||||
|
||||
return (
|
||||
<div className="space-y-3 w-full">
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
|
||||
<div className="bg-gray-950 rounded border border-gray-900 p-2">
|
||||
<div className="text-cyan-700 text-xs font-bold tracking-widest">LATEST V</div>
|
||||
<div className="text-lg font-bold text-cyan-400">
|
||||
{hasData ? latestV.toFixed(2) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-950 rounded border border-gray-900 p-2">
|
||||
<div className="text-green-700 text-xs font-bold tracking-widest">LATEST %</div>
|
||||
<div className="text-lg font-bold text-green-400">
|
||||
{hasData ? Math.round(latestSoC) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-950 rounded border border-gray-900 p-2">
|
||||
<div className="text-purple-700 text-xs font-bold tracking-widest">CYCLES</div>
|
||||
<div className="text-lg font-bold text-purple-400">
|
||||
{chargeCycles.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-950 rounded border border-gray-900 p-2">
|
||||
<div className="text-gray-600 text-xs font-bold tracking-widest">SAMPLES</div>
|
||||
<div className="text-lg font-bold text-gray-400">
|
||||
{voltages.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-950 rounded border border-gray-900 p-2">
|
||||
<div className="text-yellow-700 text-xs font-bold tracking-widest">MIN V</div>
|
||||
<div className="text-lg font-bold text-yellow-400">
|
||||
{hasData ? Math.min(...voltages).toFixed(2) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-950 rounded border border-gray-900 p-2">
|
||||
<div className="text-red-700 text-xs font-bold tracking-widest">MAX V</div>
|
||||
<div className="text-lg font-bold text-red-400">
|
||||
{hasData ? Math.max(...voltages).toFixed(2) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="text-cyan-700 text-xs font-bold tracking-widest">
|
||||
24H BATTERY HISTORY
|
||||
</div>
|
||||
<div className="text-gray-600 text-xs">
|
||||
{lastUpdate
|
||||
? new Date(lastUpdate).toLocaleTimeString()
|
||||
: 'Waiting for data…'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasData ? (
|
||||
<div className="overflow-x-auto">
|
||||
<DualAxisChart
|
||||
voltages={voltages}
|
||||
socValues={socValues}
|
||||
chargeCycles={chargeCycles}
|
||||
width={1200}
|
||||
height={240}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-600 text-xs text-center py-12 border border-dashed border-gray-800 rounded">
|
||||
Waiting for /saltybot/battery/history data…
|
||||
<div className="mt-2 text-gray-700 text-xs">
|
||||
Ensure firmware publishes to /saltybot/battery/history topic with
|
||||
voltages_v and soc_pct fields.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-6 mt-3 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-1 bg-cyan-400 rounded"></div>
|
||||
<span className="text-cyan-400">Voltage (V)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-1 bg-green-400 rounded"></div>
|
||||
<span className="text-green-400">State of Charge (%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-1 bg-green-600 rounded opacity-30"></div>
|
||||
<span className="text-green-600">Charge Cycles</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user