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; });