const ui = {
canvas: document.getElementById("mapCanvas"),
canvasWrap: document.getElementById("canvasWrap"),
canvasEmpty: document.getElementById("canvasEmpty"),
videoFeed: document.getElementById("videoFeed"),
videoHint: document.getElementById("videoHint"),
cameraChip: document.getElementById("cameraChip"),
navStatus: document.getElementById("navStatus"),
poseStatus: document.getElementById("poseStatus"),
locStatus: document.getElementById("locStatus"),
safetyStatus: document.getElementById("safetyStatus"),
goalStatus: document.getElementById("goalStatus"),
routeStatus: document.getElementById("routeStatus"),
dwellStatus: document.getElementById("dwellStatus"),
mapText: document.getElementById("mapText"),
poseText: document.getElementById("poseText"),
localizationText: document.getElementById("localizationText"),
amclQualityText: document.getElementById("amclQualityText"),
safetyText: document.getElementById("safetyText"),
goalText: document.getElementById("goalText"),
routeText: document.getElementById("routeText"),
routeProgressText: document.getElementById("routeProgressText"),
dwellText: document.getElementById("dwellText"),
networkText: document.getElementById("networkText"),
qualityText: document.getElementById("qualityText"),
poseSourceText: document.getElementById("poseSourceText"),
mapSourceText: document.getElementById("mapSourceText"),
modeText: document.getElementById("modeText"),
toolText: document.getElementById("toolText"),
hintText: document.getElementById("hintText"),
messageText: document.getElementById("messageText"),
clockText: document.getElementById("clockText"),
lastUpdateText: document.getElementById("lastUpdateText"),
operatorAlert: document.getElementById("operatorAlert"),
alertTitle: document.getElementById("alertTitle"),
alertBody: document.getElementById("alertBody"),
alertSeverity: document.getElementById("alertSeverity"),
preflightList: document.getElementById("preflightList"),
launchCheckPanel: document.getElementById("launchCheckPanel"),
launchCheckStatus: document.getElementById("launchCheckStatus"),
launchCheckProgress: document.getElementById("launchCheckProgress"),
launchCheckList: document.getElementById("launchCheckList"),
consoleSummary: document.getElementById("consoleSummary"),
consoleLog: document.getElementById("consoleLog"),
missionStateText: document.getElementById("missionStateText"),
positionErrorText: document.getElementById("positionErrorText"),
yawErrorText: document.getElementById("yawErrorText"),
dwellStateText: document.getElementById("dwellStateText"),
segmentText: document.getElementById("segmentText"),
nextStepText: document.getElementById("nextStepText"),
dwellProgressPanel: document.getElementById("dwellProgressPanel"),
dwellProgressTitle: document.getElementById("dwellProgressTitle"),
dwellProgressTime: document.getElementById("dwellProgressTime"),
dwellProgressBar: document.getElementById("dwellProgressBar"),
routeNameInput: document.getElementById("routeNameInput"),
routeList: document.getElementById("routeList"),
refreshBtn: document.getElementById("refreshBtn"),
startNavBtn: document.getElementById("startNavBtn"),
initialPoseBtn: document.getElementById("initialPoseBtn"),
confirmPoseBtn: document.getElementById("confirmPoseBtn"),
goalBtn: document.getElementById("goalBtn"),
routeEditBtn: document.getElementById("routeEditBtn"),
routeSaveBtn: document.getElementById("routeSaveBtn"),
clearTrailBtn: document.getElementById("clearTrailBtn"),
routeStartBtn: document.getElementById("routeStartBtn"),
patrolExecutorBtn: document.getElementById("patrolExecutorBtn"),
routeCancelBtn: document.getElementById("routeCancelBtn"),
stopBtn: document.getElementById("stopBtn"),
showPoseBtn: document.getElementById("showPoseBtn"),
poseConsoleLog: document.getElementById("poseConsoleLog"),
poseConsoleSummary: document.getElementById("poseConsoleSummary"),
waypointRows: document.getElementById("waypointRows"),
wpAddEmptyBtn: document.getElementById("wpAddEmptyBtn"),
wpAddFromPoseBtn: document.getElementById("wpAddFromPoseBtn"),
wpSaveBtn: document.getElementById("wpSaveBtn"),
stopNavBtn: document.getElementById("stopNavBtn"),
};
const ctx = ui.canvas.getContext("2d");
let state = null;
let mapGrid = null;
let mapBitmap = null;
let patrolRoute = null;
let transform = null;
let tool = "view";
let dragStart = null;
let dragPreview = null;
let busy = false;
let videoOnline = false;
let videoReconnectTimer = null;
let poseTrail = [];
let inspectionRoute = null;
let routeDirty = false;
let diagnosticLines = [];
let lastDiagnosticSignature = "";
let launchCheck = null;
async function requestJson(method, url, body = null) {
const response = await fetch(url, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
cache: "no-store",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.error) {
const message = payload.error ? payload.error.message : `HTTP ${response.status}`;
throw new Error(message);
}
return payload;
}
function updateClock() {
if (!ui.clockText) return;
ui.clockText.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
}
function syncCanvasBufferSize() {
if (!ui.canvas || !ui.canvasWrap) return false;
const wrapRect = ui.canvasWrap.getBoundingClientRect();
const mapAspect = mapGrid && mapGrid.width && mapGrid.height ? Number(mapGrid.width) / Number(mapGrid.height) : 1;
const gutter = 10;
const availableW = Math.max(320, wrapRect.width - gutter);
const availableH = Math.max(240, wrapRect.height - gutter);
let width = availableW;
let height = width / mapAspect;
if (height > availableH) {
height = availableH;
width = height * mapAspect;
}
width = Math.max(320, Math.round(width));
height = Math.max(240, Math.round(height));
ui.canvas.style.width = `${width}px`;
ui.canvas.style.height = `${height}px`;
ui.canvas.style.aspectRatio = `${width} / ${height}`;
ui.canvasWrap.style.setProperty("--map-aspect", `${width} / ${height}`);
if (ui.canvas.width === width && ui.canvas.height === height) return false;
ui.canvas.width = width;
ui.canvas.height = height;
return true;
}
function updateVideoAspectFromNaturalSize() {
if (!ui.videoFeed || !ui.videoFeed.parentElement) return;
const width = Number(ui.videoFeed.naturalWidth || 0);
const height = Number(ui.videoFeed.naturalHeight || 0);
if (!width || !height) return;
ui.videoFeed.parentElement.style.setProperty("--video-aspect", `${width} / ${height}`);
}
function connectVideo() {
if (!ui.videoFeed) return;
videoReconnectTimer = null;
videoOnline = false;
updateCameraChip();
ui.videoHint.textContent = "连接摄像头";
ui.videoHint.hidden = false;
ui.videoFeed.src = `/api/video?ts=${Date.now()}`;
}
function scheduleVideoReconnect() {
if (videoReconnectTimer) return;
videoReconnectTimer = window.setTimeout(connectVideo, 1600);
}
function updateCameraChip() {
setChip(ui.cameraChip, videoOnline, videoOnline ? "Camera OK" : "Camera WAIT");
}
if (ui.videoFeed) {
ui.videoFeed.addEventListener("load", () => {
videoOnline = true;
updateVideoAspectFromNaturalSize();
ui.videoHint.hidden = true;
updateCameraChip();
});
ui.videoFeed.addEventListener("error", () => {
videoOnline = false;
ui.videoHint.textContent = "摄像头重连中";
ui.videoHint.hidden = false;
updateCameraChip();
scheduleVideoReconnect();
});
}
async function refresh() {
state = await requestJson("GET", "/api/state");
updateClock();
if (ui.lastUpdateText) ui.lastUpdateText.textContent = `刷新 ${new Date().toLocaleTimeString("zh-CN", { hour12: false })}`;
if (!inspectionRoute || !routeDirty) {
inspectionRoute = await requestJson("GET", "/api/inspection-routes/default");
if (ui.routeNameInput && !routeDirty) ui.routeNameInput.value = inspectionRoute.display_name || "默认巡检路线";
}
if (state.map && (!mapGrid || mapGrid.revision !== state.map.revision)) {
mapGrid = await requestJson("GET", "/api/map");
mapBitmap = createMapBitmap(mapGrid);
}
updateDiagnostics();
renderState();
renderRouteEditor();
draw();
}
function setBusy(nextBusy) {
busy = nextBusy;
for (const button of [
ui.refreshBtn,
ui.startNavBtn,
ui.initialPoseBtn,
ui.confirmPoseBtn,
ui.goalBtn,
ui.routeEditBtn,
ui.routeSaveBtn,
ui.clearTrailBtn,
ui.routeStartBtn,
ui.routeCancelBtn,
ui.stopBtn,
]) {
if (!button) continue;
button.disabled = busy;
}
}
function buildPreflightItems(preflight, robotRos, localization, routeSummary, cameraReady, navigationReady, navigationHealth) {
const blockingReasons = preflight && Array.isArray(preflight.blocking_reasons) ? preflight.blocking_reasons : [];
const backendChecks = preflight && Array.isArray(preflight.checks) ? preflight.checks : [];
const navHealthChecks = navigationHealth && Array.isArray(navigationHealth.checks) ? navigationHealth.checks : [];
const findCheck = (id) => backendChecks.find((item) => item && item.id === id) || null;
const detailLines = (title, rows) => [title, ...rows.filter(Boolean)].join(String.fromCharCode(10));
const routeCount = routeSummary ? Number(routeSummary.enabled_waypoint_count || 0) : 0;
const routeMapOk = !routeSummary || routeSummary.map_match !== false;
const obstacleCheck = findCheck("nav2_obstacle_avoidance");
const navHealthCheck = findCheck("navigation_health");
return [
{
label: "机器人通信",
ok: Boolean(robotRos && robotRos.ready),
detail: robotRos && robotRos.stale_sources && robotRos.stale_sources.length
? `数据过期 ${robotRos.stale_sources.join(", ")}`
: `cmd ${robotRos && robotRos.command_subscriber_count ? robotRos.command_subscriber_count : 0}`,
},
{
label: "网络链路",
ok: !(state && state.network && state.network.blocking_error),
warn: Boolean(state && state.network && state.network.fallback_active),
detail: networkDetailText(state && state.network),
diagnostic: detailLines("robot communication", [
`selected=${state && state.network ? state.network.selected_iface || state.network.ros_iface || "-" : "-"}`,
`policy=${state && state.network ? state.network.policy_role || "-" : "-"}`,
`reason=${state && state.network ? state.network.selection_reason || "-" : "-"}`,
state && state.network && state.network.blocking_error ? `blocking=${operatorReasonText(state.network.blocking_error)}` : "blocking=none",
]),
},
{
label: "Nav2 导航",
ok: Boolean(navigationReady),
detail: blockingReasons.find((item) => /Nav2/i.test(item))
? operatorReasonText(blockingReasons.find((item) => /Nav2/i.test(item)))
: "action server ready",
diagnostic: detailLines("Nav2 navigation", [
`navigation_ready=${Boolean(preflight && preflight.navigation_ready)}`,
`action_ready=${Boolean(state && state.ros && state.ros.nav2_action_ready)}`,
`launch_running=${Boolean(state && state.ros && state.ros.nav2_launch_running)}`,
state && state.ros && state.ros.nav2_launch_pid ? `launch_pid=${state.ros.nav2_launch_pid}` : "",
]),
},
{
label: "避障配置",
ok: obstacleCheck ? Boolean(obstacleCheck.ok) : true,
detail: obstacleCheck
? operatorReasonText(obstacleCheck.detail || obstacleCheck.reason || "等待检查")
: "Nav2 obstacle/inflation check",
},
{
label: "避障实时链路",
ok: Boolean(navigationHealth && navigationHealth.ok),
detail: navHealthCheck
? operatorReasonText(navHealthCheck.detail || navHealthCheck.reason || "等待 /scan + TF + 位姿")
: navigationHealthText(navigationHealth),
},
{
label: "地图与姿态",
ok: Boolean(state && state.map && state.pose && localization && localization.pose_fresh),
detail: state && state.pose ? `pose age ${Number(state.pose_age_s || 0).toFixed(2)}s` : "等待 map pose",
},
{
label: "定位确认",
ok: Boolean(localization && localization.confirmed),
detail: localization && localization.confirmed
? "operator confirmed"
: operatorReasonText(localization && localization.reason ? localization.reason : "等待定位确认"),
},
{
label: "巡检路线",
ok: routeCount > 0 && routeMapOk && !routeDirty,
warn: routeDirty || (routeCount === 0 && routeMapOk),
detail: routeDirty ? "路线未保存" : routeMapOk ? `${routeCount} 个点` : "地图 revision 不匹配",
},
{
label: "视频回传",
ok: Boolean(cameraReady),
warn: true,
detail: cameraReady ? "camera stream ready" : "等待视频流",
},
].filter(item => item.label !== "定位确认" && item.label !== "巡检路线");
}
function renderPreflightItems(container, items) {
if (!container) return;
container.replaceChildren();
for (const item of items) {
const row = document.createElement("button");
row.type = "button";
row.className = `preflight-item ${item.ok ? "ok" : item.warn ? "warn" : "fail"}`;
row.title = "????????";
const dot = document.createElement("span");
dot.className = "preflight-dot";
const main = document.createElement("div");
main.className = "preflight-main";
const label = document.createElement("strong");
label.textContent = item.label;
const detail = document.createElement("span");
detail.textContent = item.detail || "-";
main.append(label, detail);
const stateText = document.createElement("span");
stateText.className = "preflight-state";
stateText.textContent = item.ok ? "OK" : item.warn ? "WAIT" : "FAIL";
row.addEventListener("click", () => showCheckDetails(item));
row.append(dot, main, stateText);
container.append(row);
}
}
function showCheckDetails(item) {
const newline = String.fromCharCode(10);
const title = item && item.label ? item.label : "check item";
const detail = item && item.detail ? item.detail : "no detail";
const text = item && item.diagnostic ? item.diagnostic : `${title}${newline}${detail}`;
const lines = text.split(newline).filter(Boolean);
const status = item && item.ok ? "OK" : item && item.warn ? "WAIT" : "FAIL";
const timestamp = new Date().toLocaleTimeString("zh-CN", { hour12: false });
const message = [`[${timestamp}] ${status} ${title}`, ...lines.map((line) => ` ${line}`)].join(newline);
diagnosticLines.push(message);
if (diagnosticLines.length > 80) diagnosticLines = diagnosticLines.slice(-80);
lastDiagnosticSignature = `${lastDiagnosticSignature}:detail:${Date.now()}`;
if (ui.consoleSummary) ui.consoleSummary.textContent = `${title} details`;
if (ui.consoleLog) {
ui.consoleLog.textContent = diagnosticLines.join(`${newline}${newline}`);
ui.consoleLog.scrollTop = ui.consoleLog.scrollHeight;
}
if (ui.messageText) ui.messageText.textContent = `${title}: ${detail}`;
}
function updateLaunchCheckPanel() {
if (!ui.launchCheckPanel) return;
if (!ui.launchCheckPanel.dataset.detailsBound) {
ui.launchCheckPanel.dataset.detailsBound = "1";
ui.launchCheckPanel.addEventListener("click", (event) => {
if (event.target && event.target.closest && event.target.closest(".preflight-item")) return;
showLaunchCheckDetails();
});
}
const visible = Boolean(launchCheck);
ui.launchCheckPanel.hidden = !visible;
if (!visible) return;
const items = Array.isArray(launchCheck.items) ? launchCheck.items : [];
const okCount = items.filter((item) => item.ok).length;
const total = Math.max(1, items.length);
const pct = launchCheck.phase === "starting" ? 100 : Math.round((okCount / total) * 100);
ui.launchCheckPanel.classList.remove("ok", "warn", "fail", "running", "starting");
ui.launchCheckPanel.classList.add(launchCheck.phase || "running");
if (ui.launchCheckStatus) ui.launchCheckStatus.textContent = launchCheck.status || "\u68c0\u67e5\u4e2d";
if (ui.launchCheckProgress) ui.launchCheckProgress.style.width = `${pct}%`;
renderPreflightItems(ui.launchCheckList, items);
}
function showLaunchCheckDetails() {
if (!launchCheck || !Array.isArray(launchCheck.items)) return;
const newline = String.fromCharCode(10);
const failed = launchCheck.items.filter((item) => !item.ok && !item.warn);
const waiting = launchCheck.items.filter((item) => !item.ok && item.warn);
const header = `${launchCheck.status || "check"}: ${launchCheck.items.filter((item) => item.ok).length}/${launchCheck.items.length}`;
const sections = [header];
for (const item of [...failed, ...waiting, ...launchCheck.items.filter((item) => item.ok)]) {
const status = item.ok ? "OK" : item.warn ? "WAIT" : "FAIL";
sections.push(`${status} ${item.label}: ${item.detail || "-"}`);
}
diagnosticLines.push(`[${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] launch check${newline} ${sections.join(`${newline} `)}`);
if (diagnosticLines.length > 80) diagnosticLines = diagnosticLines.slice(-80);
if (ui.consoleSummary) ui.consoleSummary.textContent = "launch check details";
if (ui.consoleLog) {
ui.consoleLog.textContent = diagnosticLines.join(`${newline}${newline}`);
ui.consoleLog.scrollTop = ui.consoleLog.scrollHeight;
}
if (ui.messageText) ui.messageText.textContent = failed[0] ? `${failed[0].label}: ${failed[0].detail || "FAIL"}` : header;
}
function setLaunchCheck(phase, status, items = null) {
launchCheck = { phase, status, items: items || [] };
updateLaunchCheckPanel();
}
function currentPreflightItems() {
const preflight = state && state.preflight ? state.preflight : { navigation_ready: false, inspection_start_ready: false, blocking_reasons: [] };
const robotRos = state && state.robot_ros ? state.robot_ros : { ready: false };
const localization = state && state.localization ? state.localization : { confirmed: false, pose_fresh: false, state: "waiting" };
const routeSummary = state && state.inspection_route ? state.inspection_route : null;
const cameraReady = Boolean((state && state.camera && state.camera.available) || videoOnline);
return buildPreflightItems(preflight, robotRos, localization, routeSummary, cameraReady, Boolean(preflight.navigation_ready), state && state.navigation_health);
}
function renderState() {
const navReady = Boolean(state && state.ros && state.ros.nav2_action_ready);
const navLaunch = Boolean(state && state.ros && state.ros.nav2_launch_running);
const preflight = state && state.preflight ? state.preflight : { navigation_ready: false, inspection_start_ready: false, blocking_reasons: [] };
const robotRos = state && state.robot_ros ? state.robot_ros : { ready: false };
const localization = state && state.localization ? state.localization : { confirmed: false, pose_fresh: false, state: "waiting" };
const poseReady = Boolean(state && state.pose);
const poseFresh = Boolean(localization.pose_fresh);
const localizationConfirmed = Boolean(localization.confirmed);
const navigationReady = Boolean(preflight.navigation_ready);
const goal = state && state.goal ? state.goal : { state: "idle" };
const routeRun = state && state.inspection_run ? state.inspection_run : { state: "idle" };
const routeSummary = state && state.inspection_route ? state.inspection_route : null;
const cameraReady = Boolean((state && state.camera && state.camera.available) || videoOnline);
const routeActive = inspectionRunActive(routeRun);
setChip(ui.navStatus, navigationReady, navigationReady ? "Nav2 OK" : navLaunch ? (robotRos.ready ? "Nav2 WAIT" : "ROS WAIT") : "Nav2 OFF");
setChip(ui.poseStatus, poseFresh, poseFresh ? "Pose OK" : "Pose WAIT");
if (ui.locStatus) setChip(ui.locStatus, localizationConfirmed, localizationChipText(localization));
setChip(ui.goalStatus, ["accepted", "executing", "sending"].includes(goal.state), goal.state || "idle");
setChip(ui.routeStatus, routeActive || routeRun.state === "completed", routeChipText(routeRun, routeSummary));
setChip(ui.dwellStatus, ["arrived", "segmenting", "dwelling"].includes(routeRun.state), dwellChipText(routeRun));
setChip(ui.cameraChip, cameraReady, cameraReady ? "Camera OK" : "Camera WAIT");
ui.modeText.textContent = state ? state.mode : "go2_robot_nav2";
ui.mapText.textContent = state && state.map
? `${state.map.width}x${state.map.height} @ ${Number(state.map.resolution).toFixed(3)}m`
: "等待";
ui.poseText.textContent = state && state.pose
? `${state.pose.raw_source || state.pose.source || "pose"} x=${state.pose.x.toFixed(2)} y=${state.pose.y.toFixed(2)} yaw=${state.pose.yaw_deg.toFixed(1)}°`
: "等待";
if (ui.localizationText) ui.localizationText.textContent = localizationText(localization, preflight);
if (ui.amclQualityText) {
const cov = state && state.amcl_covariance;
if (cov && cov.position_uncertainty_m != null) {
const pu = cov.position_uncertainty_m;
const yu = cov.yaw_uncertainty_deg;
let label = pu < 0.05 ? "✅ " : pu < 0.15 ? "⚠️ " : "❌ ";
label += pu.toFixed(3) + "m";
if (yu != null) label += " / " + yu.toFixed(1) + "°";
if (pu < 0.05) label += " (可开始巡检)";
else if (pu < 0.15) label += " (接近收敛)";
else label += " (粒子未收敛)";
ui.amclQualityText.textContent = label;
} else {
ui.amclQualityText.textContent = "等待 AMCL";
}
}
ui.goalText.textContent = goal.state || "idle";
if (ui.routeText) ui.routeText.textContent = routeSummary ? `${routeSummary.enabled_waypoint_count || 0} 点` : "未保存";
if (ui.routeProgressText) ui.routeProgressText.textContent = routeProgressText(routeRun, routeSummary);
if (ui.dwellText) ui.dwellText.textContent = dwellText(routeRun);
if (ui.segmentText) ui.segmentText.textContent = realtimeSegmentText(routeRun.realtime_segment) || "等待到点";
updateSegmentConsole(routeRun);
if (ui.networkText) ui.networkText.textContent = networkText(state && state.network);
if (ui.safetyText) ui.safetyText.textContent = navigationHealthText(state && state.navigation_health);
if (ui.qualityText) ui.qualityText.textContent = mapQualityText(mapGrid);
if (ui.poseSourceText) ui.poseSourceText.textContent = poseSourceText(state);
if (ui.mapSourceText) ui.mapSourceText.textContent = state && state.map ? compactPath(state.map.source || "ros:/map") : "等待";
updateMissionStrip(goal, routeRun, localization, preflight);
updateOperatorAlert(goal, routeRun, localization, preflight, routeSummary, navigationReady, state && state.navigation_health);
updatePreflightList(preflight, robotRos, localization, routeSummary, cameraReady, navigationReady, state && state.navigation_health);
updateDwellProgress(routeRun);
updateLaunchCheckPanel();
if (state && state.network && state.network.blocking_error && ui.messageText.textContent === "dashboard ready") {
ui.messageText.textContent = operatorReasonText(state.network.blocking_error);
}
if (state && state.network && state.network.warning && ui.messageText.textContent === "dashboard ready") {
ui.messageText.textContent = operatorReasonText(state.network.warning);
}
if (preflight.blocking_reasons && preflight.blocking_reasons.length && ui.messageText.textContent === "dashboard ready") {
ui.messageText.textContent = operatorReasonText(preflight.blocking_reasons[0]);
}
ui.toolText.textContent = tool === "initial" ? "初始位姿" : tool === "goal" ? "目标点" : tool === "route" ? "路线编辑" : "查看";
if (ui.initialPoseBtn) ui.initialPoseBtn.classList.toggle("selected", tool === "initial");
ui.goalBtn.classList.toggle("selected", tool === "goal");
ui.routeEditBtn.classList.toggle("selected", tool === "route");
const routeSavedReady = routeDirty || (routeSummary && (routeSummary.enabled_waypoint_count || 0) > 0 && routeSummary.map_match !== false);
const routeCanStart = Boolean(preflight.inspection_start_ready);
if (ui.initialPoseBtn) ui.initialPoseBtn.disabled = busy || routeActive;
if (ui.confirmPoseBtn) ui.confirmPoseBtn.disabled = busy || routeActive || !poseFresh || !navReady || !robotRos.ready;
if (ui.goalBtn) ui.goalBtn.disabled = busy || routeActive;
if (ui.routeNameInput) ui.routeNameInput.disabled = routeActive;
ui.routeCancelBtn.disabled = busy || !routeActive;
for (const button of [ui.routeEditBtn, ui.routeSaveBtn]) {
if (button) button.disabled = busy || routeActive;
}
if (ui.routeStartBtn) ui.routeStartBtn.disabled = busy || routeActive || !routeCanStart;
ui.canvasWrap.classList.toggle("crosshair", tool !== "view");
ui.canvasEmpty.hidden = Boolean(mapGrid);
updatePoseTrail();
if (ui.clearTrailBtn) ui.clearTrailBtn.disabled = busy || poseTrail.length <= 1;
}
function updateMissionStrip(goal, run, localization, preflight) {
if (ui.missionStateText) ui.missionStateText.textContent = missionStateText(goal, run, preflight);
const check = run && run.arrival_check ? run.arrival_check : run && run.navigation_check ? run.navigation_check : goal && goal.xy_fallback ? goal.xy_fallback : null;
const target = run && run.current_waypoint ? run.current_waypoint : goal && goal.target ? goal.target : null;
const pose = state && state.pose ? state.pose : null;
const distance = check && Number.isFinite(Number(check.distance_m))
? Number(check.distance_m)
: pose && target && Number.isFinite(Number(target.x))
? Math.hypot(Number(pose.x) - Number(target.x), Number(pose.y) - Number(target.y))
: null;
const yawError = check && Number.isFinite(Number(check.yaw_error_deg))
? Math.abs(Number(check.yaw_error_deg))
: pose && target && Number.isFinite(Number(target.yaw_deg))
? Math.abs(normalizeYawDeg(Number(pose.yaw_deg) - Number(target.yaw_deg)))
: null;
if (ui.positionErrorText) ui.positionErrorText.textContent = distance === null ? "--" : `${distance.toFixed(3)} m`;
if (ui.yawErrorText) ui.yawErrorText.textContent = yawError === null ? "--" : `${yawError.toFixed(1)} deg`;
if (ui.dwellStateText) ui.dwellStateText.textContent = dwellStateText(run);
if (ui.nextStepText) ui.nextStepText.textContent = nextStepText(goal, run, localization, preflight);
const positionTolerance = currentPositionTolerance(check);
const yawTolerance = currentYawTolerance(check);
setMissionClass(ui.positionErrorText, distance, positionTolerance, positionTolerance + 0.10);
setMissionClass(ui.yawErrorText, yawError, yawTolerance, Math.max(yawTolerance + 10.0, yawTolerance * 2.25));
setMissionClass(ui.dwellStateText, run && run.state === "failed" ? 99 : 0, 0, 1);
}
function updateOperatorAlert(goal, run, localization, preflight, routeSummary, navigationReady, navigationHealth) {
if (!ui.operatorAlert) return;
const network = state && state.network ? state.network : null;
let level = "warn";
let title = "\u7b49\u5f85\u7cfb\u7edf\u72b6\u6001";
let body = "\u542f\u52a8\u5bfc\u822a\u3001\u786e\u8ba4\u5b9a\u4f4d\u540e\u518d\u6267\u884c\u76ee\u6807\u70b9\u6216\u56fa\u5b9a\u5de1\u68c0\u3002";
let label = "STANDBY";
const blockingReason = preflight && preflight.blocking_reasons && preflight.blocking_reasons.length
? operatorReasonText(preflight.blocking_reasons[0])
: "";
const safetyReason = navigationHealth && !navigationHealth.ok ? navigationHealthText(navigationHealth) : "";
if (run && run.state === "failed") {
level = "fail";
title = "\u5de1\u68c0\u5df2\u505c\u6b62";
body = failureDetailText(run, goal, "\u9700\u8981\u4eba\u5de5\u5904\u7406\u540e\u91cd\u65b0\u5f00\u59cb");
label = failureCategoryLabel(run.failure_category || (run.diagnostics && run.diagnostics.category) || "ROUTE FAIL");
} else if (run && run.state === "segmenting") {
level = "ok";
title = run.status_text || "\u6b63\u5728\u8c03\u7528\u5b9e\u65f6\u8bc6\u522b\u63a5\u53e3";
body = realtimeSegmentText(run.realtime_segment) || "\u5df2\u5230\u70b9\uff0c\u6b63\u5728\u4e0a\u4f20\u56fe\u7247\u5e76\u8c03\u7528\u63a5\u53e3\u3002";
label = "SEGMENT";
} else if (run && ["arrived", "dwelling"].includes(run.state)) {
level = "ok";
title = run.status_text || arrivalLabel(run);
body = realtimeSegmentText(run.realtime_segment) || dwellText(run);
label = "DWELL";
} else if (goal && goal.state === "xy_fallback") {
level = "warn";
title = "\u5df2\u89e6\u53d1 XY \u5230\u70b9\u515c\u5e95";
body = "\u4f4d\u7f6e\u5df2\u7a33\u5b9a\uff0c\u5df2\u505c\u6b62\u5f53\u524d Nav2 goal \u5e76\u8fdb\u5165\u5230\u70b9\u786e\u8ba4\u3002";
label = "XY FALLBACK";
} else if (inspectionRunActive(run)) {
level = run && run.xy_fallback_used ? "warn" : "ok";
title = "\u5de1\u68c0\u6267\u884c\u4e2d";
body = routeProgressText(run, routeSummary);
label = run && run.xy_fallback_used ? "XY FALLBACK" : "RUNNING";
} else if (network && network.blocking_error) {
level = "fail";
title = "\u673a\u5668\u4eba\u7f51\u7edc\u94fe\u8def\u5f02\u5e38";
body = operatorReasonText(network.blocking_error);
label = "NETWORK";
} else if (network && network.fallback_active) {
level = "warn";
title = "\u5df2\u5207\u6362\u6709\u7ebf\u515c\u5e95";
body = networkDetailText(network);
label = "FALLBACK";
} else if (goal && ["accepted", "executing", "sending"].includes(goal.state)) {
level = "ok";
title = "\u76ee\u6807\u5bfc\u822a\u4e2d";
body = "\u7b49\u5f85 Nav2 \u5230\u70b9\uff0c\u5230\u70b9\u540e\u4f1a\u7ee7\u7eed\u505c\u7a33\u548c\u62cd\u7167\u68c0\u67e5\u3002";
label = "NAVIGATING";
} else if (safetyReason) {
level = "fail";
title = "\u907f\u969c\u5b9e\u65f6\u94fe\u8def\u5f02\u5e38";
body = safetyReason;
label = "SAFETY";
} else if (blockingReason) {
level = "warn";
title = "\u542f\u52a8\u68c0\u67e5\u672a\u901a\u8fc7";
body = blockingReason;
label = "CHECK";
} else if (routeSummary && routeSummary.map_match === false) {
level = "warn";
title = "\u8def\u7ebf\u5730\u56fe\u4e0d\u5339\u914d";
body = "\u5f53\u524d\u5730\u56fe\u5df2\u53d8\u5316\uff0c\u8bf7\u91cd\u65b0\u4fdd\u5b58\u5de1\u68c0\u8def\u7ebf\u540e\u518d\u542f\u52a8\u3002";
label = "MAP MISMATCH";
} else if (!navigationReady) {
level = "warn";
title = "\u5bfc\u822a\u672a\u5c31\u7eea";
body = "\u8bf7\u5148\u542f\u52a8 Nav2 \u5e76\u7b49\u5f85\u673a\u5668\u4eba ROS \u6570\u636e\u5c31\u7eea\u3002";
label = "NAV WAIT";
} else if (localization && !localization.confirmed) {
level = "warn";
title = "\u9700\u8981\u786e\u8ba4\u5b9a\u4f4d";
body = operatorReasonText(localization.reason || "\u8bf7\u6838\u5bf9\u5730\u56fe\u4e2d\u7684\u771f\u5b9e\u4f4d\u7f6e\u548c\u671d\u5411\u3002");
label = "LOC CHECK";
} else {
level = "ok";
title = "\u7cfb\u7edf\u5c31\u7eea";
body = "\u53ef\u4ee5\u53d1\u9001\u76ee\u6807\u70b9\uff0c\u6216\u542f\u52a8\u5df2\u4fdd\u5b58\u7684\u56fa\u5b9a\u5de1\u68c0\u8def\u7ebf\u3002";
label = "READY";
}
ui.operatorAlert.classList.remove("ok", "warn", "fail");
ui.operatorAlert.classList.add(level);
if (ui.alertTitle) ui.alertTitle.textContent = title;
if (ui.alertBody) ui.alertBody.textContent = body;
if (ui.alertSeverity) ui.alertSeverity.textContent = label;
}
function updatePreflightList(preflight, robotRos, localization, routeSummary, cameraReady, navigationReady, navigationHealth) {
if (!ui.preflightList) return;
renderPreflightItems(ui.preflightList, buildPreflightItems(preflight, robotRos, localization, routeSummary, cameraReady, navigationReady, navigationHealth));
}
function settleTimeoutText(check) {
if (!check) return "机器狗没有在限定时间内满足位置、朝向和停稳条件。";
const distance = Number(check.distance_m || 0).toFixed(3);
const yaw = Number(check.yaw_error_deg || 0).toFixed(1);
const stable = Number(check.stable_for_s || 0).toFixed(1);
return `距离误差 ${distance}m,朝向误差 ${yaw}deg,停稳 ${stable}s;请调整点位朝向或重新设置初始位姿。`;
}
function missionStateText(goal, run, preflight) {
if (run && run.state === "navigating") return `\u524d\u5f80\u7b2c ${Number(run.current_index || 0) + 1} \u4e2a\u70b9 / ${run.total_count || 0}`;
if (run && run.state === "arrived") return run.status_text || arrivalLabel(run);
if (run && run.state === "correcting") return run.status_text || "位置修正中";
if (run && run.state === "segmenting") return run.status_text || "\u8c03\u7528\u5b9e\u65f6\u8bc6\u522b\u63a5\u53e3";
if (run && run.state === "dwelling") return run.status_text || arrivalLabel(run);
if (run && run.state === "failed") return "\u5de1\u68c0\u5931\u8d25";
if (run && run.state === "completed") return "\u5de1\u68c0\u5b8c\u6210";
if (run && run.state === "canceled") return "\u5de1\u68c0\u5df2\u53d6\u6d88";
if (goal && ["accepted", "executing", "sending"].includes(goal.state)) return `\u76ee\u6807 ${goal.state}`;
if (preflight && preflight.inspection_start_ready) return "\u51c6\u5907\u5c31\u7eea";
return "\u7b49\u5f85";
}
function arrivalLabel(run) {
const current = Number(run && run.current_index || 0) + 1;
return current === 1 ? "\u5df2\u5230\u7b2c 1 \u4e2a\u70b9\uff08\u521d\u59cb\u70b9\uff09" : `\u5df2\u5230\u7b2c ${current} \u4e2a\u70b9`;
}
function dwellStateText(run) {
if (!run) return "\u7b49\u5f85";
if (run.state === "arrived") return "\u5230\u70b9\u786e\u8ba4";
if (run.state === "segmenting") return "\u8c03\u7528\u8bc6\u522b\u63a5\u53e3";
if (run.state === "dwelling") return dwellText(run);
if (run.state === "completed") return "\u505c\u7559\u5b8c\u6210";
if (run.state === "failed") return "\u5df2\u505c\u6b62";
if (run.state === "canceled") return "\u5df2\u53d6\u6d88";
return "\u7b49\u5f85\u5230\u70b9";
}
function dwellText(run) {
if (!run) return "\u7b49\u5f85\u5230\u70b9";
if (run.state === "arrived") return `${arrivalLabel(run)} / \u5230\u70b9\u786e\u8ba4\u4e2d`;
if (run.state === "segmenting") return realtimeSegmentText(run.realtime_segment) || "\u5230\u70b9\u540e\u6b63\u5728\u8c03\u7528\u5b9e\u65f6\u8bc6\u522b\u63a5\u53e3";
if (run.state === "dwelling") {
const total = Number(run.dwell_duration_s || 3);
const elapsed = Math.max(0, Number(run.dwell_elapsed_s || 0));
const remaining = Math.max(0, Number(run.dwell_remaining_s || Math.max(0, total - elapsed)));
return `${arrivalLabel(run)} / \u505c\u7559 ${elapsed.toFixed(1)}s / \u5269\u4f59 ${remaining.toFixed(1)}s`;
}
if (run.state === "completed") {
const count = Number(run.total_count || 0);
return `\u5168\u90e8 ${count} \u4e2a\u70b9\u5df2\u5b8c\u6210`;
}
return "\u7b49\u5f85\u5230\u70b9";
}
function updateDwellProgress(run) {
if (!ui.dwellProgressPanel) return;
const active = Boolean(run && run.state === "dwelling");
ui.dwellProgressPanel.hidden = !active;
if (!active) return;
const total = Number(run.dwell_duration_s || 3);
const elapsed = Math.max(0, Number(run.dwell_elapsed_s || 0));
const remaining = Math.max(0, Number(run.dwell_remaining_s || Math.max(0, total - elapsed)));
const ratio = Math.max(0, Math.min(1, Number(run.dwell_progress_ratio || (total > 0 ? elapsed / total : 1))));
const current = Number(run.current_index || 0) + 1;
const count = Number(run.total_count || 0);
if (ui.dwellProgressTitle) ui.dwellProgressTitle.textContent = `${arrivalLabel(run)} / ${current}/${count}`;
if (ui.dwellProgressTime) ui.dwellProgressTime.textContent = `${elapsed.toFixed(1)}s / ${total.toFixed(1)}s\uff0c\u5269\u4f59 ${remaining.toFixed(1)}s`;
if (ui.dwellProgressBar) ui.dwellProgressBar.style.width = `${Math.round(ratio * 100)}%`;
}
function realtimeSegmentText(segment) {
if (!segment) return "";
const decision = segment.decision ? String(segment.decision) : "";
if (segment.state === "running") return decision || "\u5df2\u5230\u70b9\uff0c\u6b63\u5728\u8c03\u7528\u5b9e\u65f6\u8bc6\u522b\u63a5\u53e3";
const message = segment.message ? String(segment.message) : (segment.ok ? "\u8c03\u7528\u6210\u529f" : "\u8c03\u7528\u5931\u8d25");
const status = segment.http_status ? `HTTP ${segment.http_status}` : "";
const prefix = segment.ok ? "\u6210\u529f" : "\u5931\u8d25";
return [prefix, status, message, decision].filter(Boolean).join(" / ");
}
let _lastSegmentLogState = null;
let _lastSegmentRunId = null;
function updateSegmentConsole(run) {
if (!ui.segmentConsoleLog) return;
if (!run || !run.realtime_segment) {
if (run && run.state === "idle" && _lastSegmentRunId !== null) {
_lastSegmentRunId = null;
_lastSegmentLogState = null;
}
return;
}
const seg = run.realtime_segment;
const runId = run.run_id || 0;
const stateKey = `${runId}_${seg.waypoint_index}_${seg.state}`;
if (stateKey === _lastSegmentLogState) return;
_lastSegmentLogState = stateKey;
if (_lastSegmentRunId !== runId) {
_lastSegmentRunId = runId;
const startLine = tsNow() + " ===== \u5de1\u68c0 #" + runId + " \u5f00\u59cb =====";
ui.segmentConsoleLog.textContent = startLine + "\n";
}
const ts = tsNow();
const wpName = seg.waypoint_display_name || ("waypoint_" + (seg.waypoint_index + 1));
let line = "";
if (seg.state === "running") {
line = ts + " [" + wpName + "] \u8c03\u7528\u4e2d... URL=" + (seg.url || "");
if (ui.segmentConsoleSummary) {
ui.segmentConsoleSummary.textContent = "\u8c03\u7528\u4e2d";
ui.segmentConsoleSummary.className = "status-pill status-warn";
}
} else if (seg.state === "succeeded") {
const dur = seg.duration_s ? seg.duration_s.toFixed(2) + "s" : "";
const httpCode = seg.http_status ? "HTTP " + seg.http_status : "";
line = ts + " [" + wpName + "] \u2705 \u6210\u529f " + [httpCode, dur].filter(Boolean).join(" ");
if (ui.segmentConsoleSummary) {
ui.segmentConsoleSummary.textContent = "\u6700\u8fd1: \u6210\u529f";
ui.segmentConsoleSummary.className = "status-pill status-ok";
}
} else if (seg.state === "failed") {
const msg = seg.message || "\u672a\u77e5\u9519\u8bef";
const httpCode = seg.http_status ? "HTTP " + seg.http_status : "";
line = ts + " [" + wpName + "] \u274c \u5931\u8d25 " + [httpCode, msg].filter(Boolean).join(" | ");
if (ui.segmentConsoleSummary) {
ui.segmentConsoleSummary.textContent = "\u6700\u8fd1: \u5931\u8d25";
ui.segmentConsoleSummary.className = "status-pill status-fail";
}
}
if (line) {
ui.segmentConsoleLog.textContent = line + "\n" + ui.segmentConsoleLog.textContent;
if (ui.segmentConsoleLog.textContent.length > 6000)
ui.segmentConsoleLog.textContent = ui.segmentConsoleLog.textContent.slice(0, 6000);
}
}
function tsNow() {
const d = new Date();
return "[" + String(d.getHours()).padStart(2,"0") + ":" + String(d.getMinutes()).padStart(2,"0") + ":" + String(d.getSeconds()).padStart(2,"0") + "]";
}
function nextStepText(goal, run, localization, preflight) {
if (run && run.state === "arrived") return "\u6b63\u5728\u786e\u8ba4\u4f4d\u7f6e\u3001\u671d\u5411\u3001\u505c\u7a33\u548c\u5b9a\u4f4d\u65b0\u9c9c\u5ea6\u3002";
if (run && run.state === "segmenting") return realtimeSegmentText(run.realtime_segment) || "\u5df2\u5230\u70b9\uff0c\u6b63\u5728\u4e0a\u4f20\u56fe\u7247\u5e76\u8c03\u7528\u5b9e\u65f6\u8bc6\u522b\u63a5\u53e3\u3002";
if (run && run.state === "dwelling") return Number(run.dwell_remaining_s || 0) > 0 ? `\u505c\u7559\u5b8c\u6210\u540e\u81ea\u52a8\u524d\u5f80\u4e0b\u4e00\u4e2a\u70b9\uff0c\u5269\u4f59 ${Number(run.dwell_remaining_s || 0).toFixed(1)} \u79d2\u3002` : "\u505c\u7559\u5b8c\u6210\uff0c\u51c6\u5907\u524d\u5f80\u4e0b\u4e00\u4e2a\u70b9\u3002";
if (run && run.state === "failed") return failureDetailText(run, goal, "\u9700\u8981\u4eba\u5de5\u5904\u7406\u540e\u91cd\u65b0\u5f00\u59cb");
if (run && run.state === "completed") return "\u5de1\u68c0\u5b8c\u6210\u3002";
if (run && run.state === "canceled") return "\u5de1\u68c0\u5df2\u53d6\u6d88\u3002";
if (localization && localization.reason) return operatorReasonText(localization.reason);
if (preflight && preflight.blocking_reasons && preflight.blocking_reasons.length) return operatorReasonText(preflight.blocking_reasons[0]);
if (goal && ["accepted", "executing", "sending"].includes(goal.state)) return "\u7b49\u5f85 Nav2 \u5230\u70b9";
return "\u53ef\u4ee5\u8bbe\u7f6e\u76ee\u6807\u6216\u5f00\u59cb\u5de1\u68c0";
}
function setMissionClass(textEl, value, okLimit, failLimit) {
if (!textEl) return;
const item = textEl.closest(".mission-item");
if (!item) return;
item.classList.remove("ok", "warn", "fail");
if (value === null || value === undefined || Number.isNaN(Number(value))) return;
if (Number(value) <= okLimit) item.classList.add("ok");
else if (Number(value) >= failLimit) item.classList.add("fail");
else item.classList.add("warn");
}
function normalizeYawDeg(value) {
return ((value + 180) % 360 + 360) % 360 - 180;
}
function currentPositionTolerance(check) {
const value = check && Number.isFinite(Number(check.position_tolerance_m)) ? Number(check.position_tolerance_m) : 0.25;
return value > 0 ? value : 0.25;
}
function currentYawTolerance(check) {
const value = check && Number.isFinite(Number(check.yaw_tolerance_deg)) ? Number(check.yaw_tolerance_deg) : 8.0;
return value > 0 ? value : 8.0;
}
function dwellChipText(run) {
if (!run) return "\u505c\u7559 WAIT";
if (run.state === "arrived") return "\u505c\u7559 CHECK";
if (run.state === "segmenting") return "\u8bc6\u522b RUN";
if (run.state === "dwelling") {
const total = Number(run.dwell_duration_s || 3);
const elapsed = Math.max(0, Number(run.dwell_elapsed_s || 0));
return `\u505c\u7559 ${elapsed.toFixed(1)}/${total.toFixed(1)}s`;
}
return "\u505c\u7559 WAIT";
}
function safetyChipText(navigationHealth) {
if (!navigationHealth) return "\u5b89\u5168 WAIT";
return navigationHealth.ok ? "\u5b89\u5168 OK" : "\u5b89\u5168 FAIL";
}
function navigationHealthText(navigationHealth) {
if (!navigationHealth) return "\u7b49\u5f85 /scan + TF + \u5f53\u524d\u4f4d\u59ff";
if (navigationHealth.ok) {
const scan = navigationHealth.scan || {};
const valid = Number(scan.valid_range_count || 0);
const age = navigationHealth.pose_age_s;
const ageText = Number.isFinite(Number(age)) ? ` pose ${Number(age).toFixed(2)}s` : "";
return `/scan ${valid} \u70b9 / TF OK${ageText}`;
}
const reasons = Array.isArray(navigationHealth.blocking_reasons) ? navigationHealth.blocking_reasons : [];
return operatorReasonText(reasons[0] || navigationHealth.summary || "/scan\u3001TF \u6216\u5f53\u524d\u4f4d\u59ff\u5f02\u5e38");
}
function networkDetailText(network) {
if (!network) return "\u7b49\u5f85\u7f51\u7edc\u94fe\u8def";
if (network.blocking_error) return operatorReasonText(network.blocking_error);
const selected = network.selected_iface || network.ros_iface || "-";
const primary = network.primary_iface || network.requested_ros_iface || "-";
if (network.fallback_active) {
return `\u65e0\u7ebf ${primary} \u4e0d\u53ef\u7528\uff0c\u5df2\u5207\u5230\u6709\u7ebf ${selected} \u515c\u5e95`;
}
if (network.selection_reason) {
return operatorReasonText(network.selection_reason);
}
if (network.warning) {
return operatorReasonText(network.warning);
}
return `${selected} / \u6b63\u5e38`;
}
function localizationChipText(localization) {
if (!localization || !localization.pose_fresh) return "Loc WAIT";
if (localization.confirmed) return "Loc OK";
return "Loc CHECK";
}
function localizationText(localization, preflight) {
if (!localization) return "等待定位";
if (localization.confirmed) {
const pose = localization.confirmation && localization.confirmation.pose ? localization.confirmation.pose : null;
return pose ? `已确认 x=${pose.x.toFixed(2)} y=${pose.y.toFixed(2)} yaw=${pose.yaw_deg.toFixed(1)}°` : "已确认";
}
if (localization.reason) return operatorReasonText(localization.reason);
if (preflight && preflight.blocking_reasons && preflight.blocking_reasons.length) return operatorReasonText(preflight.blocking_reasons[0]);
return "需要在地图上核对真实位置和朝向";
}
function failureCategoryLabel(category) {
const text = String(category || "").trim();
const labels = {
nav2_controller_stalled: "NAV2 STALL",
nav2_no_valid_trajectory: "NO TRAJ",
tf_time_sync: "TF TIME",
robot_ros_link: "ROS LINK",
navigation_health: "DATA STALE",
nav2_goal_aborted: "NAV2 ABORT",
nav2_goal_failed: "NAV2 FAIL",
};
return labels[text] || "ROUTE FAIL";
}
function failureDetailText(run, goal, fallback) {
const source = run || {};
const diagnostics = source.diagnostics || (goal && goal.diagnostics) || {};
const detail = source.error_detail || diagnostics.message || (goal && goal.error_detail) || "";
const nextStep = source.failure_next_step || diagnostics.next_step || (goal && goal.failure_next_step) || "";
const reason = source.error || fallback || "";
const pieces = [];
if (detail) pieces.push(operatorReasonText(detail));
else if (reason) pieces.push(operatorReasonText(reason));
if (nextStep) pieces.push(operatorReasonText(nextStep));
return pieces.filter(Boolean).join(" ");
}
function operatorReasonText(reason) {
const text = String(reason || "").trim();
if (!text) return "";
if (/Failed to make progress/i.test(text) || /控制器无法继续推进/.test(text)) {
return "Nav2 控制器无法继续推进,已中止本次目标;优先检查局部代价地图、障碍层、目标点附近是否被占用,以及 TF/scan 时间戳是否在丢帧。";
}
if (/No valid trajectories/i.test(text) || /没有找到可用轨迹|局部规划器没有找到可用轨迹/.test(text)) {
return "局部规划器没有可用轨迹;通常是障碍层/膨胀层把路径封住,或目标点太贴近障碍。";
}
if (/Message Filter dropping message|Extrapolation|extrapolation|Failed to transform|时间戳不同步|TF\/传感器时间戳/.test(text)) {
return "TF 或传感器时间戳不同步,Nav2 正在丢弃 scan/costmap 消息;检查 map->odom、odom->base_link、base_link->utlidar_lidar 和 /scan stamp。";
}
if (/scan.*过期|未收到 \/scan|\/scan .*stale|scan.*fresh/i.test(text)) {
return "/scan 不新鲜或未收到,局部避障不可用;检查雷达、pointcloud_to_laserscan 和 DDS 网络。";
}
if (/map->base TF.*过期|未收到 map->base TF|TF .*stale/i.test(text)) {
return "map->base TF 不新鲜,当前位置不可信;检查 odom->base_link、map->odom 和 TF 发布节点。";
}
if (/robot ros|机器人 ROS|网线|ens160|DDS|source data/i.test(text)) {
return "机器人 ROS 链路异常或数据不新鲜;检查网线/ens160、DDS、机器狗源话题和 /api/sport/request 订阅者。";
}
if (/Goal failed|Aborting handle|Nav2 .*中止|Nav2 .*aborted/i.test(text)) {
return "Nav2 action 已中止;查看控制器、规划器和 costmap 日志摘要定位具体原因。";
}
if (text.includes("\u5bfc\u822a\u5931\u8d25") || text.includes("\u5bfc\u822a\u8d85\u65f6") || /navigation ended/i.test(text)) {
return "\u5de1\u68c0\u70b9\u5bfc\u822a\u672a\u6210\u529f\uff0c\u5df2\u505c\u6b62\u7b49\u5f85\u4eba\u5de5\u5904\u7406\u3002";
}
if (
text.includes("\u5230\u70b9\u786e\u8ba4\u8d85\u65f6") ||
text.includes("\u4f4d\u7f6e\u504f\u5dee\u8d85\u51fa\u9608\u503c") ||
text.includes("\u671d\u5411\u504f\u5dee\u8d85\u51fa\u9608\u503c") ||
text.includes("\u5b9a\u4f4d\u6570\u636e\u5df2\u8fc7\u671f") ||
text.includes("\u673a\u5668\u4eba\u5c1a\u672a\u505c\u7a33")
) {
return "\u5230\u70b9\u786e\u8ba4\u672a\u901a\u8fc7\uff0c\u5df2\u505c\u6b62\u5de1\u68c0\u3002";
}
if (text.includes("\u505c\u7559\u5931\u8d25") || text.includes("\u505c\u7559\u6761\u4ef6\u4e0d\u6ee1\u8db3")) {
return "\u505c\u7559\u671f\u95f4\u6761\u4ef6\u5931\u6548\uff0c\u5df2\u505c\u6b62\u5de1\u68c0\u3002";
}
if (text.includes("\u5df2\u624b\u52a8\u505c\u6b62\u5de1\u68c0")) return "\u5df2\u6267\u884c\u4eba\u5de5\u505c\u6b62\u3002";
const rules = [
[/Nav2 launch is not running/i, "Nav2 \u672a\u8fd0\u884c\uff0c\u8bf7\u5148\u542f\u52a8\u5bfc\u822a\u3002"],
[/Nav2 .*action server is not ready/i, "Nav2 action server \u672a\u5c31\u7eea\uff0c\u7b49\u5f85\u5bfc\u822a\u542f\u52a8\u5b8c\u6210\u3002"],
[/Missing fresh robot source data:(.*)/i, "\u673a\u5668\u72d7 ROS \u6e90\u6570\u636e\u4e0d\u65b0\u9c9c\uff0c\u68c0\u67e5\u673a\u5668\u4eba\u8fde\u63a5\u548c\u9a71\u52a8\u8bdd\u9898\u3002"],
[/Missing robot command subscriber/i, "\u7f3a\u5c11\u673a\u5668\u72d7\u901f\u5ea6\u6307\u4ee4\u8ba2\u9605\u8005\uff0c\u6682\u65f6\u4e0d\u8981\u53d1\u9001\u5bfc\u822a\u76ee\u6807\u3002"],
[/No current pose in map frame/i, "\u5f53\u524d\u6ca1\u6709 map \u4f4d\u59ff\uff0c\u8bf7\u5148\u5728\u5730\u56fe\u4e2d\u8bbe\u7f6e\u521d\u59cb\u4f4d\u59ff\u3002"],
[/Current map pose is stale/i, "\u5f53\u524d map \u4f4d\u59ff\u5df2\u8fc7\u671f\uff0c\u8bf7\u91cd\u65b0\u8bbe\u7f6e\u521d\u59cb\u4f4d\u59ff\u3002"],
[/Map is unavailable/i, "\u5f53\u524d\u6ca1\u6709\u5730\u56fe\u6570\u636e\uff0c\u8bf7\u786e\u8ba4 Nav2 \u4f7f\u7528\u7684\u662f\u5df2\u4fdd\u5b58\u5730\u56fe\u3002"],
[/Operator must confirm/i, "\u8bf7\u6838\u5bf9\u673a\u5668\u72d7\u771f\u5b9e\u4f4d\u7f6e\u548c\u671d\u5411\uff0c\u7136\u540e\u70b9\u51fb\u786e\u8ba4\u5b9a\u4f4d\u3002"],
[/Map changed after localization confirmation/i, "\u786e\u8ba4\u5b9a\u4f4d\u540e\u5730\u56fe\u6587\u4ef6\u53d8\u5316\uff0c\u9700\u8981\u91cd\u65b0\u786e\u8ba4\u5b9a\u4f4d\u3002"],
[/Map revision changed after localization confirmation/i, "\u786e\u8ba4\u5b9a\u4f4d\u540e\u5730\u56fe revision \u53d8\u5316\uff0c\u9700\u8981\u91cd\u65b0\u786e\u8ba4\u5b9a\u4f4d\u3002"],
[/Nav2 was restarted after localization confirmation/i, "Nav2 \u91cd\u542f\u540e\u9700\u8981\u91cd\u65b0\u786e\u8ba4\u5b9a\u4f4d\u3002"],
[/Localization confirmation expired/i, "\u5b9a\u4f4d\u786e\u8ba4\u5df2\u8fc7\u671f\uff0c\u9700\u8981\u91cd\u65b0\u786e\u8ba4\u5b9a\u4f4d\u3002"],
[/Robot moved after localization confirmation/i, "\u786e\u8ba4\u5b9a\u4f4d\u540e\u673a\u5668\u72d7\u53d1\u751f\u79fb\u52a8\uff0c\u9700\u8981\u91cd\u65b0\u786e\u8ba4\u5b9a\u4f4d\u3002"],
[/Robot yaw changed after localization confirmation/i, "\u786e\u8ba4\u5b9a\u4f4d\u540e\u673a\u5668\u72d7\u671d\u5411\u53d8\u5316\uff0c\u9700\u8981\u91cd\u65b0\u786e\u8ba4\u5b9a\u4f4d\u3002"],
[/Manual stop requested/i, "\u5df2\u6267\u884c\u4eba\u5de5\u505c\u6b62\u3002"],
[/mapping_requires_wired:/i, "\u5efa\u56fe\u6216\u4fdd\u5b58\u5730\u56fe\u8981\u6c42\u4f7f\u7528\u6709\u7ebf\u8fde\u63a5\uff0c\u8bf7\u5148\u63d2\u597d\u6709\u7ebf\u5e76\u786e\u8ba4 ens160 \u5df2\u83b7\u53d6\u5730\u5740\u3002"],
[/robot_ros_network_unavailable:/i, "\u65e0\u7ebf\u4e0e\u6709\u7ebf\u90fd\u4e0d\u53ef\u7528\uff0c\u673a\u5668\u4eba ROS \u94fe\u8def\u672a\u5efa\u7acb\uff0c\u7981\u6b62\u542f\u52a8\u5bfc\u822a\u6216\u5de1\u68c0\u3002"],
[/wireless_unavailable_using_wired_fallback:/i, "\u65e0\u7ebf\u4e0d\u53ef\u7528\uff0c\u7cfb\u7edf\u5df2\u81ea\u52a8\u5207\u6362\u4e3a\u6709\u7ebf\u515c\u5e95\u3002"],
[/wireless_selected:/i, "\u5f53\u524d\u5df2\u4f7f\u7528\u65e0\u7ebf\u94fe\u8def\u3002"],
];
for (const [pattern, replacement] of rules) {
if (pattern.test(text)) return replacement;
}
return text;
}
function inspectionRunActive(run) {
return Boolean(run && ["starting", "navigating", "arrived", "segmenting", "dwelling", "canceling"].includes(run.state));
}
function routeChipText(run, summary) {
if (inspectionRunActive(run)) return `Route ${run.current_index + 1 || 1}/${run.total_count || 0}`;
if (run && run.state === "completed") return "Route OK";
if (run && run.state === "failed") return "Route FAIL";
if (run && run.state === "canceled") return "Route STOP";
const count = summary ? summary.enabled_waypoint_count || 0 : 0;
return count > 0 ? `Route ${count}` : "Route EMPTY";
}
function routeProgressText(run, summary) {
if (inspectionRunActive(run)) {
const current = Number(run.current_index || 0) + 1;
const total = Number(run.total_count || 0);
const waypoint = run.current_waypoint && run.current_waypoint.display_name ? ` / ${run.current_waypoint.display_name}` : "";
if (run.state === "arrived" || run.state === "segmenting" || run.state === "dwelling") return `${run.status_text || arrivalLabel(run)} / ${dwellText(run)}`;
return `${run.status_text || run.state} ${current}/${total}${waypoint}`;
}
if (run && run.state === "completed") return `\u5df2\u5b8c\u6210 ${run.total_count || 0} \u70b9`;
if (run && run.state === "failed") return `\u505c\u6b62\u7b49\u5f85 / ${failureDetailText(run, state && state.goal, "failed")}`;
if (run && run.state === "canceled") return "\u5df2\u53d6\u6d88";
if (summary && summary.map_match === false && (summary.enabled_waypoint_count || 0) > 0) return "\u5730\u56fe\u5df2\u53d8\u5316\uff0c\u9700\u8981\u91cd\u65b0\u4fdd\u5b58";
return summary ? `${summary.enabled_waypoint_count || 0} \u4e2a\u542f\u7528\u70b9` : "\u7b49\u5f85\u8def\u7ebf";
}
function networkText(network) {
if (!network) return "\u7b49\u5f85";
if (network.blocking_error) return `\u963b\u65ad ${network.selected_iface || network.ros_iface || "-"}`;
if (network.fallback_active) return `\u515c\u5e95 ${network.selected_iface || network.ros_iface || "-"}`;
if (network.warning) return `\u8bca\u65ad ${network.selected_iface || network.ros_iface || "-"}`;
return network.selected_iface || network.ros_iface || "\u6b63\u5e38";
}
function poseSourceText(currentState) {
if (!currentState || !currentState.pose) return "等待";
const source = currentState.pose.raw_source || currentState.pose.source || "pose";
const age = Number(currentState.pose_age_s || 0);
return `${source} / ${age.toFixed(2)}s`;
}
function updateDiagnostics() {
if (!ui.consoleLog || !state) return;
const goal = state.goal || {};
const run = state.inspection_run || {};
const localization = state.localization || {};
const preflight = state.preflight || { blocking_reasons: [] };
const check = run.arrival_check || run.navigation_check || goal.xy_fallback || {};
const target = run.current_waypoint || goal.target || {};
const signature = [
goal.state,
run.state,
run.error,
run.error_detail,
run.failure_category,
run.failure_next_step,
run.diagnostics ? JSON.stringify(run.diagnostics) : "",
goal.error_detail,
goal.failure_category,
run.realtime_segment ? run.realtime_segment.state : "",
run.realtime_segment ? run.realtime_segment.message : "",
run.realtime_segment ? run.realtime_segment.decision : "",
check.ready,
check.distance_m,
check.yaw_error_deg,
check.xy_fallback_triggered,
check.yaw_bypassed,
goal.xy_fallback ? JSON.stringify(goal.xy_fallback) : "",
localization.state,
localization.reason,
inspectionRunActive(run) ? "" : (preflight.blocking_reasons || []).join("|"),
].join("::");
if (signature !== lastDiagnosticSignature) {
lastDiagnosticSignature = signature;
const now = new Date().toLocaleTimeString("zh-CN", { hour12: false });
const lines = [];
lines.push(`[${now}] goal=${goal.state || "idle"} route=${run.state || "idle"}`);
if (target && Number.isFinite(Number(target.x))) {
lines.push(` target x=${Number(target.x).toFixed(2)} y=${Number(target.y).toFixed(2)} yaw=${Number(target.yaw_deg).toFixed(1)}deg`);
}
if (state.pose) {
lines.push(` pose x=${Number(state.pose.x).toFixed(2)} y=${Number(state.pose.y).toFixed(2)} yaw=${Number(state.pose.yaw_deg).toFixed(1)}deg age=${Number(state.pose_age_s || 0).toFixed(2)}s`);
}
if (goal.xy_fallback) {
const fallback = goal.xy_fallback;
lines.push(
` xy_fallback dist=${Number(fallback.distance_m || 0).toFixed(3)}m yaw_err=${Number(fallback.yaw_error_deg || 0).toFixed(1)}deg ` +
`stable=${Number(fallback.stable_for_s || 0).toFixed(1)}s`
);
}
if (check.yaw_bypassed) {
lines.push(` yaw_bypassed yaw_err=${Number(check.yaw_error_deg || 0).toFixed(1)}deg position_stable=${Number(check.position_stable_for_s || 0).toFixed(1)}s`);
}
if (run.realtime_segment) lines.push(` realtime_segment ${realtimeSegmentText(run.realtime_segment)}`);
const terminalRoute = Boolean(run && ["failed", "completed", "canceled"].includes(run.state));
if (run.error || run.error_detail) lines.push(` route_error ${failureDetailText(run, goal, run.error || "failed")}`);
const diagnostics = run.diagnostics || goal.diagnostics || {};
if (diagnostics.category) lines.push(` failure_category ${diagnostics.category}`);
if (diagnostics.navigation_health_summary) lines.push(` navigation_health ${operatorReasonText(diagnostics.navigation_health_summary)}`);
if (Array.isArray(diagnostics.nav_log_lines) && diagnostics.nav_log_lines.length) {
lines.push(" nav2_log");
for (const line of diagnostics.nav_log_lines.slice(-6)) lines.push(` ${line}`);
}
if (!inspectionRunActive(run) && !terminalRoute && localization.reason) lines.push(` localization ${operatorReasonText(localization.reason)}`);
if (!inspectionRunActive(run) && !terminalRoute && preflight.blocking_reasons && preflight.blocking_reasons.length) {
lines.push(` preflight ${preflight.blocking_reasons.map(operatorReasonText).join(" ; ")}`);
}
if (state.ros && state.map_yaml) {
lines.push(` nav_cmd ros2 launch go2_navigation2 go2_nav2.launch.py map:=${state.map_yaml}`);
}
diagnosticLines.push(lines.join("\n"));
if (diagnosticLines.length > 80) diagnosticLines = diagnosticLines.slice(-80);
}
const lastBlock = diagnosticLines[diagnosticLines.length - 1] || "";
if (ui.consoleSummary) ui.consoleSummary.textContent = consoleSummaryText(state, lastBlock);
ui.consoleLog.textContent = diagnosticLines.join("\n\n");
ui.consoleLog.scrollTop = ui.consoleLog.scrollHeight;
}
function consoleSummaryText(currentState, lastBlock) {
const run = currentState.inspection_run || {};
const preflight = currentState.preflight || {};
if (run.state === "failed") return failureDetailText(run, currentState.goal || {}, "\u5de1\u68c0\u5931\u8d25");
if (run.state === "segmenting") return "\u8c03\u7528\u5b9e\u65f6\u8bc6\u522b";
if (run.state === "dwelling") return dwellText(run);
if (preflight.blocking_reasons && preflight.blocking_reasons.length) return operatorReasonText(preflight.blocking_reasons[0]);
if (lastBlock) return "\u5b9e\u65f6\u8bca\u65ad";
return "\u7b49\u5f85\u72b6\u6001";
}
function compactPath(value) {
if (!value) return "等待";
const text = String(value);
if (text.length <= 46) return text;
return `...${text.slice(-43)}`;
}
function mapQualityText(grid) {
if (!grid || !Array.isArray(grid.cells) || !grid.cells.length) return "等待 /map";
let free = 0;
let occupied = 0;
let unknown = 0;
for (const cell of grid.cells) {
if (cell < 0) unknown += 1;
else if (cell >= 65) occupied += 1;
else free += 1;
}
const total = Math.max(1, grid.cells.length);
const freePct = (free / total) * 100;
const occupiedPct = (occupied / total) * 100;
const unknownPct = (unknown / total) * 100;
const warning = occupiedPct > 18 ? "散点偏高" : unknownPct > 72 ? "未知区偏高" : "正常";
return `${warning} / 可通行 ${freePct.toFixed(0)}% / 未知 ${unknownPct.toFixed(0)}% / 占用 ${occupiedPct.toFixed(0)}%`;
}
function renderRouteEditor() {
if (!ui.routeList) return;
const route = ensureEditableRoute();
const waypoints = Array.isArray(route.waypoints) ? route.waypoints : [];
const run = state && state.inspection_run ? state.inspection_run : { state: "idle" };
const active = inspectionRunActive(run);
ui.routeList.replaceChildren();
if (!waypoints.length) {
const empty = document.createElement("div");
empty.className = "route-empty";
empty.textContent = "暂无巡检点";
ui.routeList.append(empty);
return;
}
for (const [index, waypoint] of waypoints.entries()) {
const row = document.createElement("div");
row.className = "route-item";
if (active && run.current_index === index) row.classList.add("active");
const badge = document.createElement("div");
badge.className = "route-index";
badge.textContent = String(index + 1);
const input = document.createElement("input");
input.value = waypoint.display_name || `巡检点 ${index + 1}`;
input.disabled = active;
input.addEventListener("change", () => {
waypoint.display_name = input.value.trim() || `巡检点 ${index + 1}`;
markRouteDirty();
});
const actions = document.createElement("div");
actions.className = "route-item-actions";
const up = routeActionButton("↑", active || index === 0, () => moveWaypoint(index, -1));
const down = routeActionButton("↓", active || index === waypoints.length - 1, () => moveWaypoint(index, 1));
const del = routeActionButton("删", active, () => deleteWaypoint(index));
actions.append(up, down, del);
row.append(badge, input, actions);
ui.routeList.append(row);
}
}
function routeActionButton(text, disabled, handler) {
const button = document.createElement("button");
button.type = "button";
button.textContent = text;
button.disabled = disabled;
button.addEventListener("click", handler);
return button;
}
function ensureEditableRoute() {
if (!inspectionRoute) {
inspectionRoute = {
name: "inspectionRoutes/default",
display_name: "默认巡检路线",
waypoints: [],
};
}
if (!Array.isArray(inspectionRoute.waypoints)) inspectionRoute.waypoints = [];
return inspectionRoute;
}
function cloneInspectionRoute(route) {
return JSON.parse(JSON.stringify(route));
}
function markRouteDirty() {
routeDirty = true;
if (ui.messageText) ui.messageText.textContent = "路线未保存";
renderState();
draw();
}
function makeWaypointId() {
return `wp_${Date.now().toString(36)}_${Math.floor(Math.random() * 1000).toString(36)}`;
}
function addRouteWaypoint(pose) {
const route = ensureEditableRoute();
route.display_name = ui.routeNameInput ? ui.routeNameInput.value.trim() || "默认巡检路线" : route.display_name || "默认巡检路线";
route.waypoints.push({
id: makeWaypointId(),
display_name: `巡检点 ${route.waypoints.length + 1}`,
x: pose.x,
y: pose.y,
yaw_deg: pose.yaw_deg,
frame_id: "map",
enabled: true,
});
markRouteDirty();
renderRouteEditor();
}
function moveWaypoint(index, delta) {
const route = ensureEditableRoute();
const nextIndex = index + delta;
if (nextIndex < 0 || nextIndex >= route.waypoints.length) return;
const [item] = route.waypoints.splice(index, 1);
route.waypoints.splice(nextIndex, 0, item);
markRouteDirty();
renderRouteEditor();
}
async function deleteWaypoint(index) {
const route = ensureEditableRoute();
if (index < 0 || index >= route.waypoints.length) return;
const previousRoute = cloneInspectionRoute(route);
const [removed] = route.waypoints.splice(index, 1);
const removedName = removed && removed.display_name ? removed.display_name : `巡检点 ${index + 1}`;
routeDirty = true;
if (ui.messageText) ui.messageText.textContent = `正在删除 ${removedName}`;
renderRouteEditor();
renderState();
draw();
setBusy(true);
try {
await saveRoute();
if (ui.messageText) ui.messageText.textContent = `已删除 ${removedName}`;
} catch (error) {
if (routeDirty) {
inspectionRoute = previousRoute;
routeDirty = false;
if (ui.routeNameInput) ui.routeNameInput.value = previousRoute.display_name || "默认巡检路线";
renderRouteEditor();
renderState();
draw();
if (ui.messageText) ui.messageText.textContent = `删除失败:${error.message}`;
} else if (ui.messageText) {
ui.messageText.textContent = `已删除 ${removedName},但刷新状态失败:${error.message}`;
}
} finally {
setBusy(false);
renderState();
}
}
function setChip(el, ok, text) {
if (!el) return;
el.textContent = text;
el.classList.toggle("ok", Boolean(ok));
el.classList.toggle("warn", !ok);
}
function createMapBitmap(grid) {
const image = ctx.createImageData(grid.width, grid.height);
for (let y = 0; y < grid.height; y += 1) {
const sourceY = grid.height - 1 - y;
for (let x = 0; x < grid.width; x += 1) {
const occ = grid.cells[sourceY * grid.width + x];
const offset = (y * grid.width + x) * 4;
let color;
if (occ < 0) color = [29, 42, 56];
else if (occ >= 65) color = [7, 11, 18];
else color = [213, 233, 237];
image.data[offset] = color[0];
image.data[offset + 1] = color[1];
image.data[offset + 2] = color[2];
image.data[offset + 3] = 255;
}
}
const offscreen = document.createElement("canvas");
offscreen.width = grid.width;
offscreen.height = grid.height;
offscreen.getContext("2d").putImageData(image, 0, 0);
return offscreen;
}
function computeTransform() {
if (!mapGrid) return null;
const padding = 8;
const mapW = mapGrid.width * mapGrid.resolution;
const mapH = mapGrid.height * mapGrid.resolution;
const scale = Math.min((ui.canvas.width - padding * 2) / mapW, (ui.canvas.height - padding * 2) / mapH);
return {
scale,
left: (ui.canvas.width - mapW * scale) / 2,
top: (ui.canvas.height - mapH * scale) / 2,
mapW,
mapH,
};
}
function worldToCanvas(point) {
if (!transform || !mapGrid) return null;
const mx = point.x - mapGrid.origin.x;
const my = point.y - mapGrid.origin.y;
return {
x: transform.left + mx * transform.scale,
y: transform.top + (transform.mapH - my) * transform.scale,
};
}
function canvasToWorld(event) {
if (!transform || !mapGrid) return null;
const rect = ui.canvas.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * ui.canvas.width;
const y = ((event.clientY - rect.top) / rect.height) * ui.canvas.height;
const mx = (x - transform.left) / transform.scale;
const my = transform.mapH - (y - transform.top) / transform.scale;
return { x: mapGrid.origin.x + mx, y: mapGrid.origin.y + my };
}
function draw() {
syncCanvasBufferSize();
ctx.clearRect(0, 0, ui.canvas.width, ui.canvas.height);
transform = computeTransform();
drawGridBackground();
if (!mapGrid || !mapBitmap || !transform) return;
ctx.save();
ctx.imageSmoothingEnabled = false;
ctx.shadowColor = "rgba(38, 215, 255, 0.24)";
ctx.shadowBlur = 18;
ctx.drawImage(
mapBitmap,
transform.left,
transform.top,
transform.mapW * transform.scale,
transform.mapH * transform.scale
);
ctx.shadowBlur = 0;
ctx.strokeStyle = "rgba(102, 232, 255, 0.68)";
ctx.lineWidth = 2;
ctx.strokeRect(transform.left, transform.top, transform.mapW * transform.scale, transform.mapH * transform.scale);
ctx.restore();
drawMapOverlay();
drawPoseTrail();
drawRouteOverlay();
if (state && state.pose) drawRobot(state.pose);
if (state && state.goal && state.goal.target) drawPoseArrow(state.goal.target, "#20e3b2", 26);
if (dragStart && dragPreview) drawDragPreview();
drawScaleBar();
}
function clearPoseTrail() {
poseTrail = [];
if (ui.messageText) ui.messageText.textContent = "\u8def\u7ebf\u8f68\u8ff9\u5df2\u6e05\u9664";
renderState();
draw();
}
function updatePoseTrail() {
if (!state || !state.pose) return;
const pose = {
x: state.pose.x,
y: state.pose.y,
yaw_deg: state.pose.yaw_deg,
t: state.pose.received_at_unix_s || state.server_time_unix_s || Date.now() / 1000,
};
const last = poseTrail[poseTrail.length - 1];
if (!last || Math.hypot(pose.x - last.x, pose.y - last.y) > 0.015 || Math.abs(pose.yaw_deg - last.yaw_deg) > 1.5) {
poseTrail.push(pose);
if (poseTrail.length > 160) poseTrail = poseTrail.slice(-160);
}
}
function drawPoseTrail() {
if (!poseTrail.length) return;
ctx.save();
ctx.strokeStyle = "rgba(32, 227, 178, 0.58)";
ctx.lineWidth = 3;
ctx.beginPath();
let started = false;
for (const pose of poseTrail) {
const p = worldToCanvas(pose);
if (!p) continue;
if (!started) {
ctx.moveTo(p.x, p.y);
started = true;
} else {
ctx.lineTo(p.x, p.y);
}
}
ctx.stroke();
ctx.restore();
}
function drawRouteOverlay() {
const route = ensureEditableRoute();
const waypoints = route.waypoints.filter((item) => item && item.enabled !== false);
if (!waypoints.length) return;
const run = state && state.inspection_run ? state.inspection_run : { state: "idle" };
ctx.save();
ctx.lineWidth = 3;
ctx.strokeStyle = "rgba(64, 217, 255, 0.68)";
ctx.setLineDash([10, 8]);
ctx.beginPath();
let started = false;
for (const waypoint of waypoints) {
const point = worldToCanvas(waypoint);
if (!point) continue;
if (!started) {
ctx.moveTo(point.x, point.y);
started = true;
} else {
ctx.lineTo(point.x, point.y);
}
}
ctx.stroke();
ctx.setLineDash([]);
for (const [index, waypoint] of waypoints.entries()) {
const point = worldToCanvas(waypoint);
if (!point) continue;
const active = inspectionRunActive(run) && run.current_waypoint && run.current_waypoint.id === waypoint.id;
ctx.fillStyle = active ? "#25e0b0" : "#07101d";
ctx.strokeStyle = active ? "#b8fff1" : "#40d9ff";
ctx.lineWidth = active ? 4 : 3;
ctx.beginPath();
ctx.arc(point.x, point.y, active ? 14 : 12, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = active ? "#041018" : "#ecf8fb";
ctx.font = "900 13px Segoe UI, Microsoft YaHei, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(index + 1), point.x, point.y + 0.5);
drawPoseArrow(waypoint, active ? "#25e0b0" : "#40d9ff", active ? 34 : 28);
}
ctx.restore();
}
function drawGridBackground() {
ctx.save();
ctx.fillStyle = "#07101d";
ctx.fillRect(0, 0, ui.canvas.width, ui.canvas.height);
ctx.strokeStyle = "rgba(86, 132, 168, 0.13)";
ctx.lineWidth = 1;
for (let x = 0; x < ui.canvas.width; x += 42) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, ui.canvas.height);
ctx.stroke();
}
for (let y = 0; y < ui.canvas.height; y += 42) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(ui.canvas.width, y);
ctx.stroke();
}
ctx.restore();
}
function drawMapOverlay() {
if (!transform || !mapGrid) return;
ctx.save();
const mapLeft = transform.left;
const mapTop = transform.top;
const mapRight = transform.left + transform.mapW * transform.scale;
const mapBottom = transform.top + transform.mapH * transform.scale;
ctx.strokeStyle = "rgba(64, 217, 255, 0.18)";
ctx.lineWidth = 1;
const meterStep = transform.scale < 42 ? 2 : 1;
for (let x = Math.ceil(mapGrid.origin.x / meterStep) * meterStep; x <= mapGrid.origin.x + transform.mapW; x += meterStep) {
const p = worldToCanvas({ x, y: mapGrid.origin.y });
if (!p || p.x < mapLeft || p.x > mapRight) continue;
ctx.beginPath();
ctx.moveTo(p.x, mapTop);
ctx.lineTo(p.x, mapBottom);
ctx.stroke();
}
for (let y = Math.ceil(mapGrid.origin.y / meterStep) * meterStep; y <= mapGrid.origin.y + transform.mapH; y += meterStep) {
const p = worldToCanvas({ x: mapGrid.origin.x, y });
if (!p || p.y < mapTop || p.y > mapBottom) continue;
ctx.beginPath();
ctx.moveTo(mapLeft, p.y);
ctx.lineTo(mapRight, p.y);
ctx.stroke();
}
ctx.restore();
}
function drawRobot(pose) {
const p = worldToCanvas(pose);
if (!p) return;
const yaw = (pose.yaw_deg * Math.PI) / 180;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(-yaw);
ctx.shadowColor = "rgba(91, 141, 255, 0.56)";
ctx.shadowBlur = 18;
ctx.fillStyle = "rgba(91, 141, 255, 0.22)";
ctx.beginPath();
ctx.moveTo(34, 0);
ctx.lineTo(0, -20);
ctx.lineTo(0, 20);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = "#5b8dff";
ctx.strokeStyle = "#e8fbff";
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(24, 0);
ctx.lineTo(-15, -12);
ctx.lineTo(-9, 0);
ctx.lineTo(-15, 12);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#25e0b0";
ctx.beginPath();
ctx.arc(0, 0, 4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawPoseArrow(pose, color, length) {
const p = worldToCanvas(pose);
if (!p) return;
const yaw = (pose.yaw_deg * Math.PI) / 180;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(-yaw);
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(length, 0);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(length + 10, 0);
ctx.lineTo(length - 3, -7);
ctx.lineTo(length - 3, 7);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawDragPreview() {
const a = worldToCanvas(dragStart);
const b = worldToCanvas(dragPreview);
if (!a || !b) return;
ctx.save();
ctx.strokeStyle = tool === "goal" ? "#20e3b2" : "#f59e0b";
ctx.fillStyle = ctx.strokeStyle;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
ctx.beginPath();
ctx.arc(a.x, a.y, 6, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawScaleBar() {
if (!transform || !mapGrid) return;
const meters = transform.scale < 45 ? 2 : 1;
const width = meters * transform.scale;
const x = transform.left + 18;
const y = transform.top + transform.mapH * transform.scale - 24;
ctx.save();
ctx.strokeStyle = "rgba(236, 248, 251, 0.82)";
ctx.fillStyle = "rgba(236, 248, 251, 0.88)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + width, y);
ctx.stroke();
ctx.font = "700 13px Segoe UI, Microsoft YaHei, sans-serif";
ctx.fillText(`${meters}m`, x + width + 8, y + 4);
ctx.restore();
}
function setTool(nextTool) {
tool = tool === nextTool ? "view" : nextTool;
dragStart = null;
dragPreview = null;
renderState();
draw();
}
ui.canvas.addEventListener("pointerdown", (event) => {
if (tool === "view" || !mapGrid || busy) return;
dragStart = canvasToWorld(event);
dragPreview = dragStart;
ui.canvas.setPointerCapture(event.pointerId);
draw();
});
ui.canvas.addEventListener("pointermove", (event) => {
if (!dragStart) return;
dragPreview = canvasToWorld(event);
draw();
});
ui.canvas.addEventListener("pointerup", (event) => {
dragStart = null;
dragPreview = null;
draw();
});
async function sendPose(url, pose) {
setBusy(true);
try {
const payload = await requestJson("POST", url, pose);
ui.messageText.textContent = payload.name || "done";
tool = "view";
await refresh();
} catch (error) {
ui.messageText.textContent = error.message;
} finally {
setBusy(false);
renderState();
}
}
async function saveRoute() {
const route = ensureEditableRoute();
route.display_name = ui.routeNameInput ? ui.routeNameInput.value.trim() || "默认巡检路线" : route.display_name || "默认巡检路线";
const payload = await requestJson("PATCH", "/api/inspection-routes/default?update_mask=display_name,waypoints", {
display_name: route.display_name,
waypoints: route.waypoints,
});
inspectionRoute = payload;
routeDirty = false;
if (ui.routeNameInput) ui.routeNameInput.value = payload.display_name || "默认巡检路线";
ui.messageText.textContent = "路线已保存";
renderRouteEditor();
await refresh();
}
async function runInspectionStartChecks() {
setLaunchCheck("running", "\u68c0\u67e5\u4e2d", currentPreflightItems());
ui.messageText.textContent = "\u6b63\u5728\u6267\u884c\u5de1\u68c0\u524d\u7f6e\u68c0\u67e5";
for (let attempt = 0; attempt < 10; attempt += 1) {
await refresh();
const items = currentPreflightItems();
const preflight = state && state.preflight ? state.preflight : { inspection_start_ready: false, blocking_reasons: [] };
const ready = Boolean(preflight.inspection_start_ready) && items.every((item) => item.ok || item.warn);
setLaunchCheck(ready ? "ok" : "running", ready ? "\u53ef\u542f\u52a8" : `\u68c0\u67e5 ${attempt + 1}/10`, items);
if (ready) return true;
await new Promise((resolve) => window.setTimeout(resolve, 300));
}
await refresh();
const items = currentPreflightItems();
const preflight = state && state.preflight ? state.preflight : { blocking_reasons: [] };
const reason = preflight.blocking_reasons && preflight.blocking_reasons.length ? operatorReasonText(preflight.blocking_reasons[0]) : "\u5de1\u68c0\u524d\u7f6e\u68c0\u67e5\u672a\u901a\u8fc7";
setLaunchCheck("fail", "\u672a\u901a\u8fc7", items);
ui.messageText.textContent = reason;
return false;
}
async function startInspectionRoute() {
if (routeDirty) await saveRoute();
const ok = await runInspectionStartChecks();
if (!ok) return;
setLaunchCheck("starting", "\u542f\u52a8\u4e2d", currentPreflightItems());
const payload = await requestJson("POST", "/api/inspection-routes/default:start", {});
ui.messageText.textContent = `\u5de1\u68c0\u542f\u52a8 #${payload.run_id || ""}`;
await refresh();
setLaunchCheck("ok", "\u5df2\u542f\u52a8", currentPreflightItems());
}
async function cancelInspectionRoute() {
const payload = await requestJson("POST", "/api/inspection-runs/current:cancel", {});
ui.messageText.textContent = payload.state || "canceled";
await refresh();
}
ui.refreshBtn.addEventListener("click", () => refresh().catch((error) => (ui.messageText.textContent = error.message)));
ui.startNavBtn.addEventListener("click", async () => {
setBusy(true);
try {
const payload = await requestJson("POST", "/api/navigation/start", {});
const reasons = payload.preflight && payload.preflight.blocking_reasons ? payload.preflight.blocking_reasons : [];
ui.messageText.textContent = reasons.length ? `导航已启动,等待预检:${reasons[0]}` : payload.name || "navigation started";
await refresh();
} catch (error) {
ui.messageText.textContent = error.message;
} finally {
setBusy(false);
}
});
if (ui.stopNavBtn) {
ui.stopNavBtn.addEventListener("click", async () => {
if (!window.confirm("确认停止导航?(将关闭 Nav2 和 RViz)")) return;
setBusy(true);
try {
const payload = await requestJson("POST", "/api/navigation/stop", {});
ui.messageText.textContent = payload.name === "navigation/stopped" ? "导航已停止" : "导航未在运行";
await refresh();
} catch (error) {
ui.messageText.textContent = error.message;
} finally {
setBusy(false);
}
});
}
// initialPoseBtn removed
// confirmPoseBtn removed
ui.goalBtn.addEventListener("click", async () => {
const currentPose = state && state.pose ? state.pose : null;
const defaultX = currentPose ? currentPose.x.toFixed(3) : "0.000";
const defaultY = currentPose ? currentPose.y.toFixed(3) : "0.000";
const defaultYaw = currentPose ? (currentPose.yaw_deg * Math.PI / 180).toFixed(3) : "0.000";
const input = window.prompt(
"\u8f93\u5165\u76ee\u6807\u70b9\u5750\u6807 (x, y, yaw_rad)\n\u4f8b: 1.5, 0.8, 1.57",
`${defaultX}, ${defaultY}, ${defaultYaw}`
);
if (!input) return;
const parts = input.split(",").map(s => parseFloat(s.trim()));
if (parts.length < 3 || parts.some(isNaN)) {
ui.messageText.textContent = "\u683c\u5f0f\u9519\u8bef\uff0c\u8bf7\u8f93\u5165: x, y, yaw_rad";
return;
}
const pose = { x: parts[0], y: parts[1], yaw_deg: parts[2] * 180 / Math.PI };
const ok = window.confirm(`\u53d1\u9001\u76ee\u6807\u70b9 x=${parts[0].toFixed(3)} y=${parts[1].toFixed(3)} yaw=${parts[2].toFixed(3)} rad?`);
if (!ok) return;
setBusy(true);
try {
await sendPose("/api/goal", pose);
} finally {
setBusy(false);
}
});
ui.routeEditBtn.addEventListener("click", () => setTool("route"));
if (ui.clearTrailBtn) ui.clearTrailBtn.addEventListener("click", clearPoseTrail);
ui.routeSaveBtn.addEventListener("click", async () => {
setBusy(true);
try {
await saveRoute();
} catch (error) {
ui.messageText.textContent = error.message;
} finally {
setBusy(false);
renderState();
}
});
ui.routeStartBtn.addEventListener("click", async () => {
setBusy(true);
try {
const payload = await requestJson("POST", "/api/inspection-routes/default:start", {});
ui.messageText.textContent = payload.status_text || "巡检已启动";
await refresh();
} catch (error) {
ui.messageText.textContent = error.message;
} finally {
setBusy(false);
}
});
ui.routeCancelBtn.addEventListener("click", async () => {
setBusy(true);
try {
await cancelInspectionRoute();
} catch (error) {
ui.messageText.textContent = error.message;
} finally {
setBusy(false);
renderState();
}
});
ui.routeNameInput.addEventListener("change", () => {
const route = ensureEditableRoute();
route.display_name = ui.routeNameInput.value.trim() || "默认巡检路线";
markRouteDirty();
});
ui.stopBtn.addEventListener("click", async () => {
setBusy(true);
try {
const payload = await requestJson("POST", "/api/stop", {});
ui.messageText.textContent = payload.name || "stop";
await refresh();
} catch (error) {
ui.messageText.textContent = error.message;
} finally {
setBusy(false);
}
});
// --- 巡检服务切换 ---
async function _syncPatrolExecutorBtn() {
if (!ui.patrolExecutorBtn) return;
try {
const s = await requestJson("GET", "/api/script/patrol_executor/status", null);
ui.patrolExecutorBtn.textContent = s.running ? "停止巡检服务" : "启动巡检服务";
ui.patrolExecutorBtn.className = s.running ? "danger" : "amber";
} catch (_) {}
}
if (ui.patrolExecutorBtn) {
ui.patrolExecutorBtn.addEventListener("click", async () => {
ui.patrolExecutorBtn.disabled = true;
try {
const r = await requestJson("POST", "/api/script/patrol_executor/toggle", {});
ui.messageText.textContent = r.msg || "";
await _syncPatrolExecutorBtn();
} catch (error) {
ui.messageText.textContent = error.message;
} finally {
ui.patrolExecutorBtn.disabled = false;
}
});
setInterval(_syncPatrolExecutorBtn, 3000);
_syncPatrolExecutorBtn();
}
// --- 实时位姿记录 ---
let _poseEvtSrc = null;
if (ui.showPoseBtn) {
ui.showPoseBtn.addEventListener("click", () => {
if (_poseEvtSrc) {
_poseEvtSrc.close();
_poseEvtSrc = null;
ui.showPoseBtn.textContent = "记录位姿";
if (ui.poseConsoleSummary) ui.poseConsoleSummary.textContent = "已停止";
return;
}
if (ui.poseConsoleLog) ui.poseConsoleLog.textContent = "";
_poseEvtSrc = new EventSource("/api/pose_stream");
ui.showPoseBtn.textContent = "停止记录";
if (ui.poseConsoleSummary) ui.poseConsoleSummary.textContent = "记录中";
_poseEvtSrc.onmessage = (e) => {
if (!ui.poseConsoleLog) return;
const ts = new Date().toLocaleTimeString("zh-CN", {hour12: false});
ui.poseConsoleLog.textContent = ts + " " + e.data + "\n" + ui.poseConsoleLog.textContent;
if (ui.poseConsoleLog.textContent.length > 4000)
ui.poseConsoleLog.textContent = ui.poseConsoleLog.textContent.slice(0, 4000);
};
_poseEvtSrc.onerror = () => {
if (ui.poseConsoleSummary) ui.poseConsoleSummary.textContent = "连接断开";
};
});
}
window.addEventListener("resize", draw);
if (window.ResizeObserver && ui.canvasWrap) {
const canvasResizeObserver = new ResizeObserver(() => draw());
canvasResizeObserver.observe(ui.canvasWrap);
}
setInterval(updateClock, 1000);
setInterval(() => refresh().catch(() => {}), 250);
// === Patrol Route Waypoint Editor ===
async function loadPatrolRoute() {
try {
patrolRoute = await requestJson("GET", "/api/patrol-route");
} catch (e) {
patrolRoute = { route_name: "default", frame_id: "map", waypoints: [] };
}
renderWaypointTable();
}
function renderWaypointTable() {
if (!ui.waypointRows) return;
const waypoints = patrolRoute && patrolRoute.waypoints ? patrolRoute.waypoints : [];
ui.waypointRows.innerHTML = "";
if (!waypoints.length) {
const empty = document.createElement("div");
empty.className = "wp-row wp-empty";
empty.textContent = "\u6682\u65e0\u822a\u70b9\uff0c\u70b9\u51fb\u4e0b\u65b9\u6309\u94ae\u6dfb\u52a0";
ui.waypointRows.append(empty);
return;
}
for (let i = 0; i < waypoints.length; i++) {
const wp = waypoints[i];
const row = document.createElement("div");
row.className = "wp-row";
row.innerHTML = `
${i + 1}
`;
ui.waypointRows.append(row);
}
// Attach event listeners
ui.waypointRows.querySelectorAll("input").forEach(input => {
input.addEventListener("change", (e) => {
const idx = parseInt(e.target.dataset.idx);
const field = e.target.dataset.field;
if (field === "enabled") {
patrolRoute.waypoints[idx].enabled = e.target.checked;
} else if (field === "yaw") {
patrolRoute.waypoints[idx][field] = (parseFloat(e.target.value) || 0) * Math.PI / 180;
} else if (field === "x" || field === "y") {
patrolRoute.waypoints[idx][field] = parseFloat(e.target.value) || 0;
} else {
patrolRoute.waypoints[idx][field] = e.target.value;
}
});
});
ui.waypointRows.querySelectorAll("button[data-action]").forEach(btn => {
btn.addEventListener("click", (e) => {
const idx = parseInt(e.target.dataset.idx);
const action = e.target.dataset.action;
if (action === "up" && idx > 0) {
[patrolRoute.waypoints[idx - 1], patrolRoute.waypoints[idx]] = [patrolRoute.waypoints[idx], patrolRoute.waypoints[idx - 1]];
renderWaypointTable();
} else if (action === "down" && idx < patrolRoute.waypoints.length - 1) {
[patrolRoute.waypoints[idx], patrolRoute.waypoints[idx + 1]] = [patrolRoute.waypoints[idx + 1], patrolRoute.waypoints[idx]];
renderWaypointTable();
} else if (action === "del") {
patrolRoute.waypoints.splice(idx, 1);
renderWaypointTable();
}
});
});
}
if (ui.wpAddEmptyBtn) {
ui.wpAddEmptyBtn.addEventListener("click", () => {
if (!patrolRoute) patrolRoute = { route_name: "default", frame_id: "map", waypoints: [] };
const n = patrolRoute.waypoints.length + 1;
patrolRoute.waypoints.push({ name: `waypoint_${n}`, frame_id: "map", x: 0, y: 0, yaw: 0, enabled: true });
renderWaypointTable();
});
}
if (ui.wpAddFromPoseBtn) {
ui.wpAddFromPoseBtn.addEventListener("click", () => {
if (!patrolRoute) patrolRoute = { route_name: "default", frame_id: "map", waypoints: [] };
const pose = state && state.pose ? state.pose : null;
if (!pose) {
ui.messageText.textContent = "\u5f53\u524d\u65e0\u4f4d\u59ff\u6570\u636e";
return;
}
const n = patrolRoute.waypoints.length + 1;
const yawRad = pose.yaw_deg * Math.PI / 180;
patrolRoute.waypoints.push({
name: `waypoint_${n}`,
frame_id: "map",
x: parseFloat(pose.x.toFixed(3)),
y: parseFloat(pose.y.toFixed(3)),
yaw: parseFloat(yawRad.toFixed(3)),
enabled: true,
});
renderWaypointTable();
ui.messageText.textContent = `\u5df2\u6dfb\u52a0\u822a\u70b9 ${n}: x=${pose.x.toFixed(3)} y=${pose.y.toFixed(3)} yaw=${yawRad.toFixed(3)}`;
});
}
if (ui.wpSaveBtn) {
ui.wpSaveBtn.addEventListener("click", async () => {
if (!patrolRoute || !patrolRoute.waypoints) {
ui.messageText.textContent = "\u65e0\u822a\u70b9\u6570\u636e";
return;
}
setBusy(true);
try {
const result = await requestJson("PATCH", "/api/patrol-route", patrolRoute);
patrolRoute = result;
renderWaypointTable();
ui.messageText.textContent = `\u5df2\u4fdd\u5b58 ${result.waypoints.length} \u4e2a\u822a\u70b9\u5230 YAML`;
} catch (e) {
ui.messageText.textContent = `\u4fdd\u5b58\u5931\u8d25: ${e.message}`;
} finally {
setBusy(false);
}
});
}
// Load patrol route on startup
loadPatrolRoute();
connectVideo();
updateClock();
refresh().catch((error) => {
ui.messageText.textContent = error.message;
});