Files

1996 lines
88 KiB
JavaScript
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.

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 = `
<span class="wp-col-idx">${i + 1}</span>
<input class="wp-col-name" type="text" value="${wp.name || ""}" data-field="name" data-idx="${i}" />
<input class="wp-col-x" type="number" step="0.001" value="${wp.x}" data-field="x" data-idx="${i}" />
<input class="wp-col-y" type="number" step="0.001" value="${wp.y}" data-field="y" data-idx="${i}" />
<input class="wp-col-yaw" type="number" step="0.1" value="${(wp.yaw * 180 / Math.PI).toFixed(1)}" data-field="yaw" data-idx="${i}" />
<input class="wp-col-en" type="checkbox" ${wp.enabled !== false ? "checked" : ""} data-field="enabled" data-idx="${i}" />
<span class="wp-col-act">
<button type="button" class="wp-btn" data-action="up" data-idx="${i}" ${i === 0 ? "disabled" : ""}>\u2191</button>
<button type="button" class="wp-btn" data-action="down" data-idx="${i}" ${i === waypoints.length - 1 ? "disabled" : ""}>\u2193</button>
<button type="button" class="wp-btn wp-del" data-action="del" data-idx="${i}">\u5220</button>
</span>
`;
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;
});