/* 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); } }; }, };