- {TABS.map((tab) => (
-
+ {/* ── Tab Navigation ── */}
+
{/* ── Content ── */}
- {activeTab === 'status' && (
-
- )}
+ {activeTab === 'status' && }
+ {activeTab === 'faces' && }
+ {activeTab === 'conversation' && }
+ {activeTab === 'personality' && }
+ {activeTab === 'navigation' && }
- {activeTab === 'faces' && (
-
- )}
-
- {activeTab === 'conversation' && (
-
- )}
-
- {activeTab === 'personality' && (
-
- )}
-
- {activeTab === 'navigation' && (
-
- )}
+ {activeTab === 'imu' && }
+ {activeTab === 'battery' && }
+ {activeTab === 'motors' && }
+ {activeTab === 'map' && }
+ {activeTab === 'control' && }
+ {activeTab === 'health' && }
{/* ── Footer ── */}
diff --git a/ui/social-bot/src/components/BatteryPanel.jsx b/ui/social-bot/src/components/BatteryPanel.jsx
new file mode 100644
index 0000000..e08685a
--- /dev/null
+++ b/ui/social-bot/src/components/BatteryPanel.jsx
@@ -0,0 +1,234 @@
+/**
+ * BatteryPanel.jsx — Battery state monitoring.
+ *
+ * Topics:
+ * /saltybot/balance_state (std_msgs/String JSON)
+ * Fields: motor_cmd, state, mode (no direct voltage yet)
+ * /diagnostics (diagnostic_msgs/DiagnosticArray)
+ * KeyValues: battery_voltage_v, battery_current_a, battery_soc_pct
+ *
+ * NOTE: Dedicated /saltybot/battery (sensor_msgs/BatteryState) can be added
+ * to cmd_vel_bridge_node once firmware sends voltage/current over USB.
+ * The panel will pick it up automatically from /diagnostics KeyValues.
+ *
+ * 4S LiPo range: 12.0 V (empty) → 16.8 V (full)
+ */
+
+import { useEffect, useRef, useState } from 'react';
+
+const LIPO_4S_MIN = 12.0;
+const LIPO_4S_MAX = 16.8;
+const HISTORY_MAX = 120; // 2 min at 1 Hz
+
+function socFromVoltage(v) {
+ if (v <= 0) return null;
+ return Math.max(0, Math.min(100, ((v - LIPO_4S_MIN) / (LIPO_4S_MAX - LIPO_4S_MIN)) * 100));
+}
+
+function voltColor(v) {
+ const soc = socFromVoltage(v);
+ if (soc === null) return '#6b7280';
+ if (soc > 50) return '#22c55e';
+ if (soc > 20) return '#f59e0b';
+ return '#ef4444';
+}
+
+function SparklineCanvas({ data, width = 280, height = 60 }) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const canvas = ref.current;
+ if (!canvas || data.length < 2) return;
+ const ctx = canvas.getContext('2d');
+ const W = canvas.width, H = canvas.height;
+ ctx.clearRect(0, 0, W, H);
+
+ // Background
+ ctx.fillStyle = '#020208';
+ ctx.fillRect(0, 0, W, H);
+
+ // Grid lines at 25% / 50% / 75%
+ [25, 50, 75].forEach(pct => {
+ const y = H - (pct / 100) * H;
+ ctx.strokeStyle = 'rgba(0,255,255,0.05)';
+ ctx.lineWidth = 0.5;
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
+ });
+
+ // Voltage range for y-axis
+ const minV = Math.min(LIPO_4S_MIN, ...data);
+ const maxV = Math.max(LIPO_4S_MAX, ...data);
+ const rangeV = maxV - minV || 1;
+
+ // Line
+ ctx.strokeStyle = '#06b6d4';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ data.forEach((v, i) => {
+ const x = (i / (data.length - 1)) * W;
+ const y = H - ((v - minV) / rangeV) * H * 0.9 - H * 0.05;
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
+ });
+ ctx.stroke();
+
+ // Fill under
+ ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath();
+ ctx.fillStyle = 'rgba(6,182,212,0.08)';
+ ctx.fill();
+
+ // Latest value label
+ const last = data[data.length - 1];
+ ctx.fillStyle = '#06b6d4';
+ ctx.font = 'bold 10px monospace';
+ ctx.textAlign = 'right';
+ ctx.fillText(`${last.toFixed(2)}V`, W - 3, 12);
+ }, [data]);
+
+ return (
+
+ );
+}
+
+export function BatteryPanel({ subscribe }) {
+ const [voltage, setVoltage] = useState(0);
+ const [current, setCurrent] = useState(null);
+ const [soc, setSoc] = useState(null);
+ const [history, setHistory] = useState([]);
+ const [lastTs, setLastTs] = useState(null);
+ const lastHistoryPush = useRef(0);
+
+ // /diagnostics — look for battery KeyValues
+ useEffect(() => {
+ const unsub = subscribe(
+ '/diagnostics',
+ 'diagnostic_msgs/DiagnosticArray',
+ (msg) => {
+ for (const status of msg.status ?? []) {
+ const kv = {};
+ for (const pair of status.values ?? []) {
+ kv[pair.key] = pair.value;
+ }
+ if (kv.battery_voltage_v !== undefined) {
+ const v = parseFloat(kv.battery_voltage_v);
+ setVoltage(v);
+ setSoc(socFromVoltage(v));
+ setLastTs(Date.now());
+ // Throttle history to ~1 Hz
+ const now = Date.now();
+ if (now - lastHistoryPush.current >= 1000) {
+ lastHistoryPush.current = now;
+ setHistory(h => [...h, v].slice(-HISTORY_MAX));
+ }
+ }
+ if (kv.battery_current_a !== undefined) {
+ setCurrent(parseFloat(kv.battery_current_a));
+ }
+ if (kv.battery_soc_pct !== undefined) {
+ setSoc(parseFloat(kv.battery_soc_pct));
+ }
+ }
+ }
+ );
+ return unsub;
+ }, [subscribe]);
+
+ // Also listen to balance_state for rough motor-current proxy
+ useEffect(() => {
+ const unsub = subscribe('/saltybot/balance_state', 'std_msgs/String', (msg) => {
+ try {
+ const d = JSON.parse(msg.data);
+ // motor_cmd ∈ [-1000..1000] → rough current proxy
+ if (d.motor_cmd !== undefined && current === null) {
+ setCurrent(Math.abs(d.motor_cmd) / 1000 * 20); // rough max 20A
+ }
+ } catch { /* ignore */ }
+ });
+ return unsub;
+ }, [subscribe, current]);
+
+ const socPct = soc ?? socFromVoltage(voltage) ?? 0;
+ const col = voltColor(voltage);
+ const stale = lastTs && Date.now() - lastTs > 10000;
+ const runtime = (voltage > 0 && current && current > 0.1)
+ ? ((socPct / 100) * 16000 / current / 60).toFixed(0) // rough Wh/V/A estimate
+ : null;
+
+ return (
+
+ {/* Main gauges */}
+
+
+
VOLTAGE
+
+ {voltage > 0 ? voltage.toFixed(2) : '—'}
+
+
V {stale ? '(stale)' : ''}
+
+
+
+
SOC
+
+ {socPct > 0 ? Math.round(socPct) : '—'}
+
+
%
+
+
+
+
CURRENT
+
+ {current != null ? current.toFixed(1) : '—'}
+
+
A {current === null ? '' : '(est.)'}
+
+
+
+
EST. RUN
+
+ {runtime ?? '—'}
+
+
min
+
+
+
+ {/* SoC bar */}
+
+
+ STATE OF CHARGE
+ {Math.round(socPct)}%
+
+
+
+ {LIPO_4S_MIN}V empty
+ {LIPO_4S_MAX}V full
+
+
+
+ {/* Voltage history sparkline */}
+
+
+ VOLTAGE HISTORY (2 min)
+
+ {history.length >= 2 ? (
+
+ ) : (
+
+ Waiting for /diagnostics data…
+
+ Requires battery_voltage_v KeyValue in DiagnosticArray
+
+
+ )}
+
+
+ );
+}
diff --git a/ui/social-bot/src/components/ControlMode.jsx b/ui/social-bot/src/components/ControlMode.jsx
new file mode 100644
index 0000000..80a7b04
--- /dev/null
+++ b/ui/social-bot/src/components/ControlMode.jsx
@@ -0,0 +1,198 @@
+/**
+ * ControlMode.jsx — RC / Autonomous control mode display.
+ *
+ * Topics:
+ * /saltybot/control_mode (std_msgs/String JSON)
+ * {
+ * mode: "RC" | "RAMP_TO_AUTO" | "AUTO" | "RAMP_TO_RC",
+ * blend_alpha: 0.0..1.0,
+ * slam_ok: bool,
+ * rc_link_ok: bool,
+ * override_active: bool
+ * }
+ * /saltybot/balance_state (std_msgs/String JSON) — robot state, mode label
+ */
+
+import { useEffect, useState } from 'react';
+
+const MODE_CONFIG = {
+ RC: {
+ label: 'RC MANUAL',
+ color: 'text-blue-300',
+ bg: 'bg-blue-950',
+ border: 'border-blue-600',
+ description: 'Pilot has full control via CRSF/ELRS RC link',
+ },
+ RAMP_TO_AUTO: {
+ label: 'RAMP → AUTO',
+ color: 'text-amber-300',
+ bg: 'bg-amber-950',
+ border: 'border-amber-600',
+ description: 'Transitioning from RC to autonomous (500 ms blend)',
+ },
+ AUTO: {
+ label: 'AUTONOMOUS',
+ color: 'text-green-300',
+ bg: 'bg-green-950',
+ border: 'border-green-600',
+ description: 'Jetson AI/Nav2 in full control',
+ },
+ RAMP_TO_RC: {
+ label: 'RAMP → RC',
+ color: 'text-orange-300',
+ bg: 'bg-orange-950',
+ border: 'border-orange-600',
+ description: 'Returning control to pilot (500 ms blend)',
+ },
+};
+
+function SafetyFlag({ label, ok, invert }) {
+ const isOk = invert ? !ok : ok;
+ return (
+
+
+
+ {label}
+
+
+ {isOk ? 'OK' : 'FAULT'}
+
+
+ );
+}
+
+export function ControlMode({ subscribe }) {
+ const [ctrlMode, setCtrlMode] = useState(null);
+ const [balState, setBalState] = useState(null);
+ const [lastTs, setLastTs] = useState(null);
+
+ useEffect(() => {
+ const unsub = subscribe('/saltybot/control_mode', 'std_msgs/String', (msg) => {
+ try {
+ setCtrlMode(JSON.parse(msg.data));
+ setLastTs(Date.now());
+ } catch { /* ignore */ }
+ });
+ return unsub;
+ }, [subscribe]);
+
+ useEffect(() => {
+ const unsub = subscribe('/saltybot/balance_state', 'std_msgs/String', (msg) => {
+ try { setBalState(JSON.parse(msg.data)); }
+ catch { /* ignore */ }
+ });
+ return unsub;
+ }, [subscribe]);
+
+ const mode = ctrlMode?.mode ?? 'RC';
+ const cfg = MODE_CONFIG[mode] ?? MODE_CONFIG.RC;
+ const alpha = ctrlMode?.blend_alpha ?? 0;
+ const stale = lastTs && Date.now() - lastTs > 5000;
+
+ return (
+
+ {/* Main mode badge */}
+
+
+
+
CONTROL MODE
+
{cfg.label}
+
{cfg.description}
+
+
+
Blend α
+
0.5 ? 'text-green-400' : 'text-blue-400'}`}>
+ {alpha.toFixed(2)}
+
+ {stale &&
STALE
}
+
+
+
+ {/* Blend alpha bar */}
+ {(mode === 'RAMP_TO_AUTO' || mode === 'RAMP_TO_RC' || alpha > 0) && (
+
+ )}
+
+
+ {/* Safety flags */}
+
+
SAFETY INTERLOCKS
+
+
+
+
+
+ {!ctrlMode && (
+
+ Waiting for /saltybot/control_mode…
+
+ )}
+
+
+ {/* Balance state detail */}
+ {balState && (
+
+
BALANCE STATE
+
+
+ STM32 State:
+ {balState.state}
+
+
+ STM32 Mode:
+ {balState.mode}
+
+
+ Pitch:
+ {balState.pitch_deg}°
+
+
+ Motor CMD:
+ {balState.motor_cmd}
+
+
+
+ )}
+
+ {/* Mode transition guide */}
+
+
STATE MACHINE
+
+ {['RC', 'RAMP_TO_AUTO', 'AUTO', 'RAMP_TO_RC'].map((m, i) => (
+
+ {m}
+ {i < 3 && →}
+
+ ))}
+
+
+
AUX2 switch → RC⇄AUTO | Stick > 10% while AUTO → instant RC
+
SLAM fix lost while AUTO → RAMP_TO_RC | RC link lost → instant RC
+
+
+
+ );
+}
diff --git a/ui/social-bot/src/components/ImuPanel.jsx b/ui/social-bot/src/components/ImuPanel.jsx
new file mode 100644
index 0000000..c78fc45
--- /dev/null
+++ b/ui/social-bot/src/components/ImuPanel.jsx
@@ -0,0 +1,379 @@
+/**
+ * ImuPanel.jsx — IMU attitude visualization.
+ *
+ * Topics:
+ * /saltybot/imu (sensor_msgs/Imu) — quaternion orientation
+ * /saltybot/balance_state (std_msgs/String JSON) — pitch/roll/yaw deg,
+ * motor_cmd, state, mode
+ *
+ * Displays:
+ * - Artificial horizon canvas (pitch / roll)
+ * - Compass tape (yaw)
+ * - Three.js 3D robot orientation cube
+ * - Numeric readouts + angular velocity
+ */
+
+import { useEffect, useRef, useState, useCallback } from 'react';
+import * as THREE from 'three';
+
+// ── 2D canvas helpers ──────────────────────────────────────────────────────────
+
+function drawHorizon(ctx, W, H, pitch, roll) {
+ const cx = W / 2, cy = H / 2;
+ const rollRad = roll * Math.PI / 180;
+ const pitchPx = pitch * (H / 60);
+
+ ctx.clearRect(0, 0, W, H);
+ ctx.fillStyle = '#051a30';
+ ctx.fillRect(0, 0, W, H);
+
+ ctx.save();
+ ctx.translate(cx, cy);
+ ctx.rotate(-rollRad);
+
+ // Ground
+ ctx.fillStyle = '#1a0f00';
+ ctx.fillRect(-W, pitchPx, W * 2, H * 2);
+
+ // Horizon
+ ctx.strokeStyle = '#00ffff';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.moveTo(-W, pitchPx);
+ ctx.lineTo(W, pitchPx);
+ ctx.stroke();
+
+ // Pitch ladder
+ for (let d = -30; d <= 30; d += 10) {
+ if (d === 0) continue;
+ const y = pitchPx + d * (H / 60);
+ const lw = Math.abs(d) % 20 === 0 ? 22 : 14;
+ ctx.strokeStyle = 'rgba(0,210,210,0.4)';
+ ctx.lineWidth = 0.7;
+ ctx.beginPath();
+ ctx.moveTo(-lw, y); ctx.lineTo(lw, y);
+ ctx.stroke();
+ ctx.fillStyle = 'rgba(0,210,210,0.5)';
+ ctx.font = '7px monospace';
+ ctx.textAlign = 'left';
+ ctx.fillText((-d).toString(), lw + 2, y + 3);
+ }
+ ctx.restore();
+
+ // Reticle
+ ctx.strokeStyle = '#f97316';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.moveTo(cx - 28, cy); ctx.lineTo(cx - 8, cy);
+ ctx.moveTo(cx + 8, cy); ctx.lineTo(cx + 28, cy);
+ ctx.moveTo(cx, cy - 4); ctx.lineTo(cx, cy + 4);
+ ctx.stroke();
+}
+
+function drawCompass(ctx, W, H, yaw) {
+ const cx = W / 2;
+ ctx.clearRect(0, 0, W, H);
+ ctx.fillStyle = '#050510';
+ ctx.fillRect(0, 0, W, H);
+
+ const degPerPx = W / 70;
+ const cardinals = { 0:'N', 45:'NE', 90:'E', 135:'SE', 180:'S', 225:'SW', 270:'W', 315:'NW' };
+
+ for (let i = -35; i <= 35; i++) {
+ const deg = ((Math.round(yaw) + i) % 360 + 360) % 360;
+ const x = cx + i * degPerPx;
+ const isMaj = deg % 45 === 0;
+ const isMed = deg % 15 === 0;
+ if (!isMed && !isMaj) continue;
+
+ ctx.strokeStyle = isMaj ? '#00cccc' : 'rgba(0,200,200,0.3)';
+ ctx.lineWidth = isMaj ? 1.5 : 0.5;
+ const tH = isMaj ? 16 : 7;
+ ctx.beginPath();
+ ctx.moveTo(x, 0); ctx.lineTo(x, tH);
+ ctx.stroke();
+
+ if (isMaj && cardinals[deg] !== undefined) {
+ ctx.fillStyle = deg === 0 ? '#ff4444' : '#00cccc';
+ ctx.font = 'bold 9px monospace';
+ ctx.textAlign = 'center';
+ ctx.fillText(cardinals[deg], x, 28);
+ }
+ }
+
+ const hdg = ((Math.round(yaw) % 360) + 360) % 360;
+ ctx.fillStyle = '#00ffff';
+ ctx.font = 'bold 11px monospace';
+ ctx.textAlign = 'center';
+ ctx.fillText(hdg + '°', cx, H - 4);
+
+ // Pointer
+ ctx.strokeStyle = '#f97316';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(cx, 0); ctx.lineTo(cx, 10);
+ ctx.stroke();
+}
+
+// ── Three.js hook ──────────────────────────────────────────────────────────────
+
+function useThreeOrientation(containerRef) {
+ const sceneRef = useRef(null);
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+
+ const scene = new THREE.Scene();
+ const camera = new THREE.PerspectiveCamera(45, el.clientWidth / el.clientHeight, 0.1, 100);
+ camera.position.set(2.5, 2, 3);
+ camera.lookAt(0, 0, 0);
+
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
+ renderer.setClearColor(0x070712, 1);
+ renderer.setPixelRatio(window.devicePixelRatio);
+ renderer.setSize(el.clientWidth, el.clientHeight);
+ el.appendChild(renderer.domElement);
+
+ // Lighting
+ scene.add(new THREE.AmbientLight(0x404060, 3));
+ const dir = new THREE.DirectionalLight(0xffffff, 4);
+ dir.position.set(5, 8, 6);
+ scene.add(dir);
+
+ // Robot body group
+ const group = new THREE.Group();
+
+ // Body
+ const body = new THREE.Mesh(
+ new THREE.BoxGeometry(0.8, 1.4, 0.4),
+ new THREE.MeshPhongMaterial({ color: 0x1a2a4a, specular: 0x334466 })
+ );
+ body.position.y = 0.5;
+ group.add(body);
+
+ // Wheels
+ const wheelGeo = new THREE.CylinderGeometry(0.35, 0.35, 0.12, 18);
+ const wheelMat = new THREE.MeshPhongMaterial({ color: 0x1a1a1a, specular: 0x333333 });
+ [-0.5, 0.5].forEach(x => {
+ const pivot = new THREE.Group();
+ pivot.position.set(x, -0.2, 0);
+ pivot.rotation.z = Math.PI / 2;
+ pivot.add(new THREE.Mesh(wheelGeo, wheelMat));
+ group.add(pivot);
+
+ const rim = new THREE.Mesh(
+ new THREE.TorusGeometry(0.3, 0.02, 8, 16),
+ new THREE.MeshPhongMaterial({ color: 0x0055cc })
+ );
+ rim.rotation.z = Math.PI / 2;
+ rim.position.set(x * 1.07, -0.2, 0);
+ group.add(rim);
+ });
+
+ // Forward indicator arrow (red tip at +Z front)
+ const arrow = new THREE.Mesh(
+ new THREE.ConeGeometry(0.08, 0.25, 8),
+ new THREE.MeshBasicMaterial({ color: 0xff3030 })
+ );
+ arrow.rotation.x = -Math.PI / 2;
+ arrow.position.set(0, 0.5, 0.35);
+ group.add(arrow);
+
+ // Sensor head
+ const head = new THREE.Mesh(
+ new THREE.BoxGeometry(0.32, 0.18, 0.32),
+ new THREE.MeshPhongMaterial({ color: 0x111122 })
+ );
+ head.position.set(0, 1.35, 0);
+ group.add(head);
+
+ // Axis helper
+ scene.add(new THREE.AxesHelper(1.4));
+ scene.add(group);
+
+ const q = new THREE.Quaternion();
+ let curQ = new THREE.Quaternion();
+
+ sceneRef.current = { group, q, curQ };
+
+ let animId;
+ const animate = () => {
+ animId = requestAnimationFrame(animate);
+ curQ.slerp(q, 0.12);
+ group.quaternion.copy(curQ);
+ renderer.render(scene, camera);
+ };
+ animate();
+
+ const ro = new ResizeObserver(() => {
+ camera.aspect = el.clientWidth / el.clientHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(el.clientWidth, el.clientHeight);
+ });
+ ro.observe(el);
+
+ return () => {
+ cancelAnimationFrame(animId);
+ ro.disconnect();
+ renderer.dispose();
+ el.removeChild(renderer.domElement);
+ sceneRef.current = null;
+ };
+ }, [containerRef]);
+
+ const updateOrientation = useCallback((qx, qy, qz, qw) => {
+ if (!sceneRef.current) return;
+ sceneRef.current.q.set(qx, qy, qz, qw);
+ }, []);
+
+ return updateOrientation;
+}
+
+// ── Component ──────────────────────────────────────────────────────────────────
+
+function Readout({ label, value, unit, warn }) {
+ return (
+
+
{label}
+
{value}
+
{unit}
+
+ );
+}
+
+export function ImuPanel({ subscribe }) {
+ const horizonRef = useRef(null);
+ const compassRef = useRef(null);
+ const threeRef = useRef(null);
+
+ const [attitude, setAttitude] = useState({ pitch: 0, roll: 0, yaw: 0 });
+ const [angVel, setAngVel] = useState({ x: 0, y: 0, z: 0 });
+ const [balState, setBalState] = useState(null);
+ const [pktHz, setPktHz] = useState(0);
+
+ const threeUpdate = useThreeOrientation(threeRef);
+ const pktRef = useRef({ count: 0, last: Date.now() });
+
+ // Draw canvases on attitude change
+ useEffect(() => {
+ const hc = horizonRef.current;
+ const cc = compassRef.current;
+ if (hc) {
+ const ctx = hc.getContext('2d');
+ drawHorizon(ctx, hc.width, hc.height, attitude.pitch, attitude.roll);
+ }
+ if (cc) {
+ const ctx = cc.getContext('2d');
+ drawCompass(ctx, cc.width, cc.height, attitude.yaw);
+ }
+ }, [attitude]);
+
+ // Subscribe to Imu
+ useEffect(() => {
+ const unsub = subscribe('/saltybot/imu', 'sensor_msgs/Imu', (msg) => {
+ const o = msg.orientation;
+ if (o) {
+ // Convert quaternion to Euler (simple ZYX)
+ const { x, y, z, w } = o;
+ const pitchRad = Math.asin(2 * (w * y - z * x));
+ const rollRad = Math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y));
+ const yawRad = Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z));
+ setAttitude({
+ pitch: pitchRad * 180 / Math.PI,
+ roll: rollRad * 180 / Math.PI,
+ yaw: yawRad * 180 / Math.PI,
+ });
+ threeUpdate(x, y, z, w);
+ }
+ const av = msg.angular_velocity;
+ if (av) {
+ setAngVel({
+ x: av.x * 180 / Math.PI,
+ y: av.y * 180 / Math.PI,
+ z: av.z * 180 / Math.PI,
+ });
+ }
+ // Hz counter
+ pktRef.current.count++;
+ const now = Date.now();
+ if (now - pktRef.current.last >= 1000) {
+ setPktHz(pktRef.current.count);
+ pktRef.current.count = 0;
+ pktRef.current.last = now;
+ }
+ });
+ return unsub;
+ }, [subscribe, threeUpdate]);
+
+ // Subscribe to balance_state for motor/state info
+ useEffect(() => {
+ const unsub = subscribe('/saltybot/balance_state', 'std_msgs/String', (msg) => {
+ try {
+ setBalState(JSON.parse(msg.data));
+ } catch { /* ignore */ }
+ });
+ return unsub;
+ }, [subscribe]);
+
+ const pitchWarn = Math.abs(attitude.pitch) > 20;
+ const rollWarn = Math.abs(attitude.roll) > 20;
+
+ return (
+
+ {/* Numeric readouts */}
+
+
+
+
+
+
+ {/* Angular velocity */}
+
+
+
+
+
+
+ {/* 2D gauges */}
+
+
+
+
COMPASS
+
+ {/* Balance state info */}
+ {balState && (
+
+
State: {balState.state}
+
Mode: {balState.mode}
+
Motor: {balState.motor_cmd}
+
Error: {balState.pid_error_deg}°
+
+ )}
+
+
+
+ {/* Three.js 3D orientation */}
+
+
+
3D ORIENTATION
+
{pktHz} Hz
+
+
+
+
+ );
+}
diff --git a/ui/social-bot/src/components/MapViewer.jsx b/ui/social-bot/src/components/MapViewer.jsx
new file mode 100644
index 0000000..3c1fdaa
--- /dev/null
+++ b/ui/social-bot/src/components/MapViewer.jsx
@@ -0,0 +1,298 @@
+/**
+ * MapViewer.jsx — 2D occupancy grid + robot pose + Nav2 path overlay.
+ *
+ * Topics:
+ * /map (nav_msgs/OccupancyGrid) — SLAM/static map
+ * /odom (nav_msgs/Odometry) — robot position & heading
+ * /outdoor/route (nav_msgs/Path) — Nav2 / OSM route path
+ *
+ * NOTE: OccupancyGrid data can be large (384×384 = 150K cells).
+ * We decode on a worker-free canvas; map refreshes at topic rate
+ * (typically 0.1–1 Hz from SLAM), odom at ~10 Hz.
+ */
+
+import { useEffect, useRef, useState, useCallback } from 'react';
+
+const CELL_COLORS = {
+ unknown: '#1a1a2e',
+ free: '#0a1020',
+ occ: '#00ffff33',
+ occFull: '#00b8d9',
+};
+
+function quatToYaw(o) {
+ return Math.atan2(2 * (o.w * o.z + o.x * o.y), 1 - 2 * (o.y * o.y + o.z * o.z));
+}
+
+export function MapViewer({ subscribe }) {
+ const canvasRef = useRef(null);
+ const mapRef = useRef(null); // last OccupancyGrid info
+ const odomRef = useRef(null); // last robot pose {x,y,yaw}
+ const pathRef = useRef([]); // [{x,y}] path points in map coords
+ const [mapInfo, setMapInfo] = useState(null);
+ const [odomPose, setOdomPose] = useState(null);
+ const [zoom, setZoom] = useState(1);
+ const [pan, setPan] = useState({ x: 0, y: 0 });
+ const dragging = useRef(null);
+
+ // ── Render ────────────────────────────────────────────────────────────────
+ const render = useCallback(() => {
+ const canvas = canvasRef.current;
+ const map = mapRef.current;
+ if (!canvas || !map) return;
+
+ const ctx = canvas.getContext('2d');
+ const W = canvas.width, H = canvas.height;
+
+ ctx.clearRect(0, 0, W, H);
+ ctx.fillStyle = CELL_COLORS.unknown;
+ ctx.fillRect(0, 0, W, H);
+
+ const { data, info } = map;
+ const mW = info.width, mH = info.height;
+ const res = info.resolution; // m/cell
+ const cellPx = zoom; // 1 map cell = zoom pixels
+
+ // Canvas centre
+ const cx = W / 2 + pan.x;
+ const cy = H / 2 + pan.y;
+
+ // Map origin (bottom-left in world) → we flip y for canvas
+ const ox = info.origin.position.x;
+ const oy = info.origin.position.y;
+
+ // Draw map cells in chunks
+ const img = ctx.createImageData(W, H);
+ const imgData = img.data;
+
+ for (let r = 0; r < mH; r++) {
+ for (let c = 0; c < mW; c++) {
+ const val = data[r * mW + c];
+ // World coords of this cell centre
+ const wx = ox + (c + 0.5) * res;
+ const wy = oy + (r + 0.5) * res;
+ // Canvas coords (flip y)
+ const px = Math.round(cx + wx * cellPx / res);
+ const py = Math.round(cy - wy * cellPx / res);
+
+ if (px < 0 || px >= W || py < 0 || py >= H) continue;
+
+ let ro, go, bo, ao;
+ if (val < 0) { ro=26; go=26; bo=46; ao=255; } // unknown
+ else if (val === 0) { ro=10; go=16; bo=32; ao=255; } // free
+ else if (val < 60) { ro=0; go=100; bo=120; ao=120; } // low occ
+ else { ro=0; go=184; bo=217; ao=220; } // occupied
+
+ const i = (py * W + px) * 4;
+ imgData[i] = ro; imgData[i+1] = go;
+ imgData[i+2] = bo; imgData[i+3] = ao;
+ }
+ }
+ ctx.putImageData(img, 0, 0);
+
+ // ── Nav2 path ─────────────────────────────────────────────────────────
+ const path = pathRef.current;
+ if (path.length >= 2) {
+ ctx.strokeStyle = '#f59e0b';
+ ctx.lineWidth = 2;
+ ctx.setLineDash([4, 4]);
+ ctx.beginPath();
+ path.forEach(({ x, y }, i) => {
+ const px = cx + x * cellPx / res;
+ const py = cy - y * cellPx / res;
+ i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
+ });
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ // ── Robot ────────────────────────────────────────────────────────────
+ const odom = odomRef.current;
+ if (odom) {
+ const rx = cx + odom.x * cellPx / res;
+ const ry = cy - odom.y * cellPx / res;
+
+ // Heading arrow
+ const arrowLen = Math.max(12, cellPx * 1.5);
+ ctx.strokeStyle = '#f97316';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(rx, ry);
+ ctx.lineTo(rx + Math.cos(odom.yaw) * arrowLen, ry - Math.sin(odom.yaw) * arrowLen);
+ ctx.stroke();
+
+ // Robot circle
+ ctx.fillStyle = '#f97316';
+ ctx.shadowBlur = 8;
+ ctx.shadowColor = '#f97316';
+ ctx.beginPath();
+ ctx.arc(rx, ry, 6, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.shadowBlur = 0;
+
+ // Heading dot
+ ctx.fillStyle = '#ffffff';
+ ctx.beginPath();
+ ctx.arc(rx + Math.cos(odom.yaw) * arrowLen, ry - Math.sin(odom.yaw) * arrowLen, 3, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ // ── Scale bar ─────────────────────────────────────────────────────────
+ if (mapInfo) {
+ const scaleM = 2; // 2-metre scale bar
+ const scalePx = scaleM * cellPx / res;
+ const bx = 12, by = H - 12;
+ ctx.strokeStyle = '#06b6d4';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(bx, by); ctx.lineTo(bx + scalePx, by);
+ ctx.moveTo(bx, by - 4); ctx.lineTo(bx, by + 4);
+ ctx.moveTo(bx + scalePx, by - 4); ctx.lineTo(bx + scalePx, by + 4);
+ ctx.stroke();
+ ctx.fillStyle = '#06b6d4';
+ ctx.font = '9px monospace';
+ ctx.textAlign = 'left';
+ ctx.fillText(`${scaleM}m`, bx, by - 6);
+ }
+ }, [zoom, pan, mapInfo]);
+
+ // ── Subscribe /map ────────────────────────────────────────────────────────
+ useEffect(() => {
+ const unsub = subscribe('/map', 'nav_msgs/OccupancyGrid', (msg) => {
+ mapRef.current = msg;
+ setMapInfo(msg.info);
+ render();
+ });
+ return unsub;
+ }, [subscribe, render]);
+
+ // ── Subscribe /odom ───────────────────────────────────────────────────────
+ useEffect(() => {
+ const unsub = subscribe('/odom', 'nav_msgs/Odometry', (msg) => {
+ const p = msg.pose.pose.position;
+ const o = msg.pose.pose.orientation;
+ const pose = { x: p.x, y: p.y, yaw: quatToYaw(o) };
+ odomRef.current = pose;
+ setOdomPose(pose);
+ render();
+ });
+ return unsub;
+ }, [subscribe, render]);
+
+ // ── Subscribe /outdoor/route (Nav2 / OSM path) ────────────────────────────
+ useEffect(() => {
+ const unsub = subscribe('/outdoor/route', 'nav_msgs/Path', (msg) => {
+ pathRef.current = (msg.poses ?? []).map(p => ({
+ x: p.pose.position.x,
+ y: p.pose.position.y,
+ }));
+ render();
+ });
+ return unsub;
+ }, [subscribe, render]);
+
+ // Re-render when zoom/pan changes
+ useEffect(() => { render(); }, [zoom, pan, render]);
+
+ // ── Mouse pan ─────────────────────────────────────────────────────────────
+ const onMouseDown = (e) => { dragging.current = { x: e.clientX - pan.x, y: e.clientY - pan.y }; };
+ const onMouseMove = (e) => {
+ if (!dragging.current) return;
+ setPan({ x: e.clientX - dragging.current.x, y: e.clientY - dragging.current.y });
+ };
+ const onMouseUp = () => { dragging.current = null; };
+
+ // Touch pan
+ const touchRef = useRef(null);
+ const onTouchStart = (e) => {
+ const t = e.touches[0];
+ touchRef.current = { x: t.clientX - pan.x, y: t.clientY - pan.y };
+ };
+ const onTouchMove = (e) => {
+ if (!touchRef.current) return;
+ const t = e.touches[0];
+ setPan({ x: t.clientX - touchRef.current.x, y: t.clientY - touchRef.current.y });
+ };
+
+ return (
+
+ {/* Toolbar */}
+
+
MAP VIEWER
+
+
+ {zoom.toFixed(1)}x
+
+
+
+
+
+ {/* Canvas */}
+
+
+
+ {/* Info bar */}
+
+
+
MAP SIZE
+
+ {mapInfo ? `${mapInfo.width}×${mapInfo.height}` : '—'}
+
+
+
+
RESOLUTION
+
+ {mapInfo ? `${mapInfo.resolution.toFixed(2)}m/cell` : '—'}
+
+
+
+
ROBOT POS
+
+ {odomPose ? `${odomPose.x.toFixed(2)}, ${odomPose.y.toFixed(2)}` : '—'}
+
+
+
+
HEADING
+
+ {odomPose ? `${(odomPose.yaw * 180 / Math.PI).toFixed(1)}°` : '—'}
+
+
+
+
+ {/* Legend */}
+
+
+ );
+}
diff --git a/ui/social-bot/src/components/MotorPanel.jsx b/ui/social-bot/src/components/MotorPanel.jsx
new file mode 100644
index 0000000..14ee7b2
--- /dev/null
+++ b/ui/social-bot/src/components/MotorPanel.jsx
@@ -0,0 +1,210 @@
+/**
+ * MotorPanel.jsx — Motor telemetry display.
+ *
+ * Balance bot: /saltybot/balance_state (std_msgs/String JSON)
+ * motor_cmd [-1000..1000], bridge_speed, bridge_steer
+ *
+ * Rover mode: /saltybot/rover_pwm (std_msgs/String JSON)
+ * ch1_us, ch2_us, ch3_us, ch4_us [1000..2000]
+ *
+ * Temperatures: /diagnostics (diagnostic_msgs/DiagnosticArray)
+ * motor_temp_l_c, motor_temp_r_c
+ *
+ * Displays PWM-bar for each motor, RPM proxy, temperature.
+ */
+
+import { useEffect, useState } from 'react';
+
+const PWM_MIN = 1000;
+const PWM_MID = 1500;
+const PWM_MAX = 2000;
+
+/** Map [-1..1] normalised value to bar width and color. */
+function dutyBar(norm) {
+ const pct = Math.abs(norm) * 50;
+ const color = norm > 0 ? '#f97316' : '#3b82f6';
+ const left = norm >= 0 ? '50%' : `${50 - pct}%`;
+ return { pct, color, left };
+}
+
+function MotorGauge({ label, norm, pwmUs, tempC }) {
+ const { pct, color, left } = dutyBar(norm);
+ const tempWarn = tempC != null && tempC > 70;
+
+ return (
+
+
+ {label}
+ {tempC != null && (
+
+ {tempC.toFixed(0)}°C
+
+ )}
+
+
+ {/* PWM value */}
+
+ {pwmUs != null ? `${pwmUs} µs` : `${Math.round(norm * 1000)}`}
+
+
+ {/* Bidirectional duty bar */}
+
+
+ REV
+ {(norm * 100).toFixed(0)}%
+ FWD
+
+
+ );
+}
+
+export function MotorPanel({ subscribe }) {
+ const [balState, setBalState] = useState(null);
+ const [roverPwm, setRoverPwm] = useState(null);
+ const [temps, setTemps] = useState({ l: null, r: null });
+
+ // Balance state (2-wheel robot)
+ useEffect(() => {
+ const unsub = subscribe('/saltybot/balance_state', 'std_msgs/String', (msg) => {
+ try { setBalState(JSON.parse(msg.data)); }
+ catch { /* ignore */ }
+ });
+ return unsub;
+ }, [subscribe]);
+
+ // Rover PWM (4-wheel rover)
+ useEffect(() => {
+ const unsub = subscribe('/saltybot/rover_pwm', 'std_msgs/String', (msg) => {
+ try { setRoverPwm(JSON.parse(msg.data)); }
+ catch { /* ignore */ }
+ });
+ return unsub;
+ }, [subscribe]);
+
+ // Motor temperatures from diagnostics
+ useEffect(() => {
+ const unsub = subscribe('/diagnostics', 'diagnostic_msgs/DiagnosticArray', (msg) => {
+ for (const status of msg.status ?? []) {
+ const kv = {};
+ for (const pair of status.values ?? []) kv[pair.key] = pair.value;
+ if (kv.motor_temp_l_c !== undefined || kv.motor_temp_r_c !== undefined) {
+ setTemps({
+ l: kv.motor_temp_l_c != null ? parseFloat(kv.motor_temp_l_c) : null,
+ r: kv.motor_temp_r_c != null ? parseFloat(kv.motor_temp_r_c) : null,
+ });
+ break;
+ }
+ }
+ });
+ return unsub;
+ }, [subscribe]);
+
+ // Determine display mode: rover if rover_pwm active, else balance
+ const isRover = roverPwm != null;
+
+ let leftNorm = 0, rightNorm = 0;
+ let leftPwm = null, rightPwm = null;
+
+ if (isRover) {
+ // ch1=left-front, ch2=right-front, ch3=left-rear, ch4=right-rear
+ leftPwm = Math.round((roverPwm.ch1_us + (roverPwm.ch3_us ?? roverPwm.ch1_us)) / 2);
+ rightPwm = Math.round((roverPwm.ch2_us + (roverPwm.ch4_us ?? roverPwm.ch2_us)) / 2);
+ leftNorm = (leftPwm - PWM_MID) / (PWM_MAX - PWM_MID);
+ rightNorm = (rightPwm - PWM_MID) / (PWM_MAX - PWM_MID);
+ } else if (balState) {
+ const cmd = (balState.motor_cmd ?? 0) / 1000;
+ const steer = (balState.bridge_steer ?? 0) / 1000;
+ leftNorm = cmd + steer;
+ rightNorm = cmd - steer;
+ }
+
+ // Clip to ±1
+ leftNorm = Math.max(-1, Math.min(1, leftNorm));
+ rightNorm = Math.max(-1, Math.min(1, rightNorm));
+
+ return (
+
+ {/* Mode indicator */}
+
+
+ {isRover ? 'ROVER (4WD)' : 'BALANCE (2WD)'}
+
+ {!balState && !roverPwm && (
+
No motor data
+ )}
+
+
+ {/* Motor gauges */}
+
+
+
+
+
+ {/* Balance-specific details */}
+ {balState && !isRover && (
+
+
PID DETAILS
+
+
+
CMD
+
{balState.motor_cmd}
+
+
+
ERROR
+
{balState.pid_error_deg}°
+
+
+
INTEGRAL
+
{balState.integral}
+
+
+
FRAMES
+
{balState.frames}
+
+
+
+ )}
+
+ {/* Rover PWM details */}
+ {roverPwm && (
+
+
CHANNEL PWM (µs)
+
+ {[1,2,3,4].map(ch => {
+ const us = roverPwm[`ch${ch}_us`] ?? PWM_MID;
+ const norm = (us - PWM_MID) / (PWM_MAX - PWM_MID);
+ return (
+
+
CH{ch}
+
0.05 ? '#f97316' : norm < -0.05 ? '#3b82f6' : '#6b7280' }}>
+ {us}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/ui/social-bot/src/components/SystemHealth.jsx b/ui/social-bot/src/components/SystemHealth.jsx
new file mode 100644
index 0000000..7498a90
--- /dev/null
+++ b/ui/social-bot/src/components/SystemHealth.jsx
@@ -0,0 +1,196 @@
+/**
+ * SystemHealth.jsx — System resource monitoring and ROS2 node status.
+ *
+ * Topics:
+ * /diagnostics (diagnostic_msgs/DiagnosticArray)
+ * Each DiagnosticStatus has: name, level (0=OK,1=WARN,2=ERROR,3=STALE)
+ * message, hardware_id, values: [{key,value}]
+ * Expected KeyValue sources (from tegrastats bridge / custom nodes):
+ * cpu_temp_c, gpu_temp_c, ram_used_mb, ram_total_mb,
+ * disk_used_gb, disk_total_gb, gpu_used_mb, gpu_total_mb
+ */
+
+import { useEffect, useState } from 'react';
+
+const LEVEL_CONFIG = {
+ 0: { label: 'OK', color: 'text-green-400', bg: 'bg-green-950', border: 'border-green-800', dot: 'bg-green-400' },
+ 1: { label: 'WARN', color: 'text-amber-400', bg: 'bg-amber-950', border: 'border-amber-800', dot: 'bg-amber-400 animate-pulse' },
+ 2: { label: 'ERROR', color: 'text-red-400', bg: 'bg-red-950', border: 'border-red-800', dot: 'bg-red-400 animate-pulse' },
+ 3: { label: 'STALE', color: 'text-gray-500', bg: 'bg-gray-900', border: 'border-gray-700', dot: 'bg-gray-500' },
+};
+
+function ResourceBar({ label, used, total, unit, warnPct = 80 }) {
+ if (total == null || total === 0) return null;
+ const pct = Math.round((used / total) * 100);
+ const warn = pct >= warnPct;
+ const crit = pct >= 95;
+ const color = crit ? '#ef4444' : warn ? '#f59e0b' : '#06b6d4';
+
+ return (
+
+
+ {label}
+
+ {typeof used === 'number' ? used.toFixed(1) : used}/{typeof total === 'number' ? total.toFixed(1) : total} {unit}
+ ({pct}%)
+
+
+
+
+ );
+}
+
+function TempGauge({ label, tempC }) {
+ if (tempC == null) return null;
+ const warn = tempC > 75;
+ const crit = tempC > 90;
+ const pct = Math.min(100, (tempC / 100) * 100);
+ const color = crit ? '#ef4444' : warn ? '#f59e0b' : '#22c55e';
+
+ return (
+
+
{label}
+
+ {tempC.toFixed(0)}°C
+
+
+
+ );
+}
+
+function NodeRow({ status }) {
+ const cfg = LEVEL_CONFIG[status.level] ?? LEVEL_CONFIG[3];
+ return (
+
+
+
{status.name}
+
{status.message || cfg.label}
+
+ );
+}
+
+export function SystemHealth({ subscribe }) {
+ const [resources, setResources] = useState({
+ cpuTemp: null, gpuTemp: null,
+ ramUsed: null, ramTotal: null,
+ diskUsed: null, diskTotal: null,
+ gpuUsed: null, gpuTotal: null,
+ });
+ const [nodes, setNodes] = useState([]);
+ const [lastTs, setLastTs] = useState(null);
+
+ useEffect(() => {
+ const unsub = subscribe('/diagnostics', 'diagnostic_msgs/DiagnosticArray', (msg) => {
+ setLastTs(Date.now());
+
+ // Parse each DiagnosticStatus
+ const nodeList = [];
+
+ for (const status of msg.status ?? []) {
+ const kv = {};
+ for (const pair of status.values ?? []) kv[pair.key] = pair.value;
+
+ // Resource metrics
+ setResources(prev => {
+ const next = { ...prev };
+ if (kv.cpu_temp_c !== undefined) next.cpuTemp = parseFloat(kv.cpu_temp_c);
+ if (kv.gpu_temp_c !== undefined) next.gpuTemp = parseFloat(kv.gpu_temp_c);
+ if (kv.ram_used_mb !== undefined) next.ramUsed = parseFloat(kv.ram_used_mb) / 1024;
+ if (kv.ram_total_mb !== undefined) next.ramTotal = parseFloat(kv.ram_total_mb) / 1024;
+ if (kv.disk_used_gb !== undefined) next.diskUsed = parseFloat(kv.disk_used_gb);
+ if (kv.disk_total_gb!== undefined) next.diskTotal = parseFloat(kv.disk_total_gb);
+ if (kv.gpu_used_mb !== undefined) next.gpuUsed = parseFloat(kv.gpu_used_mb);
+ if (kv.gpu_total_mb !== undefined) next.gpuTotal = parseFloat(kv.gpu_total_mb);
+ return next;
+ });
+
+ // Collect all statuses as node rows
+ nodeList.push({ name: status.name, level: status.level, message: status.message });
+ }
+
+ if (nodeList.length > 0) {
+ setNodes(nodeList);
+ }
+ });
+ return unsub;
+ }, [subscribe]);
+
+ const stale = lastTs && Date.now() - lastTs > 10000;
+ const errorCount = nodes.filter(n => n.level === 2).length;
+ const warnCount = nodes.filter(n => n.level === 1).length;
+
+ return (
+
+ {/* Summary banner */}
+ {nodes.length > 0 && (
+
0 ? 'bg-red-950 border-red-800' :
+ warnCount > 0 ? 'bg-amber-950 border-amber-800' :
+ 'bg-green-950 border-green-800'
+ }`}>
+
0 ? 'bg-red-400 animate-pulse' :
+ warnCount > 0 ? 'bg-amber-400 animate-pulse' : 'bg-green-400'
+ }`} />
+
+ {errorCount > 0 ? `${errorCount} ERROR${errorCount > 1 ? 'S' : ''}` :
+ warnCount > 0 ? `${warnCount} WARNING${warnCount > 1 ? 'S' : ''}` :
+ 'All systems nominal'}
+
+
{nodes.length} nodes
+ {stale &&
STALE
}
+
+ )}
+
+ {/* Temperatures */}
+ {(resources.cpuTemp != null || resources.gpuTemp != null) && (
+
+
+
+
+ )}
+
+ {/* Resource bars */}
+
+
SYSTEM RESOURCES
+
+
+
+ {resources.ramUsed == null && resources.gpuUsed == null && resources.diskUsed == null && (
+
+ Waiting for resource metrics from /diagnostics…
+
Expected keys: cpu_temp_c, gpu_temp_c, ram_used_mb, disk_used_gb
+
+ )}
+
+
+ {/* Node list */}
+
+
+
ROS2 NODE HEALTH
+
{nodes.length} statuses
+
+ {nodes.length === 0 ? (
+
+ Waiting for /diagnostics…
+
+ ) : (
+
+ {/* Sort: errors first, then warns, then OK */}
+ {[...nodes]
+ .sort((a, b) => (b.level ?? 0) - (a.level ?? 0))
+ .map((n, i) => )
+ }
+
+ )}
+
+
+ );
+}
diff --git a/ui/social-bot/vite.config.js b/ui/social-bot/vite.config.js
index f873100..bf73187 100644
--- a/ui/social-bot/vite.config.js
+++ b/ui/social-bot/vite.config.js
@@ -11,4 +11,16 @@ export default defineConfig({
port: 8080,
host: true,
},
+ build: {
+ chunkSizeWarningLimit: 800,
+ rollupOptions: {
+ output: {
+ manualChunks: {
+ 'vendor-three': ['three'],
+ 'vendor-roslib': ['roslib'],
+ 'vendor-react': ['react', 'react-dom'],
+ },
+ },
+ },
+ },
});