Files
keita df91b1110e 精简仓库结构并增强任务链路:归档双目可选后端、新增LOS判据与自定义返航
归档 legacy/:stereo_yolo/stereo_ranger/target_tracker 三包 + 175 条测试 + demo/配置/权重/三份规格;运行链路不再依赖,默认 pytest 446 条,归档测试按需运行。

目标有效性第二判据改为 LOS 运动学一致性(pod_bridge/los_consistency.py),删除 require_vision;pod_locked() 叠加该判据,状态机随之后退重搜。

自定义返航(task_runner/return_home.py):保持转场高度回起飞点 → 恢复初始航向 → 垂直降落,不再爬 RTL_RETURN_ALT;未起飞/无起飞点/超时/异常仍退回 PX4 原生 RTL。

任务链安全兜底:主循环异常→紧急返航、stop 幂等、landing_stop 保留遥测、in_air 流复用+超时、解锁/起飞与吊舱识别仅执行一次、rtl_timeout 上限。

入口共享件拆到 utils/sitl_support.py;pod_sitl_test.py→l1_sitl_test.py;删除旧架构 test.py;相机脚本移入 tools/。

新增 .gitignore 并剔除历史误跟踪的 59 个 .pyc;requirements 按实际依赖重写;文档全面对齐,新增 legacy/README.md 与 tools/README.md。
2026-10-21 14:48:41 +08:00

