2026-08-13 16:23:18 +04:00

223 lines
8.8 KiB
JavaScript

/* Overview - the at-a-glance page. One hero figure, supporting tiles, trends. */
import { store, get, num, duration, batteryTone, tempTone } from '../core.js';
import { el, card, stat, meter, note, pageHead, button, kv } from '../ui.js';
import { timeSeries, sparkline, attitude, compass } from '../charts.js';
export default {
id: 'overview',
label: 'Overview',
icon: 'overview',
async render() {
const root = el('div.stack');
/* -- Hero ------------------------------------------------------------- */
const heroNumber = el('span', { text: '—' });
const heroValue = el('div.hero', {}, heroNumber, el('span.unit', { text: '%' }));
const meterHost = el('div');
const heroSub = el('div.stat-sub', { text: 'Battery remaining' });
const heroSpark = el('div', { style: { marginTop: '8px', height: '40px' } });
/* -- Tiles ------------------------------------------------------------ */
const tiles = el('div.grid.cols-3');
const tileRefs = {};
for (const [key, label] of [
['mode', 'Motion mode'],
['transport', 'Link'],
['uptime', 'Bridge uptime'],
['speed', 'Ground speed'],
['heading', 'Heading'],
['temp', 'Battery temp'],
]) {
const node = stat(label, '—', { sub: ' ' });
tileRefs[key] = node;
tiles.appendChild(node);
}
function setTile(key, value, sub, tone = 'default', unit = '') {
const node = tileRefs[key];
if (!node) return;
node.dataset.tone = tone;
const valueNode = node.querySelector('.stat-value');
valueNode.textContent = String(value);
if (unit) valueNode.appendChild(el('span.unit', { text: unit }));
node.querySelector('.stat-sub').textContent = sub || '';
}
/* -- Attitude --------------------------------------------------------- */
const attitudeHost = el('div.attitude');
const compassHost = el('div.attitude');
const attitudeReadout = el('div');
/* -- Charts ----------------------------------------------------------- */
const velChart = timeSeries([
{ key: 'vel_forward', label: 'Forward', points: [], colorIndex: 0, unit: ' m/s' },
{ key: 'vel_lateral', label: 'Lateral', points: [], colorIndex: 1, unit: ' m/s' },
{ key: 'vel_angular', label: 'Yaw rate', points: [], colorIndex: 2, unit: ' rad/s' },
], { height: 176, precision: 2, zeroLine: true });
const batteryChart = timeSeries(
[{ key: 'battery_pct', label: 'Battery', points: [], colorIndex: 0, unit: '%' }],
{ height: 168, precision: 1 },
);
const tempChart = timeSeries([
{ key: 'battery_temp', label: 'Battery', points: [], colorIndex: 1, unit: ' °C' },
{ key: 'pmu_temp', label: 'PMU', points: [], colorIndex: 3, unit: ' °C' },
], { height: 168, precision: 1 });
/* -- Assemble --------------------------------------------------------- */
root.appendChild(pageHead(
'Overview',
'Live condition of the robot. Everything here is read-only — use Control to act.',
[button('Refresh trends', () => refreshCharts(), { iconName: 'refresh', size: 'sm', style: 'ghost' })],
));
root.appendChild(el('div.grid.split', {},
card('Battery', { sub: 'PMU · 0.2 Hz' },
el('div.stack', { style: { gap: '10px' } }, heroValue, meterHost, heroSub, heroSpark),
),
tiles,
));
root.appendChild(el('div.grid.cols-2', {},
card('Attitude', { sub: 'Chest IMU' },
el('div.row', { style: { justifyContent: 'space-around', alignItems: 'center', gap: '16px' } },
attitudeHost, compassHost,
),
el('div', { style: { marginTop: '14px' } }, attitudeReadout),
),
card('Velocity', { sub: 'Measured, last 2 minutes' }, velChart),
));
root.appendChild(el('div.grid.cols-2', {},
card('Battery trend', { sub: 'Last 2 minutes' }, batteryChart),
card('Thermals', { sub: 'Battery cell and PMU' }, tempChart),
));
const safety = (store.spec?.safety_notes || []).filter((n) => n.level !== 'info');
if (safety.length) {
root.appendChild(card('Before you operate', {},
el('div.stack', { style: { gap: '8px' } },
...safety.map((n) => note(n.text, n.level, n.level === 'critical' ? '⚠' : 'ⓘ')),
),
));
}
/* -- Painting --------------------------------------------------------- */
let meterNode = meter(0);
meterHost.appendChild(meterNode);
function paint() {
const s = store.state;
if (!s) return;
const pct = s.battery_pct;
const tone = batteryTone(pct);
heroNumber.textContent = pct === null || pct === undefined ? '—' : num(pct, 1);
heroValue.style.color = tone === 'critical' ? 'var(--critical)'
: tone === 'warning' ? 'var(--warning)' : 'var(--text)';
const nextMeter = meter((pct || 0) / 100, tone);
meterNode.replaceWith(nextMeter);
meterNode = nextMeter;
const { battery_voltage: voltage, battery_current: current } = s;
heroSub.textContent = [
voltage != null ? `${num(voltage, 1)} V` : null,
current != null ? `${num(Math.abs(current), 1)} A ${current > 0 ? 'charging' : 'draw'}` : null,
s.battery_cycles ? `${s.battery_cycles} cycles` : null,
].filter(Boolean).join(' · ') || 'Battery remaining';
const modeSpec = store.spec?.modes?.find((m) => m.id === s.mode);
setTile('mode', modeSpec?.label || s.mode || '—', modeSpec?.desc || '',
s.mode === 'PASSIVE_DEFAULT' ? 'critical' : 'default');
const online = s.connection?.online;
const agent = s.custom?.agent;
setTile('transport',
s.connection?.simulated ? 'Simulated' : online ? 'Live' : 'Robot off',
s.connection?.simulated
? 'No robot attached'
: online
? `${agent?.hostname || 'agent'} · ROS domain ${s.connection?.ros_domain_id ?? 0}`
: 'Agent unreachable',
online ? (s.connection.simulated ? 'warning' : 'good') : 'critical');
setTile('uptime', duration(s.connection?.uptime_s), s.connection?.host || '');
const speed = Math.hypot(s.velocity?.forward || 0, s.velocity?.lateral || 0);
setTile('speed', num(speed, 2), 'linear, from odometry', 'default', ' m/s');
const yawDeg = ((s.odom?.yaw || 0) * 180 / Math.PI + 360) % 360;
setTile('heading', num(yawDeg, 0),
`x ${num(s.odom?.x, 2)} m · y ${num(s.odom?.y, 2)} m`, 'default', '°');
setTile('temp', num(s.battery_temp, 1), 'cell temperature',
tempTone(s.battery_temp, 45, 55), ' °C');
const chest = s.imu?.chest || {};
attitudeHost.replaceChildren(attitude(chest.roll || 0, chest.pitch || 0, { size: 148 }));
compassHost.replaceChildren(compass(s.odom?.yaw || 0, { size: 148 }));
attitudeReadout.replaceChildren(kv([
['Roll', `${num((chest.roll || 0) * 180 / Math.PI, 2)}°`],
['Pitch', `${num((chest.pitch || 0) * 180 / Math.PI, 2)}°`],
['Yaw', `${num((chest.yaw || 0) * 180 / Math.PI, 2)}°`],
['Vertical accel', `${num(chest.accel_z, 2)} m/s²`],
]));
}
async function refreshCharts() {
try {
const keys = 'battery_pct,battery_temp,pmu_temp,vel_forward,vel_lateral,vel_angular';
const data = await get(`/api/series?keys=${keys}&limit=400`);
batteryChart.update([
{ key: 'battery_pct', label: 'Battery', points: data.battery_pct || [], colorIndex: 0, unit: '%' },
]);
tempChart.update([
{ key: 'battery_temp', label: 'Battery', points: data.battery_temp || [], colorIndex: 1, unit: ' °C' },
{ key: 'pmu_temp', label: 'PMU', points: data.pmu_temp || [], colorIndex: 3, unit: ' °C' },
]);
velChart.update([
{ key: 'vel_forward', label: 'Forward', points: data.vel_forward || [], colorIndex: 0, unit: ' m/s' },
{ key: 'vel_lateral', label: 'Lateral', points: data.vel_lateral || [], colorIndex: 1, unit: ' m/s' },
{ key: 'vel_angular', label: 'Yaw rate', points: data.vel_angular || [], colorIndex: 2, unit: ' rad/s' },
]);
const history = data.battery_pct || [];
heroSpark.replaceChildren(
history.length > 2 ? sparkline(history, { height: 40, colorIndex: 0 }) : el('span'),
);
} catch {
// A failed trend refresh is not worth interrupting the operator over -
// the live tiles above are still updating from the WebSocket.
}
}
paint();
refreshCharts();
// Twice a second reads as live without repainting SVG on every frame.
let lastPaint = 0;
const unsubscribe = store.on('state', () => {
const now = Date.now();
if (now - lastPaint < 500) return;
lastPaint = now;
paint();
});
const timer = setInterval(refreshCharts, 3000);
return { node: root, dispose: () => { unsubscribe(); clearInterval(timer); } };
},
};