223 lines
8.8 KiB
JavaScript
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); } };
|
|
},
|
|
};
|