[real] 整理 ROS 2 v2 导航原型

This commit is contained in:
2026-07-27 17:08:54 +08:00
parent 302b6acc64
commit c8aa490b37
69 changed files with 563988 additions and 3 deletions
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import socket
import threading
import time
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Optional
STATE_LOCK = threading.Lock()
LATEST_STATE: dict = {"type": "state", "connected": False}
NANO_ADDR: tuple[str, int]
UDP_SOCK: socket.socket
class Handler(SimpleHTTPRequestHandler):
def do_GET(self) -> None:
if self.path == "/api/state":
with STATE_LOCK:
data = json.dumps(LATEST_STATE).encode("utf-8")
self._json(200, data)
return
if self.path == "/api/map":
send_udp({"type": "map_request", "stamp": time.time()})
deadline = time.time() + 1.0
while time.time() < deadline:
with STATE_LOCK:
maybe_map = LATEST_STATE.get("map")
if isinstance(maybe_map, dict):
data = json.dumps(maybe_map).encode("utf-8")
self._json(200, data)
return
time.sleep(0.02)
self._json(200, b'{"points":[],"pose":null}')
return
super().do_GET()
def do_POST(self) -> None:
if self.path not in ("/api/control", "/api/state"):
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(body.decode("utf-8"))
send_udp(payload)
self._json(200, b'{"ok":true}')
except Exception as exc:
self._json(400, json.dumps({"ok": False, "error": str(exc)}).encode())
def _json(self, code: int, data: bytes) -> None:
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
def log_message(self, format: str, *args: object) -> None:
return
def send_udp(payload: dict) -> None:
data = json.dumps(payload, separators=(",", ":")).encode("utf-8")
UDP_SOCK.sendto(data, NANO_ADDR)
def udp_rx_loop(sock: socket.socket) -> None:
global LATEST_STATE
while True:
try:
data, _ = sock.recvfrom(65535)
payload = json.loads(data.decode("utf-8"))
payload["connected"] = True
payload["local_receive_time"] = time.time()
with STATE_LOCK:
LATEST_STATE = payload
except Exception:
time.sleep(0.01)
def heartbeat_loop() -> None:
while True:
try:
send_udp({"type": "ping", "stamp": time.time()})
except Exception:
pass
time.sleep(0.5)
def main() -> None:
global NANO_ADDR, UDP_SOCK
parser = argparse.ArgumentParser(description="Windows local web debug UI for sim2real_ros2")
parser.add_argument("--nano-host", required=True, help="Nano IP address")
parser.add_argument("--nano-port", type=int, default=15000, help="Nano UDP listen port")
parser.add_argument("--listen-host", default="0.0.0.0", help="Local HTTP host")
parser.add_argument("--http-port", type=int, default=8088, help="Local HTTP port")
parser.add_argument("--udp-port", type=int, default=15001, help="Local UDP receive port")
args = parser.parse_args()
NANO_ADDR = (args.nano_host, args.nano_port)
UDP_SOCK = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
UDP_SOCK.bind(("0.0.0.0", args.udp_port))
threading.Thread(target=udp_rx_loop, args=(UDP_SOCK,), daemon=True).start()
threading.Thread(target=heartbeat_loop, daemon=True).start()
static_dir = Path(__file__).resolve().parent / "static"
handler = lambda *a, **kw: Handler(*a, directory=str(static_dir), **kw)
httpd = ThreadingHTTPServer((args.listen_host, args.http_port), handler)
print(f"Open http://127.0.0.1:{args.http_port}")
print(f"UDP Nano={args.nano_host}:{args.nano_port} local={args.udp_port}")
httpd.serve_forever()
if __name__ == "__main__":
main()
@@ -0,0 +1,426 @@
'use strict';
const JOINT_NAMES = [
'FL_H_ABD', 'FL_H_PIT', 'FL_KNEE',
'FR_H_ABD', 'FR_H_PIT', 'FR_KNEE',
'RL_H_ABD', 'RL_H_PIT', 'RL_KNEE',
'RR_H_ABD', 'RR_H_PIT', 'RR_KNEE',
'FL_WHEEL', 'FR_WHEEL', 'RL_WHEEL', 'RR_WHEEL',
];
const $ = id => document.getElementById(id);
const cmd = { vx: 0, vy: 0, yaw: 0 };
let currentMode = 'UNKNOWN';
let dragging = false;
let mapModel = null;
let latestPose = null;
let lastGoal = null;
let pendingModePromise = null;
const mapCanvas = $('map-canvas');
const mapCtx = mapCanvas ? mapCanvas.getContext('2d') : null;
async function post(payload) {
try {
await fetch('/api/control', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (e) {
appendEvent('API_ERROR', e.message, 'bad');
}
}
function sendCmd() {
post({
type: 'cmd_vel',
linear: { x: cmd.vx, y: cmd.vy, z: 0 },
angular: { x: 0, y: 0, z: cmd.yaw },
});
$('cmd-display').textContent =
`vx=${cmd.vx.toFixed(2)} vy=${cmd.vy.toFixed(2)} yaw=${cmd.yaw.toFixed(2)}`;
}
function zeroAll() {
cmd.vx = 0;
cmd.vy = 0;
cmd.yaw = 0;
$('cmd-vx').value = 0;
$('cmd-vy').value = 0;
$('cmd-yaw').value = 0;
$('cmd-vx-v').textContent = '0.00';
$('cmd-vy-v').textContent = '0.00';
$('cmd-yaw-v').textContent = '0.00';
$('cmd-display').textContent = 'vx=0.00 vy=0.00 yaw=0.00';
$('stick').style.transform = 'translate(-50%, -50%)';
post({ type: 'zero' });
}
function highlightMode(mode) {
for (const m of ['DISABLED', 'REMOTE', 'WEB', 'NAV']) {
const btn = $('btn-' + m.toLowerCase());
if (btn) btn.classList.toggle('active-mode', m === mode);
}
const el = $('stage');
if (el) {
el.textContent = mode;
el.className = 'stage ' + mode;
}
currentMode = mode;
}
async function setMode(mode) {
if (mode === 'WEB') {
const ok = confirm('Confirm switch to WEB control?\nMake sure the robot is safe and velocity is zero.');
if (!ok) return false;
}
await post({ type: 'mode', mode });
highlightMode(mode);
appendEvent('MODE_SET', `-> ${mode}`, 'ok');
return true;
}
async function ensureMode(mode) {
if (currentMode === mode) return true;
if (pendingModePromise) {
const activeMode = await pendingModePromise;
if (activeMode === mode) return true;
}
pendingModePromise = (async () => {
const ok = await setMode(mode);
return ok ? mode : currentMode;
})();
const resolvedMode = await pendingModePromise;
pendingModePromise = null;
return resolvedMode === mode;
}
function worldToCanvas(x, y) {
if (!mapModel) return null;
const { minX, minY, scale, pad, drawH } = mapModel;
return {
x: pad + (x - minX) * scale,
y: pad + drawH - (y - minY) * scale,
};
}
function canvasToWorld(px, py) {
if (!mapModel) return null;
const { minX, minY, scale, pad, drawH } = mapModel;
return {
x: minX + (px - pad) / scale,
y: minY + (drawH - (py - pad)) / scale,
};
}
function buildMapModel(points) {
if (!points || !points.length || !mapCanvas) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const [x, y] of points) {
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
const pad = 20;
const usableW = mapCanvas.width - pad * 2;
const usableH = mapCanvas.height - pad * 2;
const spanX = Math.max(maxX - minX, 1e-6);
const spanY = Math.max(maxY - minY, 1e-6);
const scale = Math.min(usableW / spanX, usableH / spanY);
return { minX, minY, maxX, maxY, scale, pad, drawH: usableH };
}
function drawMap() {
if (!mapCtx || !mapCanvas) return;
mapCtx.clearRect(0, 0, mapCanvas.width, mapCanvas.height);
mapCtx.fillStyle = '#0b1020';
mapCtx.fillRect(0, 0, mapCanvas.width, mapCanvas.height);
if (!mapModel) {
mapCtx.fillStyle = '#9ca3af';
mapCtx.font = '16px sans-serif';
mapCtx.fillText('Map not loaded', 24, 32);
return;
}
mapCtx.fillStyle = 'rgba(255,255,255,0.28)';
for (const [x, y] of mapModel.points) {
const p = worldToCanvas(x, y);
if (!p) continue;
mapCtx.fillRect(p.x, p.y, 1.5, 1.5);
}
if (latestPose) {
const p = worldToCanvas(latestPose.x, latestPose.y);
if (p) {
mapCtx.fillStyle = '#3b82f6';
mapCtx.beginPath();
mapCtx.arc(p.x, p.y, 6, 0, Math.PI * 2);
mapCtx.fill();
mapCtx.strokeStyle = '#60a5fa';
mapCtx.lineWidth = 2;
mapCtx.beginPath();
mapCtx.moveTo(p.x, p.y);
mapCtx.lineTo(p.x + Math.cos(latestPose.yaw) * 18, p.y - Math.sin(latestPose.yaw) * 18);
mapCtx.stroke();
}
}
if (lastGoal) {
const p = worldToCanvas(lastGoal.x, lastGoal.y);
if (p) {
mapCtx.strokeStyle = '#ef4444';
mapCtx.lineWidth = 2;
mapCtx.beginPath();
mapCtx.moveTo(p.x - 7, p.y - 7);
mapCtx.lineTo(p.x + 7, p.y + 7);
mapCtx.moveTo(p.x + 7, p.y - 7);
mapCtx.lineTo(p.x - 7, p.y + 7);
mapCtx.stroke();
}
}
}
async function fetchMap() {
try {
const res = await fetch('/api/map');
const data = await res.json();
const points = data.points || [];
mapModel = buildMapModel(points);
if (mapModel) mapModel.points = points;
if (data.pose) latestPose = data.pose;
drawMap();
appendEvent('MAP', `loaded ${points.length} filtered points`, 'ok');
} catch (e) {
appendEvent('MAP_ERROR', e.message, 'bad');
}
}
function onMapClick(event) {
if (!mapCanvas || !mapModel) return;
const rect = mapCanvas.getBoundingClientRect();
const px = (event.clientX - rect.left) * (mapCanvas.width / rect.width);
const py = (event.clientY - rect.top) * (mapCanvas.height / rect.height);
const world = canvasToWorld(px, py);
if (!world) return;
lastGoal = world;
drawMap();
ensureMode('NAV').then(ok => {
if (!ok) return;
post({ type: 'go_to', x: world.x, y: world.y });
appendEvent('NAV_GO', `x=${world.x.toFixed(2)} y=${world.y.toFixed(2)}`, 'ok');
});
}
function setText(id, text, cls) {
const el = $(id);
if (!el) return;
el.textContent = text;
if (cls !== undefined) el.className = 'diag-value ' + cls;
}
function initJointsGrid() {
const grid = $('joints-grid');
if (!grid) return;
grid.innerHTML = JOINT_NAMES.map((name, i) => `
<div class="motor-row" id="mi-${i}">
<span class="stale" id="ms-${i}" style="color:#ef4444">●</span>
<span class="name">${name}</span>
<span class="val pos" id="mp-${i}">0.00</span>
<span class="val vel" id="mv-${i}">0.00</span>
<span class="val tau" id="mt-${i}">0.00</span>
</div>`).join('');
}
function updateJointsGrid(robot) {
if (!robot) return;
const pos = robot.joint_pos || [];
const vel = robot.joint_vel || [];
const tau = robot.joint_torque || [];
const upd = robot.update_counts || [];
for (let i = 0; i < 16; i++) {
const dot = $('ms-' + i);
const cnt = upd[i] ?? 0;
if (dot) dot.style.color = cnt > 0 ? '#30d158' : '#ef4444';
const p = $('mp-' + i);
if (p) p.textContent = (pos[i] || 0).toFixed(2);
const v = $('mv-' + i);
if (v) v.textContent = (vel[i] || 0).toFixed(2);
const t = $('mt-' + i);
if (t) {
t.textContent = (tau[i] || 0).toFixed(2);
t.style.color = Math.abs(tau[i] || 0) > 16 ? '#ff453a' : '#ff9f0a';
}
}
}
function applyState(data) {
const connected = data.connected && (!data.local_receive_time || Date.now() / 1000 - data.local_receive_time < 2.5);
if (!connected) {
$('stage').textContent = 'DISCONNECTED';
$('stage').className = 'stage DISCONNECTED';
return;
}
const mode = data.mode || 'UNKNOWN';
if (mode !== currentMode) highlightMode(mode);
const rt = data.runtime || {};
const src = rt.target_source || '--';
const srcCls = src === 'safety_brake' ? 'bad' : src === 'runtime_policy' ? 'ok' : src === 'runtime_zero_hold' ? 'active' : '';
setText('d-source', src, srcCls);
setText('d-released', String(rt.runtime_released ?? '--'), rt.runtime_released ? 'ok' : '');
setText('d-alpha', (rt.release_alpha ?? '--') !== '--' ? Number(rt.release_alpha).toFixed(2) : '--');
setText('d-zero', String(rt.zero_command ?? '--'));
setText('d-estop', String(data.estop ?? '--'), data.estop ? 'bad' : 'ok');
setText('d-mux', data.mux_status || '--');
const robot = data.robot || {};
const imuAge = robot.imu_age_ms ?? null;
setText('d-imu-fresh', String(robot.imu_fresh ?? '--'), robot.imu_fresh ? 'ok' : 'bad');
setText('d-imu-age', imuAge !== null ? imuAge.toFixed(1) : '--', imuAge !== null ? (imuAge > 200 ? 'bad' : imuAge > 60 ? 'warn' : 'ok') : '');
const grav = robot.projected_gravity;
setText('d-gravity', grav ? grav.map(v => Number(v).toFixed(2)).join(', ') : '--', grav && grav[2] < -0.5 ? 'ok' : 'warn');
setText('d-holdover', String(robot.holdover_count ?? '--'), (robot.holdover_count || 0) > 10 ? 'warn' : '');
const odomAge = robot.odom_age_ms ?? null;
setText('d-odom-age', odomAge !== null ? odomAge.toFixed(1) : '--', odomAge !== null ? (odomAge > 500 ? 'bad' : odomAge > 200 ? 'warn' : 'ok') : '');
const lp = robot.odom_local_pos;
setText('d-odom-pos', lp ? `x=${Number(lp[0]).toFixed(2)} y=${Number(lp[1]).toFixed(2)}` : '--');
setText('d-nav-status', data.nav_status || '--');
const nav = data.nav || {};
if (nav.pose) {
latestPose = nav.pose;
setText('d-nav-pose', `x=${nav.pose.x.toFixed(2)} y=${nav.pose.y.toFixed(2)} yaw=${(nav.pose.yaw * 57.2958).toFixed(1)}deg`);
drawMap();
}
const cv = data.cmd_vel || {};
const lin = cv.linear || {};
const ang = cv.angular || {};
setText('cv-vx', (lin.x ?? 0).toFixed(3));
setText('cv-vy', (lin.y ?? 0).toFixed(3));
setText('cv-yaw', (ang.z ?? 0).toFixed(3));
updateJointsGrid(robot);
}
async function poll() {
try {
const res = await fetch('/api/state');
const data = await res.json();
applyState(data);
} catch (_) {}
}
function appendEvent(kind, detail, cls) {
const el = $('events-log');
if (!el) return;
const div = document.createElement('div');
const t = new Date().toLocaleTimeString();
div.innerHTML = `<span class="ev-t">${t}</span> <span class="ev-${cls || 'ok'}">${kind}</span> <span style="color:#8e8e93">${detail || ''}</span>`;
el.appendChild(div);
while (el.children.length > 200) el.removeChild(el.firstChild);
el.scrollTop = el.scrollHeight;
}
$('btn-disabled').onclick = () => {
zeroAll();
setMode('DISABLED');
};
$('btn-remote').onclick = () => setMode('REMOTE');
$('btn-web').onclick = () => setMode('WEB');
$('btn-nav').onclick = () => setMode('NAV');
$('btn-zero').onclick = zeroAll;
$('btn-estop').onclick = () => {
if (confirm('Confirm soft e-stop?')) {
post({ type: 'estop', data: true });
zeroAll();
appendEvent('ESTOP', 'soft e-stop triggered', 'bad');
}
};
$('btn-refresh-map').onclick = fetchMap;
$('btn-record').onclick = () => ensureMode('NAV').then(ok => {
if (ok) post({ type: 'nav_cmd', command: 'record web_p1' });
});
$('btn-stop-nav').onclick = () => ensureMode('NAV').then(ok => {
if (ok) post({ type: 'nav_cmd', command: 'stop' });
});
$('btn-go-rel').onclick = () => ensureMode('NAV').then(ok => {
if (ok) post({ type: 'go_rel', dx: 0.3, dy: 0.0 });
});
for (const [id, key] of [['cmd-vx', 'vx'], ['cmd-vy', 'vy'], ['cmd-yaw', 'yaw']]) {
$(id).addEventListener('input', e => {
cmd[key] = parseFloat(e.target.value);
$(id + '-v').textContent = cmd[key].toFixed(2);
ensureMode('WEB').then(ok => {
if (ok) sendCmd();
});
});
}
const joystick = $('joystick');
const stick = $('stick');
function updateJoystick(clientX, clientY) {
const rect = joystick.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const maxR = rect.width * 0.42;
let dx = clientX - cx;
let dy = clientY - cy;
const dist = Math.hypot(dx, dy);
if (dist > maxR) {
dx = dx / dist * maxR;
dy = dy / dist * maxR;
}
stick.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
cmd.vx = parseFloat((-(dy / maxR) * 0.8).toFixed(3));
cmd.vy = parseFloat(((dx / maxR) * 0.3).toFixed(3));
$('cmd-vx').value = cmd.vx;
$('cmd-vy').value = cmd.vy;
$('cmd-vx-v').textContent = cmd.vx.toFixed(2);
$('cmd-vy-v').textContent = cmd.vy.toFixed(2);
if (currentMode === 'WEB') sendCmd();
}
joystick.addEventListener('pointerdown', e => {
ensureMode('WEB').then(ok => {
if (!ok) {
dragging = false;
zeroAll();
return;
}
dragging = true;
joystick.setPointerCapture(e.pointerId);
updateJoystick(e.clientX, e.clientY);
});
});
joystick.addEventListener('pointermove', e => {
if (dragging) updateJoystick(e.clientX, e.clientY);
});
joystick.addEventListener('pointerup', () => {
dragging = false;
zeroAll();
});
joystick.addEventListener('pointercancel', () => {
dragging = false;
zeroAll();
});
if (mapCanvas) mapCanvas.addEventListener('click', onMapClick);
setInterval(() => {
if (currentMode === 'WEB' && !dragging) sendCmd();
}, 50);
initJointsGrid();
setInterval(poll, 100);
fetchMap();
drawMap();
appendEvent('READY', 'page loaded, waiting for bridge state', 'ok');
@@ -0,0 +1,116 @@
<!doctype html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>sim2real ROS2 控制台</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="glass-panel top-bar">
<div class="top-bar-left">
<h1>sim2real ROS2</h1>
<span class="stage" id="stage">DISCONNECTED</span>
</div>
<div class="top-bar-center">
<span class="label">控制模式</span>
<button class="btn" id="btn-disabled">DISABLED</button>
<button class="btn btn-remote" id="btn-remote">REMOTE</button>
<button class="btn btn-web" id="btn-web">WEB</button>
<button class="btn btn-nav" id="btn-nav">NAV</button>
<div class="divider"></div>
<button class="btn" id="btn-zero">速度归零</button>
</div>
<div class="top-bar-right">
<button class="btn btn-danger" id="btn-estop">软急停</button>
</div>
</header>
<div class="glass-panel side-panel left-panel">
<div class="panel-section">
<h2 class="panel-title">运行状态</h2>
<div class="diag-row"><span class="diag-label">target_source</span><span class="diag-value" id="d-source">--</span></div>
<div class="diag-row"><span class="diag-label">runtime_released</span><span class="diag-value" id="d-released">--</span></div>
<div class="diag-row"><span class="diag-label">release_alpha</span><span class="diag-value" id="d-alpha">--</span></div>
<div class="diag-row"><span class="diag-label">zero_command</span><span class="diag-value" id="d-zero">--</span></div>
<div class="diag-row"><span class="diag-label">estop</span><span class="diag-value" id="d-estop">--</span></div>
<div class="diag-row"><span class="diag-label">mux</span><span class="diag-value" id="d-mux">--</span></div>
</div>
<div class="panel-section">
<h2 class="panel-title">IMU &amp; 里程计</h2>
<div class="diag-row"><span class="diag-label">IMU fresh</span><span class="diag-value" id="d-imu-fresh">--</span></div>
<div class="diag-row"><span class="diag-label">IMU age ms</span><span class="diag-value" id="d-imu-age">--</span></div>
<div class="diag-row"><span class="diag-label">projected_gravity</span><span class="diag-value" id="d-gravity">--</span></div>
<div class="diag-row"><span class="diag-label">holdover</span><span class="diag-value" id="d-holdover">--</span></div>
<div class="diag-row"><span class="diag-label">odom age ms</span><span class="diag-value" id="d-odom-age">--</span></div>
<div class="diag-row"><span class="diag-label">odom local pos</span><span class="diag-value" id="d-odom-pos">--</span></div>
<div class="diag-row"><span class="diag-label">nav pose</span><span class="diag-value" id="d-nav-pose">--</span></div>
<div class="diag-row"><span class="diag-label">nav status</span><span class="diag-value" id="d-nav-status">--</span></div>
</div>
<div class="panel-section flex-1">
<h2 class="panel-title">关节状态 (16轴)</h2>
<div id="joints-grid" class="motors-grid-list"></div>
</div>
</div>
<main class="glass-panel center-panel">
<div class="panel-section map-section">
<div class="map-header">
<h2 class="panel-title">PCD 2D 导航</h2>
<div class="map-actions">
<button class="btn" id="btn-refresh-map">刷新地图</button>
<button class="btn" id="btn-record">记录当前位置</button>
<button class="btn" id="btn-stop-nav">停止导航</button>
<button class="btn" id="btn-go-rel">前进 0.3m</button>
</div>
</div>
<canvas id="map-canvas" width="900" height="680"></canvas>
<div class="map-help">左键点击地图发送绝对目标;蓝点是当前机器人,红叉是最近目标。</div>
</div>
</main>
<div class="glass-panel side-panel right-panel">
<div class="panel-section">
<h2 class="panel-title">Web 手动控制</h2>
<div class="joystick-area">
<div class="joystick" id="joystick"><div id="stick"></div></div>
<p class="hint">拖动控制前后(vx)和横移(vy),松开归零</p>
</div>
<div class="slider-group">
<div class="slider-row">
<span class="slider-label">vx</span>
<input type="range" id="cmd-vx" class="glass-slider" min="-0.8" max="0.8" step="0.01" value="0">
<span class="slider-val" id="cmd-vx-v">0.00</span>
</div>
<div class="slider-row">
<span class="slider-label">vy</span>
<input type="range" id="cmd-vy" class="glass-slider" min="-0.3" max="0.3" step="0.01" value="0">
<span class="slider-val" id="cmd-vy-v">0.00</span>
</div>
<div class="slider-row">
<span class="slider-label">yaw</span>
<input type="range" id="cmd-yaw" class="glass-slider" min="-0.5" max="0.5" step="0.01" value="0">
<span class="slider-val" id="cmd-yaw-v">0.00</span>
</div>
</div>
<div class="cmd-display" id="cmd-display">vx=0.00 vy=0.00 yaw=0.00</div>
</div>
<div class="panel-section">
<h2 class="panel-title">当前输出 /cmd_vel</h2>
<div class="diag-row"><span class="diag-label">linear.x</span><span class="diag-value" id="cv-vx">--</span></div>
<div class="diag-row"><span class="diag-label">linear.y</span><span class="diag-value" id="cv-vy">--</span></div>
<div class="diag-row"><span class="diag-label">angular.z</span><span class="diag-value" id="cv-yaw">--</span></div>
</div>
<div class="panel-section log-section flex-1">
<h2 class="panel-title">事件流</h2>
<div id="events-log" class="log"></div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,299 @@
/* sim2real ROS2 Web Debug — Apple Glass Design */
:root {
--bg-primary: #000000;
--glass-bg: rgba(20, 20, 22, 0.65);
--glass-border: rgba(255, 255, 255, 0.12);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
--text-primary: #ffffff;
--text-secondary: #ebebf5;
--text-tertiary: #8e8e93;
--accent: #0a84ff;
--accent-hover: #409cff;
--success: #30d158;
--warning: #ffd60a;
--danger: #ff453a;
--blur-amount: 24px;
--saturation: 180%;
--spring: cubic-bezier(0.4, 0, 0.2, 1);
--panel-radius: 16px;
--font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-family);
background: radial-gradient(circle at top left, #1a1a24 0%, #000000 100%);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
min-height: 100vh;
overflow-x: hidden;
}
.glass-panel {
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-amount)) saturate(var(--saturation));
-webkit-backdrop-filter: blur(var(--blur-amount)) saturate(var(--saturation));
border: 0.5px solid var(--glass-border);
box-shadow: var(--glass-shadow);
z-index: 50;
}
.top-bar {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-radius: 24px;
width: 96%;
max-width: 1700px;
gap: 16px;
}
.top-bar-left, .top-bar-center, .top-bar-right {
display: flex;
align-items: center;
gap: 10px;
}
.top-bar-center { flex: 1; justify-content: center; }
.top-bar h1 {
font-size: 16px;
font-weight: 600;
background: linear-gradient(45deg, #fff, #8e8e93);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.divider { width: 1px; height: 24px; background: var(--glass-border); margin: 0 4px; }
.stage {
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
background: rgba(255,255,255,0.1);
color: var(--text-secondary);
}
.stage.DISCONNECTED { background: rgba(142,142,147,0.3); color: #aaa; }
.stage.CONNECTED { background: rgba(10,132,255,0.3); color: #82c4ff; }
.stage.REMOTE { background: rgba(48,209,88,0.3); color: #8deda7; }
.stage.WEB { background: rgba(0,122,255,0.3); color: #82c4ff; }
.stage.NAV { background: rgba(255,214,10,0.3); color: #ffe680; }
.stage.DISABLED { background: rgba(142,142,147,0.25); color: #aaa; }
.stage.ESTOPPED { background: rgba(255,69,58,0.5); color: #ff8b86; box-shadow: 0 0 8px rgba(255,69,58,0.4); }
.btn {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: var(--text-primary);
font-size: 12px;
font-weight: 500;
padding: 6px 12px;
cursor: pointer;
transition: all 0.2s var(--spring);
font-family: inherit;
}
.btn:hover:not(:disabled) { background: rgba(255,255,255,0.15); transform: translateY(-1px); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-danger { background: rgba(255,69,58,0.8); border-color: transparent; color: white; }
.btn-remote { background: rgba(48,209,88,0.2); border-color: rgba(48,209,88,0.4); color: #8deda7; }
.btn-web { background: rgba(10,132,255,0.2); border-color: rgba(10,132,255,0.4); color: #82c4ff; }
.btn-nav { background: rgba(255,214,10,0.2); border-color: rgba(255,214,10,0.4); color: #ffe680; }
.btn.active-mode { box-shadow: 0 0 0 2px white; }
.label { font-size: 11px; color: var(--text-tertiary); }
.side-panel,
.center-panel {
position: fixed;
top: 80px;
bottom: 20px;
border-radius: var(--panel-radius);
display: flex;
flex-direction: column;
overflow: hidden;
}
.left-panel { left: 2%; width: 320px; }
.right-panel { right: 2%; width: 320px; }
.center-panel { left: calc(2% + 340px); right: calc(2% + 340px); }
.flex-1 { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
.panel-section {
padding: 14px 16px;
border-bottom: 0.5px solid var(--glass-border);
}
.panel-section:last-child { border-bottom: none; }
.panel-title {
font-size: 11px;
font-weight: 700;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.diag-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 6px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
margin-bottom: 3px;
}
.diag-label { font-size: 11px; color: var(--text-tertiary); }
.diag-value { font-size: 11px; font-family: monospace; color: var(--text-primary); font-weight: 600; }
.diag-value.ok { color: var(--success); }
.diag-value.warn { color: var(--warning); }
.diag-value.bad { color: var(--danger); }
.diag-value.active { color: #82c4ff; }
.map-section {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
}
.map-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.map-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
#map-canvas {
width: 100%;
height: calc(100% - 56px);
min-height: 520px;
background: rgba(6, 10, 18, 0.95);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px;
}
.map-help {
font-size: 12px;
color: var(--text-tertiary);
}
.motors-grid-list {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
flex: 1;
}
.motor-row {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
background: rgba(0,0,0,0.25);
border-radius: 5px;
}
.motor-row .name { font-size: 10px; color: var(--text-secondary); width: 72px; font-family: monospace; flex-shrink: 0; }
.motor-row .val { font-size: 10px; font-family: monospace; text-align: right; flex: 1; }
.motor-row .val.pos { color: #0a84ff; }
.motor-row .val.vel { color: #30d158; }
.motor-row .val.tau { color: #ff9f0a; }
.motor-row .stale { font-size: 9px; width: 8px; flex-shrink: 0; }
.joystick-area { display: flex; flex-direction: column; align-items: center; gap: 8px; margin-bottom: 12px; }
.joystick {
position: relative;
width: 180px;
height: 180px;
border-radius: 50%;
background: radial-gradient(circle, rgba(10,132,255,0.2), rgba(10,132,255,0.05));
border: 1px solid rgba(10,132,255,0.3);
touch-action: none;
flex-shrink: 0;
}
#stick {
position: absolute;
left: 50%; top: 50%;
width: 56px; height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #30d158, #0a84ff);
transform: translate(-50%, -50%);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.slider-group { display: flex; flex-direction: column; gap: 8px; }
.slider-row { display: flex; align-items: center; gap: 8px; }
.slider-label { font-size: 11px; color: var(--text-tertiary); width: 28px; font-family: monospace; }
.slider-val { font-size: 11px; color: var(--accent); font-family: monospace; width: 38px; text-align: right; }
.glass-slider {
flex: 1;
-webkit-appearance: none;
height: 4px;
border-radius: 2px;
background: rgba(255,255,255,0.2);
outline: none;
}
.glass-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: white;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
.cmd-display {
margin-top: 8px;
padding: 6px 10px;
background: rgba(0,0,0,0.3);
border-radius: 6px;
font-family: monospace;
font-size: 12px;
color: var(--accent);
text-align: center;
}
.hint { font-size: 11px; color: var(--text-tertiary); text-align: center; }
.log-section { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.log {
flex: 1;
background: rgba(0,0,0,0.4);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 6px;
padding: 8px;
font-family: monospace;
font-size: 11px;
color: var(--text-secondary);
overflow-y: auto;
}
.log div { margin-bottom: 2px; line-height: 1.4; }
.ev-t { color: var(--text-tertiary); margin-right: 4px; }
.ev-ok { color: var(--success); }
.ev-warn { color: var(--warning); }
.ev-bad { color: var(--danger); }
@media (max-width: 1400px) {
.center-panel { left: 34%; right: 34%; }
}
@media (max-width: 1100px) {
.top-bar { width: 100%; border-radius: 0; top: 0; }
body { padding-top: 56px; }
.side-panel, .center-panel {
position: relative;
top: auto;
left: auto;
right: auto;
bottom: auto;
width: 100%;
border-radius: 0;
}
.center-panel { min-height: 700px; }
#map-canvas { min-height: 420px; }
}