565 lines
22 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>地面站 · PX4 信息</title>
<style>
:root { --bg:#f7f4ec; --card:#fffdf7; --ink:#2f3b2f; --mut:#6b756b;
--green:#2f5d46; --green-ink:#eaf3de; --line:#e2ddcf; --red:#b04a3a; }
* { box-sizing: border-box; }
body { margin:0; font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
background:var(--bg); color:var(--ink); }
header { padding:16px 24px; border-bottom:1px solid var(--line); background:var(--card); }
header h1 { margin:0; font-size:18px; font-weight:600; }
header .sub { color:var(--mut); font-size:12px; margin-top:2px; }
.nav { margin-top:8px; font-size:13px; }
.nav a { color:var(--green); text-decoration:none; margin-right:14px; }
.nav a.active { color:var(--ink); font-weight:600; text-decoration:underline; }
.wrap { padding:16px 24px; }
.tabs { display:flex; gap:8px; margin-bottom:14px; }
.tab { padding:8px 18px; border:1px solid var(--line); border-radius:8px 8px 0 0;
background:var(--card); color:var(--mut); font-size:14px; cursor:pointer; }
.tab.active { background:var(--green); color:var(--green-ink); border-color:var(--green);
font-weight:600; }
.panel { display:block; }
.hidden { display:none !important; }
.card { background:var(--card); border:1px solid var(--line); border-radius:10px;
padding:16px; margin-bottom:14px; }
.card h2 { margin:0 0 12px; font-size:14px; font-weight:600; color:var(--green); }
.badge { display:inline-block; padding:2px 10px; border-radius:12px; font-size:12px;
background:var(--green); color:var(--green-ink); }
.badge.warn { background:var(--red); color:#fff; }
.badge.mut { background:#c9c3b2; color:#4a514a; }
.hint { color:var(--mut); font-size:12px; margin-top:8px; }
table.kv-table { width:100%; border-collapse:collapse; font-size:13px; }
.kv-table td { padding:6px 4px; border-bottom:1px dashed var(--line);
font-variant-numeric:tabular-nums; }
.kv-table td:first-child { color:var(--mut); width:150px; }
.kv-table tr:last-child td { border-bottom:none; }
.toolbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
select, button.btn { padding:7px 12px; border:1px solid var(--line); border-radius:6px;
background:#fff; font-size:13px; color:var(--ink); }
button.btn { background:var(--green); color:var(--green-ink); border:none; cursor:pointer; }
button.btn.sec { background:#5b6a5e; }
button.btn:active { opacity:.85; }
.chips { display:flex; gap:8px; flex-wrap:wrap; margin:10px 0 4px; }
.chip { background:#efeadc; border:1px solid var(--line); border-radius:14px;
padding:4px 12px; font-size:12px; color:var(--ink);
font-variant-numeric:tabular-nums; }
.chip b { color:var(--green); }
.chart-grid { display:flex; flex-wrap:wrap; gap:12px; }
.chart-card { flex:1 1 340px; min-width:300px; background:#fffdf7;
border:1px solid var(--line); border-radius:8px; padding:6px; }
.chart-card canvas { width:100%; height:200px; display:block; }
canvas#timeline { width:100%; height:64px; display:block; background:#fffdf7;
border:1px solid var(--line); border-radius:8px; }
.ev-wrap { max-height:320px; overflow:auto; border:1px solid var(--line);
border-radius:8px; }
table.ev-table { width:100%; border-collapse:collapse; font-size:12.5px; }
.ev-table th { position:sticky; top:0; background:#efeadc; text-align:left;
padding:7px 8px; border-bottom:1px solid var(--line); z-index:1; }
.ev-table td { padding:6px 8px; border-bottom:1px dashed var(--line);
font-variant-numeric:tabular-nums; }
.ev-table tr:last-child td { border-bottom:none; }
.tl-legend { display:flex; gap:10px; flex-wrap:wrap; font-size:12px; color:var(--mut);
margin-top:6px; }
.tl-legend span { display:inline-flex; align-items:center; gap:4px; }
.dot { width:10px; height:10px; border-radius:3px; display:inline-block; }
.loading { color:var(--mut); font-size:13px; }
</style>
</head>
<body>
<header>
<h1>地面站 · PX4 信息</h1>
<div class="sub">PX4 输出实时视图 + 任务日志解读</div>
<nav class="nav">
<a href="/">主页面</a><a class="active" href="/px4">PX4 信息</a>
</nav>
</header>
<div class="wrap">
<div class="tabs">
<button class="tab active" id="tabbtn-live" onclick="switchTab('live')">实时状态</button>
<button class="tab" id="tabbtn-logs" onclick="switchTab('logs')">日志解读</button>
</div>
<!-- ===================== 实时状态 ===================== -->
<section id="tab-live" class="panel">
<div class="card">
<h2>PX4 遥测 <span class="badge" id="live-state">-</span>
<span class="badge mut" id="live-inject">注入 -</span>
<span class="badge mut" id="live-video">视频 -</span>
</h2>
<table class="kv-table"><tbody id="live-body">
<tr><td colspan="2" class="loading">加载中…</td></tr>
</tbody></table>
<div class="hint" id="live-meta"></div>
</div>
</section>
<!-- ===================== 日志解读 ===================== -->
<section id="tab-logs" class="panel hidden">
<div class="card">
<h2>运行段选择</h2>
<div class="toolbar">
<select id="run-sel" onchange="renderRun()"></select>
<button class="btn sec" onclick="loadLogs(true)">刷新日志</button>
<span class="hint" id="logs-note">日志文件为多次运行追加,已按 t_s 重置自动分段。</span>
</div>
<div class="chips" id="kpis"></div>
</div>
<div class="card">
<h2>曲线</h2>
<div class="chart-grid">
<div class="chart-card"><canvas id="c-alt"></canvas></div>
<div class="chart-card"><canvas id="c-dist"></canvas></div>
<div class="chart-card"><canvas id="c-vel"></canvas></div>
<div class="chart-card"><canvas id="c-yaw"></canvas></div>
<div class="chart-card"><canvas id="c-off"></canvas></div>
<div class="chart-card"><canvas id="c-yawrate"></canvas></div>
</div>
</div>
<div class="card">
<h2>任务状态时间线</h2>
<canvas id="timeline"></canvas>
<div class="tl-legend" id="tl-legend"></div>
</div>
<div class="card">
<h2>事件表 <span class="badge mut" id="ev-count">0</span></h2>
<div class="ev-wrap">
<table class="ev-table">
<thead><tr><th>t_s</th><th>类型</th><th>详情</th></tr></thead>
<tbody id="ev-body"></tbody>
</table>
</div>
</div>
</section>
</div>
<script>
// ============================================================ 通用
function $(id){ return document.getElementById(id); }
const STATE_COLORS = {
wait: "#8a8f8a", arm_takeoff: "#c9a227", goto: "#3a6ea5",
track: "#2f7d4f", approach: "#7a4fa3", rtl: "#b04a3a"
};
const STATE_LABELS = {
wait: "待命", arm_takeoff: "起飞", goto: "坐标引导", track: "追踪",
approach: "接近悬停", rtl: "返航"
};
const SERIES_COLORS = ["#2f6f4f", "#b04a3a", "#3a6ea5", "#c9a227", "#7a4fa3", "#0f766e"];
// ============================================================ 分页
let liveTimer = null;
function switchTab(name){
for (const t of ["live", "logs"]){
$("tabbtn-" + t).classList.toggle("active", t === name);
$("tab-" + t).classList.toggle("hidden", t !== name);
}
if (name === "live"){ startLive(); }
else { stopLive(); if (!logsLoaded) loadLogs(false); }
}
// ============================================================ 实时状态
const LIVE_FIELDS = [
["state", "任务状态", v => v],
["pos_valid", "位置有效", v => v === true ? "是" : (v === false ? "否" : "-")],
["fix_type", "定位类型 fix_type", v => v],
["num_satellites", "卫星数", v => v],
["latitude_deg", "纬度", v => num(v, 6)],
["longitude_deg", "经度", v => num(v, 6)],
["relative_altitude_m", "相对高度 (m)", v => num(v, 2)],
["yaw_deg", "航向 yaw (°)", v => num(v, 1)],
["mode", "飞行模式", v => v || "-"],
["target_injected", "目标已注入", v => v === true ? "是" : (v === false ? "否" : "-")],
["distance_to_target_m", "距目标 (m)", v => v === "inf" ? "∞" : num(v, 1)],
["target_locked", "吊舱锁定", v => v === true ? "锁定" : (v === false ? "未锁定" : "-")],
["off_target_x_deg", "脱靶 X (°)", v => num(v, 2)],
["off_target_y_deg", "脱靶 Y (°)", v => num(v, 2)],
["yaw_rate_dps", "yaw_rate (°/s)", v => num(v, 1)],
["vx_body_mps", "vx 前向 (m/s)", v => num(v, 2)],
["vz_mps", "vz 垂速 (m/s)", v => num(v, 2)],
];
function num(v, d){
if (v === null || v === undefined || v === "" || v === "nan" || v === "inf") return v === "inf" ? "∞" : "-";
const n = Number(v);
return Number.isNaN(n) ? String(v) : n.toFixed(d);
}
async function pollLive(){
try {
const r = await fetch("/api/state");
const j = await r.json();
const t = j.telemetry || {};
const s = $("live-state");
const st = j.state || "unknown";
s.textContent = st + "(" + (STATE_LABELS[st] || "") + ")";
s.className = "badge" + (st === "rtl" || st === "wait" ? " warn" : "");
$("live-inject").textContent = "注入 " + (j.continuous ? "持续中" : "未持续");
$("live-inject").className = "badge" + (j.continuous ? "" : " mut");
const v = j.video || {};
$("live-video").textContent = "视频 " + (!v.enabled ? "未配置" : (v.online ? "在线" : "断流"));
$("live-video").className = "badge" + (!v.enabled || !v.online ? " mut" : "");
$("live-body").innerHTML = LIVE_FIELDS.map(([k, label, fmt]) => {
const raw = t[k];
// /api/state 的遥测值是 CSV 原始字符串:"True"/"False" 归一化为布尔
const val0 = raw === "True" ? true : (raw === "False" ? false : raw);
const val = fmt(val0);
return `<tr><td>${label}</td><td>${val === undefined || val === null ? "-" : val}</td></tr>`;
}).join("");
$("live-meta").textContent =
`日志源: ${j.log_dir || "-"} · 刷新 ${new Date().toLocaleTimeString()}`
+ `(数据为任务日志最新一行;runner 未运行时显示上次任务数据)`;
} catch(e){
$("live-meta").textContent = "后端未连接: " + e;
}
}
function startLive(){
pollLive();
if (liveTimer === null) liveTimer = setInterval(pollLive, 1000);
}
function stopLive(){
if (liveTimer !== null){ clearInterval(liveTimer); liveTimer = null; }
}
// ============================================================ 日志加载与分段
let logsLoaded = false;
let telSegs = []; // 遥测分段(每段是对象数组)
let evSegs = []; // 事件分段
let curTel = null, curEv = null;
function parseVal(s){
if (s === "" || s === null || s === undefined) return null;
if (s === "True") return true;
if (s === "False") return false;
if (s === "inf") return "inf";
if (s === "-inf" || s === "nan") return null;
const n = Number(s);
return Number.isNaN(n) ? s : n;
}
function segmentize(arr, getT){
const segs = []; let cur = [];
for (const item of arr){
const t = getT(item);
if (cur.length && t < getT(cur[cur.length-1])){ segs.push(cur); cur = []; }
cur.push(item);
}
if (cur.length) segs.push(cur);
return segs;
}
// 事件分段:三重判据——t_s 回退(新会话)/「任务启动」标记(from==to)/
// 上一段以 rtl 收尾(每次运行以返航事件结束;旧日志没有启动标记时靠这条切分)
function segmentEvents(evs){
const segs = []; let cur = [];
for (const e of evs){
const prev = cur[cur.length - 1];
const boundary = !!prev && (
(e.t_s || 0) < (prev.t_s || 0)
|| (e.type === "state" && e.from_state === e.to_state)
|| prev.type === "rtl");
if (boundary){ segs.push(cur); cur = []; }
cur.push(e);
}
if (cur.length) segs.push(cur);
return segs;
}
async function loadLogs(force){
const note = $("logs-note");
if (logsLoaded && !force) return;
note.textContent = "加载中…";
try {
const [rt, re] = await Promise.all([
fetch("/api/logs/telemetry").then(r => r.json()),
fetch("/api/logs/events").then(r => r.json()),
]);
const fields = rt.fields || [];
const rows = (rt.rows || []).map(r => {
const o = {};
fields.forEach((f, i) => { o[f] = parseVal(r[i]); });
return o;
});
telSegs = segmentize(rows, o => o.t_s || 0);
evSegs = segmentEvents(re.events || []);
logsLoaded = true;
const sel = $("run-sel");
sel.innerHTML = "";
for (let k = 1; k <= telSegs.length; k++){ // 最近在前(默认选中)
const seg = segFromEnd(telSegs, k);
const dur = seg.length ? seg[seg.length-1].t_s || 0 : 0;
const opt = document.createElement("option");
opt.value = k; // k = 从最近数第 k 段
opt.textContent = (k === 1 ? "最近运行" : `倒数第 ${k} 次`)
+ ` · ${dur.toFixed(1)}s · ${seg.length} 行`;
sel.appendChild(opt);
}
if (!telSegs.length){
note.textContent = "未找到遥测日志(logs/task_telemetry.csv)。";
return;
}
note.textContent = `共 ${telSegs.length} 段运行(文件追加 ${rt.total} 行);`
+ "事件按运行次序从最近配对,较旧段可能无事件。";
renderRun();
} catch(e){
note.textContent = "日志加载失败: " + e;
}
}
function segFromEnd(segs, k){ return segs[segs.length - k] || null; }
function renderRun(){
const k = Number($("run-sel").value || 1);
curTel = segFromEnd(telSegs, k);
curEv = segFromEnd(evSegs, k);
if (!curTel) return;
renderKpis();
renderCharts();
renderTimeline();
renderEvents();
}
// ============================================================ KPI
function renderKpis(){
const seg = curTel;
const dur = seg.length ? seg[seg.length-1].t_s || 0 : 0;
const alts = seg.map(r => r.relative_altitude_m).filter(v => typeof v === "number" && isFinite(v));
const dists = seg.map(r => r.distance_to_target_m)
.filter(v => typeof v === "number" && isFinite(v) && v > 0);
const maxAlt = alts.length ? Math.max(...alts) : null;
const minDist = dists.length ? Math.min(...dists) : null;
const states = [];
for (const r of seg){ const s = r.state; if (s && states[states.length-1] !== s) states.push(s); }
const seq = states.map(s => STATE_LABELS[s] || s).join(" → ");
$("kpis").innerHTML = [
`时长 <b>${dur.toFixed(1)}s</b>`,
`最大高度 <b>${maxAlt === null ? "-" : maxAlt.toFixed(1) + "m"}</b>`,
`最近距目标 <b>${minDist === null ? "-" : minDist.toFixed(1) + "m"}</b>`,
`遥测 <b>${seg.length}</b> 行`,
`事件 <b>${curEv ? curEv.length : 0}</b> 条`,
`状态链 <b>${seq || "-"}</b>`,
].map(c => `<span class="chip">${c}</span>`).join("");
}
// ============================================================ 曲线图(纯 canvas)
function niceTicks(lo, hi, n){
const span = hi - lo;
if (!(span > 0)) return [lo];
const step0 = span / n;
const mag = Math.pow(10, Math.floor(Math.log10(step0)));
const norm = step0 / mag;
let step = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
step *= mag;
const out = [];
for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) out.push(v);
return out;
}
function fmtTick(v){
const a = Math.abs(v);
if (a >= 10) return String(Math.round(v * 10) / 10);
if (a >= 1) return String(Math.round(v * 100) / 100);
return String(Math.round(v * 1000) / 1000);
}
function drawChart(cv, spec){
const dpr = window.devicePixelRatio || 1;
const W = cv.clientWidth || 340, H = cv.clientHeight || 200;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
const ctx = cv.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
const L = 50, R = 12, T = 24, B = 24;
const pw = W - L - R, ph = H - T - B;
ctx.font = "12px sans-serif";
ctx.fillStyle = "#2f3b2f";
ctx.fillText(spec.title || "", 8, 15);
// 图例(右上,从右往左排)
let lx = W - R;
ctx.textAlign = "right";
for (let i = spec.series.length - 1; i >= 0; i--){
const s = spec.series[i];
const w = ctx.measureText(s.name).width;
ctx.fillStyle = s.color; ctx.fillRect(lx - w - 16, 7, 8, 8);
ctx.fillStyle = "#6b756b"; ctx.fillText(s.name, lx, 15);
lx -= w + 26;
}
ctx.textAlign = "left";
let xs = [], ys = [];
for (const s of spec.series) for (const p of s.points){
if (isFinite(p[0]) && isFinite(p[1])){ xs.push(p[0]); ys.push(p[1]); }
}
if (!xs.length){
ctx.fillStyle = "#6b756b";
ctx.fillText("无数据", L, T + ph / 2);
return;
}
let x0 = Math.min(...xs), x1 = Math.max(...xs);
if (x1 <= x0) x1 = x0 + 1;
let y0 = Math.min(...ys), y1 = Math.max(...ys);
if (y1 - y0 < 1e-9){ const m = Math.abs(y1) || 1; y0 -= m * 0.1; y1 += m * 0.1; }
else { const pad = (y1 - y0) * 0.08; y0 -= pad; y1 += pad; }
const xTo = x => L + (x - x0) / (x1 - x0) * pw;
const yTo = y => T + (1 - (y - y0) / (y1 - y0)) * ph;
ctx.lineWidth = 1;
ctx.strokeStyle = "#e2ddcf";
ctx.fillStyle = "#6b756b";
ctx.font = "11px sans-serif";
for (const yv of niceTicks(y0, y1, 5)){
const yy = yTo(yv);
ctx.beginPath(); ctx.moveTo(L, yy); ctx.lineTo(L + pw, yy); ctx.stroke();
ctx.fillText(fmtTick(yv), 6, yy + 3.5);
}
for (const xv of niceTicks(x0, x1, 4)){
const xx = xTo(xv);
ctx.beginPath(); ctx.moveTo(xx, T); ctx.lineTo(xx, T + ph); ctx.stroke();
const lbl = fmtTick(xv) + "s";
ctx.fillText(lbl, Math.min(Math.max(xx - 8, 2), W - R - ctx.measureText(lbl).width), H - 8);
}
ctx.strokeStyle = "#b9b4a3";
ctx.strokeRect(L, T, pw, ph);
for (const s of spec.series){
ctx.strokeStyle = s.color;
ctx.lineWidth = 1.6;
ctx.beginPath();
let started = false;
for (const p of s.points){
if (!isFinite(p[0]) || !isFinite(p[1])){ started = false; continue; }
const xx = xTo(p[0]), yy = yTo(p[1]);
if (!started){ ctx.moveTo(xx, yy); started = true; }
else ctx.lineTo(xx, yy);
}
ctx.stroke();
}
}
function seriesFrom(seg, key, label, colorIdx){
return { name: label, color: SERIES_COLORS[colorIdx % SERIES_COLORS.length],
points: seg.map(r => [r.t_s, r[key]]) };
}
function renderCharts(){
const seg = curTel;
drawChart($("c-alt"), { title: "相对高度 (m)", series: [seriesFrom(seg, "relative_altitude_m", "alt", 0)] });
drawChart($("c-dist"), { title: "距目标 (m)", series: [seriesFrom(seg, "distance_to_target_m", "dist", 1)] });
drawChart($("c-vel"), { title: "机体系速度 (m/s)", series: [
seriesFrom(seg, "vx_body_mps", "vx", 0), seriesFrom(seg, "vz_mps", "vz", 1)] });
drawChart($("c-yaw"), { title: "航向 yaw (°)", series: [seriesFrom(seg, "yaw_deg", "yaw", 2)] });
drawChart($("c-off"), { title: "脱靶量 (°)", series: [
seriesFrom(seg, "off_target_x_deg", "off_x", 3), seriesFrom(seg, "off_target_y_deg", "off_y", 4)] });
drawChart($("c-yawrate"), { title: "yaw_rate (°/s)", series: [seriesFrom(seg, "yaw_rate_dps", "yaw_rate", 5)] });
}
// ============================================================ 状态时间线
function transitionsOf(evSeg){
const out = [];
for (const e of (evSeg || [])){
if (e.type === "state" && e.to_state){
out.push({ t: e.t_s || 0, from: e.from_state, to: e.to_state, reason: e.reason || "" });
}
}
return out;
}
function renderTimeline(){
const cv = $("timeline");
const dpr = window.devicePixelRatio || 1;
const W = cv.clientWidth || 800, H = cv.clientHeight || 64;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
const ctx = cv.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
const seg = curTel;
const tEnd = seg.length ? (seg[seg.length-1].t_s || 0) : 0;
const trs = transitionsOf(curEv);
const blocks = [];
let curFrom = 0;
let curState = trs.length ? (trs[0].from || "wait") : (seg[0] ? seg[0].state : "wait");
for (const tr of trs){
blocks.push({ from: curFrom, to: tr.t, state: curState });
curFrom = tr.t;
curState = tr.to;
}
blocks.push({ from: curFrom, to: Math.max(tEnd, curFrom + 0.1), state: curState });
const L = 8, R = 8, T = 6, ph = H - 2 * T;
const pw = W - L - R;
ctx.font = "11px sans-serif";
for (const b of blocks){
const x0 = L + (b.from / Math.max(tEnd, 0.1)) * pw;
const x1 = L + (b.to / Math.max(tEnd, 0.1)) * pw;
const w = Math.max(x1 - x0, 1);
ctx.fillStyle = STATE_COLORS[b.state] || "#555";
ctx.fillRect(x0, T, w, ph);
const label = STATE_LABELS[b.state] || b.state;
if (w > ctx.measureText(label).width + 10){
ctx.fillStyle = "#fff";
ctx.fillText(label, x0 + (w - ctx.measureText(label).width) / 2, T + ph / 2 + 4);
}
}
ctx.fillStyle = "#6b756b";
ctx.fillText(`0s`, L, H - 1);
const endLbl = `${tEnd.toFixed(1)}s`;
ctx.fillText(endLbl, W - R - ctx.measureText(endLbl).width, H - 1);
// 图例
const used = [...new Set(blocks.map(b => b.state))];
$("tl-legend").innerHTML = used.map(s =>
`<span><span class="dot" style="background:${STATE_COLORS[s] || "#555"}"></span>${STATE_LABELS[s] || s}</span>`
).join("");
}
// ============================================================ 事件表
function evSummary(e){
if (e.type === "state") return `${e.from_state} → ${e.to_state}(${e.reason || ""})`;
if (e.type === "command") return `地面站命令: ${e.name}`;
if (e.type === "fc_status") return `飞控 ${e.severity || ""}: ${e.text || ""}`;
if (e.type === "takeoff") return `起飞至 ${e.altitude_m} m`;
if (e.type === "takeoff_failed") return `起飞失败: ${e.error || ""}`;
if (e.type === "rtl") return "开始返航";
const parts = [];
for (const [k, v] of Object.entries(e)){
if (["type", "ts_ms", "t_s"].includes(k)) continue;
parts.push(`${k}=${v}`);
}
return parts.join(", ");
}
function renderEvents(){
const evs = curEv || [];
$("ev-count").textContent = String(evs.length);
const rows = evs.slice(-300).map(e =>
`<tr><td>${(e.t_s ?? 0).toFixed(2)}</td><td>${e.type || "-"}</td><td>${evSummary(e)}</td></tr>`
);
$("ev-body").innerHTML = rows.length ? rows.join("")
: `<tr><td colspan="3" class="loading">本段无事件</td></tr>`;
}
// ============================================================ 启动
startLive();
window.addEventListener("resize", () => { if (logsLoaded && !$("tab-logs").classList.contains("hidden")) renderCharts(); });
</script>
</body>
</html>