From 1ad324ca64d1139d34b2f2848b71f4fd90d4dedb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=81=E8=96=84=E4=BA=91?= Date: Thu, 6 Aug 2026 09:52:04 +0800 Subject: [PATCH] feat: add scientific planning dashboard --- .../Runtime/EmbeddedWebAssets.cs | 25 +++++ .../Runtime/LoopbackVisualizationServer.cs | 25 ++++- .../TrajectoryPlanningVisualization.csproj | 5 + .../Web/app.css | 34 ++++++ .../Web/app.js | 83 +++++++++++++++ .../Web/index.html | 47 ++++++++ .../Program.cs | 44 +++++++- .../SampleSnapshotFactory.cs | 100 ++++++++++++++++++ .../WebAssetChecks.cs | 43 ++++++++ 9 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 ClumsyPilot/TrajectoryPlanningVisualization/Runtime/EmbeddedWebAssets.cs create mode 100644 ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css create mode 100644 ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js create mode 100644 ClumsyPilot/TrajectoryPlanningVisualization/Web/index.html create mode 100644 ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/SampleSnapshotFactory.cs create mode 100644 ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/EmbeddedWebAssets.cs b/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/EmbeddedWebAssets.cs new file mode 100644 index 0000000..eaa61d7 --- /dev/null +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/EmbeddedWebAssets.cs @@ -0,0 +1,25 @@ +using System; +using System.IO; +using System.Reflection; +using System.Text; + +namespace TrajectoryPlanningVisualization; + +public static class EmbeddedWebAssets +{ + private const string ResourcePrefix = "TrajectoryPlanningVisualization.Web."; + + public static string ReadText(string fileName) + { + if (string.IsNullOrEmpty(fileName)) + { + throw new ArgumentException("A web asset file name is required.", nameof(fileName)); + } + + Assembly assembly = typeof(EmbeddedWebAssets).Assembly; + using Stream stream = assembly.GetManifestResourceStream(ResourcePrefix + fileName) + ?? throw new InvalidOperationException("Embedded web asset was not found: " + fileName); + using var reader = new StreamReader(stream, new UTF8Encoding(false), true); + return reader.ReadToEnd(); + } +} diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/LoopbackVisualizationServer.cs b/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/LoopbackVisualizationServer.cs index 8f6046d..23890f2 100644 --- a/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/LoopbackVisualizationServer.cs +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/LoopbackVisualizationServer.cs @@ -162,9 +162,32 @@ internal sealed class LoopbackVisualizationServer : IDisposable return; } + if (request.Path == "/") + { + string page = EmbeddedWebAssets.ReadText("index.html").Replace("__SESSION_TOKEN__", token); + WriteResponse(stream, 200, "OK", "text/html; charset=utf-8", page); + return; + } + + if (request.Path == "/app.css") + { + WriteResponse(stream, 200, "OK", "text/css; charset=utf-8", EmbeddedWebAssets.ReadText("app.css")); + return; + } + + if (request.Path == "/app.js") + { + WriteResponse(stream, 200, "OK", "application/javascript; charset=utf-8", EmbeddedWebAssets.ReadText("app.js")); + return; + } + if (request.Path == "/api/bootstrap") { - WriteResponse(stream, 200, "OK", "application/json; charset=utf-8", VisualizationJson.Serialize(staticSnapshot)); + WriteResponse(stream, 200, "OK", "application/json; charset=utf-8", VisualizationJson.Serialize(new + { + staticSnapshot, + refreshRateHz = options.RefreshRateHz + })); return; } diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/TrajectoryPlanningVisualization.csproj b/ClumsyPilot/TrajectoryPlanningVisualization/TrajectoryPlanningVisualization.csproj index 942c267..3c6f638 100644 --- a/ClumsyPilot/TrajectoryPlanningVisualization/TrajectoryPlanningVisualization.csproj +++ b/ClumsyPilot/TrajectoryPlanningVisualization/TrajectoryPlanningVisualization.csproj @@ -6,4 +6,9 @@ + + + + + diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css new file mode 100644 index 0000000..9c57d8c --- /dev/null +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css @@ -0,0 +1,34 @@ +:root { + --paper: #ffffff; + --text: #20252b; + --grid: #d9dde1; + --current-trajectory: #1769aa; + --previous-trajectory: #8d959d; + --handoff: #d87918; + --failure: #b42318; +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--paper); color: var(--text); font: 14px/1.45 "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; } +header { display: flex; justify-content: space-between; align-items: end; padding: 22px 30px 16px; border-bottom: 1px solid var(--grid); } +h1, h2, p { margin: 0; } h1 { font-size: 22px; font-weight: 600; letter-spacing: .02em; } h2 { font-size: 15px; font-weight: 600; } +.eyebrow { color: #66707a; font: 11px/1.2 ui-monospace, Consolas, monospace; letter-spacing: .08em; text-transform: uppercase; } +#live-state { min-width: 170px; padding: 5px 8px; border-left: 3px solid var(--current-trajectory); color: #3f4851; text-align: right; } +nav { display: flex; gap: 2px; padding: 10px 30px 0; border-bottom: 1px solid var(--grid); } +nav button { border: 0; border-bottom: 2px solid transparent; background: transparent; color: #59636d; cursor: pointer; padding: 9px 12px 8px; font: inherit; } +nav button[aria-selected="true"] { border-bottom-color: var(--current-trajectory); color: var(--text); font-weight: 600; } +main { max-width: 1500px; margin: auto; padding: 22px 30px 36px; } +.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 7px 16px; margin-bottom: 14px; } +.summary-value { border-bottom: 1px solid var(--grid); padding: 4px 0; } .summary-value small { display: block; color: #66707a; } +.world-stack { position: relative; width: 100%; min-height: 520px; border: 1px solid var(--grid); overflow: hidden; background: #fff; } +#occupancy-grid, #world-overlay { position: absolute; inset: 0; width: 100%; height: 100%; } #world-overlay { pointer-events: none; } +#ls-st, #kinematics { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 18px; } +.chart { min-height: 280px; border: 1px solid var(--grid); padding: 8px; } .chart svg { display: block; width: 100%; height: 244px; } +.chart-title { font-size: 14px; font-weight: 600; } .chart-note { min-height: 20px; color: #66707a; font-size: 12px; } +.chart-grid { stroke: var(--grid); stroke-width: .55; } .chart-axis { stroke: #59636d; stroke-width: .8; } .chart-data { fill: none; stroke-width: 1.1; vector-effect: non-scaling-stroke; } +.chart-solid { stroke: var(--current-trajectory); } .chart-dashed { stroke: var(--previous-trajectory); stroke-dasharray: 5 4; } .chart-limit { stroke: var(--failure); stroke-dasharray: 3 3; } +.axis-label { fill: #48515b; font: 11px sans-serif; } .axis-tick { fill: #66707a; font: 10px sans-serif; } +.world-line { fill: none; stroke-width: 1.1; vector-effect: non-scaling-stroke; } .world-static { stroke: #8d959d; } .world-future { stroke: #b6bec6; stroke-dasharray: 5 4; } .world-active { stroke: #1769aa; } .world-previous { stroke: #8d959d; stroke-dasharray: 5 4; } .world-marker { fill: #d87918; stroke: #ffffff; stroke-width: .8; } +.history-panel, .configuration-panel { margin-bottom: 26px; } #cycle-history { overflow-x: auto; } table { width: 100%; border-collapse: collapse; font-size: 12px; } th, td { padding: 6px 8px; border-bottom: 1px solid var(--grid); text-align: left; white-space: nowrap; } th { color: #59636d; font-weight: 600; } +.config-group { margin: 13px 0; border-top: 1px solid var(--grid); } .config-group h3 { margin: 8px 0; font-size: 13px; } .config-entry { display: grid; grid-template-columns: minmax(110px, 1fr) minmax(130px, 1fr) minmax(90px, 1fr) 70px; gap: 8px; padding: 4px 0; border-bottom: 1px dotted #e5e8eb; } .raw { color: #66707a; font-family: ui-monospace, Consolas, monospace; } +.failure { color: var(--failure); } .notice { color: var(--handoff); } +@media (max-width: 720px) { header { align-items: start; flex-direction: column; gap: 10px; } nav, main, header { padding-left: 14px; padding-right: 14px; } .world-stack { min-height: 360px; } .config-entry { grid-template-columns: 1fr 1fr; } } diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js new file mode 100644 index 0000000..546d4ff --- /dev/null +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js @@ -0,0 +1,83 @@ +(() => { + "use strict"; + const token = new URLSearchParams(window.location.search).get("token") || ""; + const svgNs = "http://www.w3.org/2000/svg"; + const state = { staticSnapshot: null, frame: null, history: [], refreshRateHz: 10, ended: false, activeTab: "overview", redrawEnabled: {} }; + const liveState = document.getElementById("live-state"); + const occupancyCanvas = document.getElementById("occupancy-grid"); + const overlay = document.getElementById("world-overlay"); + + function authorized(path) { return path + "?token=" + encodeURIComponent(token); } + function finite(value) { return typeof value === "number" && Number.isFinite(value); } + function element(name, className, text) { const node = document.createElement(name); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } + function svg(name, attributes) { const node = document.createElementNS(svgNs, name); Object.keys(attributes || {}).forEach(key => node.setAttribute(key, String(attributes[key]))); return node; } + function safeArray(value) { return Array.isArray(value) ? value : []; } + function setLiveState(text, className) { liveState.textContent = text; liveState.className = className || ""; } + + function installTabs() { + document.querySelectorAll("nav button[data-tab]").forEach(button => button.addEventListener("click", () => { + state.activeTab = button.dataset.tab; + document.querySelectorAll("nav button[data-tab]").forEach(item => item.setAttribute("aria-selected", String(item === button))); + document.querySelectorAll("main > section").forEach(section => { section.hidden = section.id !== state.activeTab; }); + renderActiveTab(); + })); + } + + function getBounds() { + const values = []; + const include = points => safeArray(points).forEach(point => { if (finite(point.x) && finite(point.y)) values.push(point); }); + const snapshot = state.staticSnapshot || {}; + include([ { x: snapshot.worldBounds && snapshot.worldBounds.xMin, y: snapshot.worldBounds && snapshot.worldBounds.yMin }, { x: snapshot.worldBounds && snapshot.worldBounds.xMax, y: snapshot.worldBounds && snapshot.worldBounds.yMax } ]); + safeArray(snapshot.staticPolylines).forEach(line => include(line.points)); safeArray(snapshot.directionSegments).forEach(line => include(line.points)); safeArray(snapshot.staticMarkers).forEach(marker => include([marker.position])); + const frame = state.frame || {}; safeArray(frame.dynamicPolylines).forEach(line => include(line.points)); safeArray(frame.dynamicMarkers).forEach(marker => include([marker.position])); include([frame.vehiclePose]); + if (!values.length) return { xMin: 0, xMax: 1, yMin: 0, yMax: 1 }; + let xMin = Math.min(...values.map(point => point.x)), xMax = Math.max(...values.map(point => point.x)), yMin = Math.min(...values.map(point => point.y)), yMax = Math.max(...values.map(point => point.y)); + const pad = Math.max(xMax - xMin, yMax - yMin, 1) * .05; return { xMin: xMin - pad, xMax: xMax + pad, yMin: yMin - pad, yMax: yMax + pad }; + } + + function drawOccupancy(bounds) { + const grid = state.staticSnapshot && state.staticSnapshot.occupancyGrid; + const box = occupancyCanvas.getBoundingClientRect(); const scale = window.devicePixelRatio || 1; + occupancyCanvas.width = Math.max(1, Math.round(box.width * scale)); occupancyCanvas.height = Math.max(1, Math.round(box.height * scale)); + const context = occupancyCanvas.getContext("2d"); context.setTransform(scale, 0, 0, scale, 0, 0); context.clearRect(0, 0, box.width, box.height); + if (!grid || !grid.occupancyBitsBase64 || !grid.rows || !grid.columns) return; + const bits = Uint8Array.from(atob(grid.occupancyBitsBase64), character => character.charCodeAt(0)); + const factor = Math.min(box.width / (bounds.xMax - bounds.xMin), box.height / (bounds.yMax - bounds.yMin)); const offsetX = (box.width - (bounds.xMax - bounds.xMin) * factor) / 2; const offsetY = (box.height - (bounds.yMax - bounds.yMin) * factor) / 2; + context.fillStyle = "#edf0f2"; + for (let row = 0; row < grid.rows; row += 1) for (let column = 0; column < grid.columns; column += 1) { const index = row * grid.columns + column; if ((bits[index >> 3] & (1 << (index & 7))) !== 0) { const x = offsetX + (grid.bounds.xMin + column * grid.resolutionMeters - bounds.xMin) * factor; const y = offsetY + (bounds.yMax - (grid.bounds.yMin + (row + 1) * grid.resolutionMeters)) * factor; context.fillRect(x, y, grid.resolutionMeters * factor, grid.resolutionMeters * factor); } } + } + + function worldPath(points) { return safeArray(points).filter(point => finite(point.x) && finite(point.y)).map((point, index) => (index ? "L" : "M") + point.x + " " + (-point.y)).join(" "); } + function renderWorld() { + const bounds = getBounds(); drawOccupancy(bounds); overlay.replaceChildren(); overlay.setAttribute("viewBox", `${bounds.xMin} ${-bounds.yMax} ${bounds.xMax - bounds.xMin} ${bounds.yMax - bounds.yMin}`); overlay.setAttribute("preserveAspectRatio", "xMidYMid meet"); + const snapshot = state.staticSnapshot || {}; const frame = state.frame || {}; + const addLine = (line, css) => { const path = worldPath(line.points); if (path) overlay.append(svg("path", { d: path, class: "world-line " + css })); }; + safeArray(snapshot.staticPolylines).forEach(line => addLine(line, "world-static")); + safeArray(snapshot.directionSegments).forEach(segment => addLine(segment, segment.segmentIndex === frame.activeSegmentIndex ? "world-active" : segment.segmentIndex < frame.activeSegmentIndex ? "world-static" : "world-future")); + safeArray(frame.dynamicPolylines).forEach(line => addLine(line, line.lineStyle === 1 ? "world-previous" : "world-active")); + safeArray(snapshot.staticMarkers).concat(safeArray(frame.dynamicMarkers)).forEach(marker => { if (!marker.position || !finite(marker.position.x) || !finite(marker.position.y)) return; const diamond = svg("path", { d: `M ${marker.position.x} ${-marker.position.y - .18} L ${marker.position.x + .18} ${-marker.position.y} L ${marker.position.x} ${-marker.position.y + .18} L ${marker.position.x - .18} ${-marker.position.y} Z`, class: "world-marker" }); overlay.append(diamond); const label = svg("text", { x: marker.position.x + .2, y: -marker.position.y - .2, fill: "#20252b", "font-size": ".32" }); label.textContent = marker.labelChinese || ""; overlay.append(label); }); + if (frame.vehiclePose && finite(frame.vehiclePose.x) && finite(frame.vehiclePose.y)) overlay.append(svg("circle", { cx: frame.vehiclePose.x, cy: -frame.vehiclePose.y, r: ".18", fill: "#20252b" })); + } + + function range(points) { const flat = safeArray(points).filter(point => finite(point.x) && finite(point.y)); if (!flat.length) return { x0: 0, x1: 1, y0: 0, y1: 1 }; let x0 = Math.min(...flat.map(point => point.x)), x1 = Math.max(...flat.map(point => point.x)), y0 = Math.min(...flat.map(point => point.y)), y1 = Math.max(...flat.map(point => point.y)); const xPad = Math.max(x1 - x0, 1) * .05, yPad = Math.max(y1 - y0, 1) * .08; return { x0: x0 - xPad, x1: x1 + xPad, y0: y0 - yPad, y1: y1 + yPad }; } + function renderChart(id, chart) { + const host = document.getElementById(id); if (!host) return; host.replaceChildren(); const title = element("div", "chart-title", chart ? chart.chineseTitle : id); const noteText = id === "jerk-t" ? "末点后无时间区间" : chart ? chart.noteChinese : "等待当前周期数据"; const note = element("div", "chart-note", noteText); host.append(title, note); + const all = chart ? safeArray(chart.series).flatMap(series => safeArray(series.points)) : []; const domain = range(all); const width = 640, height = 244, left = 52, right = 16, top = 8, bottom = 36; const sx = value => left + (value - domain.x0) * (width - left - right) / (domain.x1 - domain.x0); const sy = value => height - bottom - (value - domain.y0) * (height - top - bottom) / (domain.y1 - domain.y0); const graph = svg("svg", { viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": chart ? chart.chineseTitle : id }); + for (let tick = 0; tick <= 4; tick += 1) { const x = left + tick * (width - left - right) / 4, y = top + tick * (height - top - bottom) / 4; graph.append(svg("line", { x1: x, y1: top, x2: x, y2: height - bottom, class: "chart-grid" }), svg("line", { x1: left, y1: y, x2: width - right, y2: y, class: "chart-grid" })); const xt = svg("text", { x, y: height - bottom + 14, class: "axis-tick", "text-anchor": "middle" }); xt.textContent = (domain.x0 + tick * (domain.x1 - domain.x0) / 4).toPrecision(3); graph.append(xt); } + graph.append(svg("line", { x1: left, y1: top, x2: left, y2: height - bottom, class: "chart-axis" }), svg("line", { x1: left, y1: height - bottom, x2: width - right, y2: height - bottom, class: "chart-axis" })); + safeArray(chart && chart.series).forEach(series => { const points = safeArray(series.points).filter(point => finite(point.x) && finite(point.y)); if (!points.length) return; const d = points.map((point, index) => (index ? "L" : "M") + sx(point.x) + " " + sy(point.y)).join(" "); const lineClass = series.lineStyle === 1 ? "chart-dashed" : series.lineStyle === 2 ? "chart-limit" : "chart-solid"; graph.append(svg("path", { d, class: "chart-data " + lineClass })); }); + const xLabel = svg("text", { x: (left + width - right) / 2, y: height - 4, class: "axis-label", "text-anchor": "middle" }); xLabel.textContent = chart ? chart.xAxisLabel : "x"; const yLabel = svg("text", { x: 13, y: (top + height - bottom) / 2, class: "axis-label", transform: `rotate(-90 13 ${(top + height - bottom) / 2})`, "text-anchor": "middle" }); yLabel.textContent = chart ? chart.yAxisLabel : "y"; graph.append(xLabel, yLabel); host.append(graph); + } + + function renderOverview() { renderWorld(); const summary = document.getElementById("overview-summary"); summary.replaceChildren(); const frame = state.frame || {}; safeArray(frame.statusValues).concat([{ chineseName: "活动方向段", rawName: "SegmentIndex", value: String(frame.activeSegmentIndex ?? "不可用"), unit: "", severity: "normal" }, { chineseName: "方向", rawName: "Direction", value: frame.activeDirection || "不可用", unit: "", severity: "normal" }]).forEach(value => { const cell = element("div", "summary-value " + (value.severity || "")); const name = element("small", "", `${value.chineseName || "不可用"} · ${value.rawName || ""}`); const data = element("div", "", `${value.value || "不可用"} ${value.unit || ""}`.trim()); cell.append(name, data); summary.append(cell); }); } + function chartById(id) { return safeArray(state.frame && state.frame.charts).find(chart => chart.id === id); } + function renderCharts(ids) { ids.forEach(id => renderChart(id, chartById(id))); } + function renderHistoryConfig() { const history = document.getElementById("cycle-history"); history.replaceChildren(); const table = element("table"); const head = element("tr"); ["周期", "时间", "状态", "发布", "耗时 (ms)", "段", "方向", "模式", "终端", "失败原因"].forEach(label => head.append(element("th", "", label))); table.append(head); safeArray(state.history).forEach(item => { const row = element("tr"); [item.cycleVersion, item.occurredAtUtc, item.status, item.published, item.planningElapsedMilliseconds, item.segmentIndex, item.direction, item.longitudinalMode, item.terminalType, item.failureReason || ""].forEach(value => row.append(element("td", "", String(value ?? "不可用")))); table.append(row); }); history.append(table); const configuration = document.getElementById("configuration"); configuration.replaceChildren(); safeArray(state.staticSnapshot && state.staticSnapshot.configurationGroups).forEach(group => { const panel = element("div", "config-group"); panel.append(element("h3", "", group.chineseTitle)); safeArray(group.entries).forEach(entry => { const line = element("div", "config-entry " + (entry.severity || "")); line.append(element("span", "", entry.chineseName), element("span", "raw", entry.rawName), element("span", "", entry.value), element("span", "", entry.unit)); panel.append(line); }); configuration.append(panel); }); } + function renderActiveTab() { if (!state.redrawEnabled[state.activeTab]) return; try { if (state.activeTab === "overview") renderOverview(); else if (state.activeTab === "ls-st") renderCharts(["ls", "st"]); else if (state.activeTab === "kinematics") renderCharts(["curvature-s", "curvature-t", "velocity-t", "acceleration-t", "jerk-t", "yaw-rate-t"]); else renderHistoryConfig(); } catch (error) { state.redrawEnabled[state.activeTab] = false; setLiveState("页面绘图异常", "failure"); } } + function renderAll() { renderActiveTab(); } + function updateConnectionState() { if (state.ended) return; if (!state.lastFrameAt || Date.now() - state.lastFrameAt > 2000 / state.refreshRateHz * 1000) setLiveState("数据已过期", "failure"); } + function receiveFrame(payload) { state.frame = payload.snapshot || null; state.history = safeArray(payload.history); state.lastFrameAt = Date.now(); if (!state.ended) setLiveState(state.frame && state.frame.sessionStateChinese || "运行中"); renderAll(); } + function startEvents() { const events = new EventSource(authorized("/api/events")); events.addEventListener("frame", event => { try { receiveFrame(JSON.parse(event.data)); } catch (error) { state.redrawEnabled[state.activeTab] = false; setLiveState("页面绘图异常", "failure"); } }); events.addEventListener("end", () => { state.ended = true; events.close(); setLiveState("会话已结束"); }); events.onerror = () => { if (!state.ended) setLiveState("连接中断,正在重连", "notice"); }; } + async function boot() { installTabs(); ["overview", "ls-st", "kinematics", "history-config"].forEach(tab => { state.redrawEnabled[tab] = true; }); try { const response = await fetch(authorized("/api/bootstrap"), { cache: "no-store" }); if (!response.ok) throw new Error("bootstrap failed"); const bootstrap = await response.json(); state.staticSnapshot = bootstrap.staticSnapshot || bootstrap; state.refreshRateHz = finite(bootstrap.refreshRateHz) && bootstrap.refreshRateHz > 0 ? bootstrap.refreshRateHz : 10; renderAll(); startEvents(); window.setInterval(updateConnectionState, 250); } catch (error) { setLiveState("页面绘图异常", "failure"); } } + window.addEventListener("error", () => { state.redrawEnabled[state.activeTab] = false; setLiveState("页面绘图异常", "failure"); }); window.addEventListener("unhandledrejection", () => { state.redrawEnabled[state.activeTab] = false; setLiveState("页面绘图异常", "failure"); }); boot(); +})(); diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Web/index.html b/ClumsyPilot/TrajectoryPlanningVisualization/Web/index.html new file mode 100644 index 0000000..af7007c --- /dev/null +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Web/index.html @@ -0,0 +1,47 @@ + + + + + + EM 轨迹规划观察台 + + + +
+

Trajectory Planning Observation

EM 轨迹规划观察台

+
等待数据
+
+ +
+
+
+
+ + +
+
+ + + +
+ + + diff --git a/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/Program.cs b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/Program.cs index c83a2ac..56ff0b7 100644 --- a/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/Program.cs +++ b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/Program.cs @@ -1,16 +1,26 @@ using System; +using System.Diagnostics; +using System.Threading; +using TrajectoryPlanningVisualization; namespace TrajectoryPlanningVisualizationVerificationHost; internal static class Program { - private static int Main() + private static int Main(string[] args) { + if (args.Length == 2 && args[0] == "--smoke-seconds" && + int.TryParse(args[1], out int seconds) && seconds > 0) + { + return RunSmoke(seconds); + } + try { ContractChecks.Run(); RuntimeChecks.Run(); ServerChecks.Run(); + WebAssetChecks.Run(); Console.WriteLine("PASS trajectory-planning-visualization"); return 0; } @@ -20,4 +30,36 @@ internal static class Program return 1; } } + + private static int RunSmoke(int seconds) + { + try + { + using var session = new PlanningVisualizationSession( + new PlanningVisualizationOptions { Port = 0, RefreshRateHz = 10, HistoryCycleLimit = 60 }); + PlanningVisualizationSessionInfo info = session.Start(SampleSnapshotFactory.CreateStaticSnapshot()); + Console.WriteLine(info.Uri.AbsoluteUri); + Stopwatch stopwatch = Stopwatch.StartNew(); + long sequence = 1L; + while (stopwatch.Elapsed < TimeSpan.FromSeconds(seconds)) + { + session.Publish(SampleSnapshotFactory.CreateDynamicSnapshot(sequence++)); + TimeSpan remaining = TimeSpan.FromSeconds(seconds) - stopwatch.Elapsed; + if (remaining <= TimeSpan.Zero) + { + break; + } + + Thread.Sleep(remaining < TimeSpan.FromMilliseconds(100) ? remaining : TimeSpan.FromMilliseconds(100)); + } + + session.Stop(); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + return 1; + } + } } diff --git a/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/SampleSnapshotFactory.cs b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/SampleSnapshotFactory.cs new file mode 100644 index 0000000..a59155b --- /dev/null +++ b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/SampleSnapshotFactory.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using TrajectoryPlanningVisualization; + +namespace TrajectoryPlanningVisualizationVerificationHost; + +internal static class SampleSnapshotFactory +{ + private static readonly DateTimeOffset Origin = DateTimeOffset.Parse("2026-08-06T00:00:00+00:00"); + + public static PlanningVisualizationStaticSnapshot CreateStaticSnapshot() + { + return new PlanningVisualizationStaticSnapshot( + "合成轨迹规划观察", + new VisualizationBounds(0d, 12d, 0d, 8d), + new VisualizationOccupancyGrid(new VisualizationBounds(0d, 12d, 0d, 8d), 2d, 4, 6, + new byte[] { 0b00100001, 0b00000100, 0b00000010 }), + new[] + { + new VisualizationPolyline("global", "全局参考路径", "global", VisualizationLineStyle.Solid, + Points((0d, 1d), (3d, 1d), (6d, 2d), (8d, 4d), (11d, 6d))) + }, + new[] { new VisualizationMarker("gear-1", "gear-switch", "换向 1", new VisualizationPoint(6d, 2d)) }, + new[] + { + new VisualizationDirectionSegment(0, "forward", false, true, Points((0d, 1d), (3d, 1d), (6d, 2d))), + new VisualizationDirectionSegment(1, "reverse", true, false, Points((6d, 2d), (8d, 4d), (11d, 6d))) + }, + new[] + { + new VisualizationConfigurationGroup("调度", new[] + { + new VisualizationValue("网页刷新率", "RefreshRateHz", "10", "Hz", "normal"), + new VisualizationValue("历史容量", "HistoryCycleLimit", "60", "cycles", "normal") + }), + new VisualizationConfigurationGroup("观察安全边界", new[] + { + new VisualizationValue("观察模式", "OBSERVE_ONLY", "true", "", "notice") + }) + }); + } + + public static PlanningVisualizationDynamicSnapshot CreateDynamicSnapshot(long sequence) + { + double offset = (sequence - 1L) * 0.05d; + return new PlanningVisualizationDynamicSnapshot( + sequence, + Origin.AddMilliseconds(sequence * 100L), + "合成数据运行中", + 0, + "forward", + new VisualizationPose(2d + offset, 1d, 0d), + new[] + { + new VisualizationPolyline("previous", "上一轮轨迹", "previous", VisualizationLineStyle.Dashed, Points((1d, 1d), (2.5d, 1d), (4d, 1.3d))), + new VisualizationPolyline("current", "当前轨迹", "current", VisualizationLineStyle.Solid, Points((2d + offset, 1d), (3.8d + offset, 1.2d), (5.8d + offset, 1.9d))) + }, + new[] { new VisualizationMarker("handoff", "handoff", "交接", new VisualizationPoint(2d + offset, 1d)) }, + CreateCharts(offset), + new[] + { + new VisualizationValue("规划耗时", "PlanningElapsedMilliseconds", "4.2", "ms", "normal"), + new VisualizationValue("轨迹年龄", "TrajectoryAge", "0.1", "s", "normal") + }, + new VisualizationCycleSummary(sequence, Origin.AddMilliseconds(sequence * 100L), "成功", true, 4.2d, 0, + "forward", "rolling", "horizon", 1.2d, 0d, "")); + } + + private static IReadOnlyList CreateCharts(double offset) + { + return new[] + { + Chart("ls", "横向偏移", "ReferenceS (m)", "l (m)", Points((0d, .05d), (2d, .08d), (4d, .02d))), + Chart("st", "时空轨迹", "t (s)", "PathS (m)", Points((0d, 0d), (1d, 1.2d), (2d, 3.0d))), + Chart("curvature-s", "曲率—距离", "s (m)", "κ (m⁻¹)", Points((0d, .01d), (2d, .04d), (4d, .02d))), + Chart("curvature-t", "曲率—时间", "t (s)", "κ (m⁻¹)", Points((0d, .01d), (1d, .04d), (2d, .02d))), + Chart("velocity-t", "速度", "t (s)", "v (m/s)", Points((0d, 0d), (1d, 1d), (2d, 1.2d + offset))), + Chart("acceleration-t", "加速度", "t (s)", "a (m/s²)", Points((0d, .8d), (1d, .2d), (2d, 0d))), + Chart("jerk-t", "加加速度", "t (s)", "j (m/s³)", Points((0d, -.6d), (1d, -.2d))), + Chart("yaw-rate-t", "横摆角速度", "t (s)", "yaw rate (rad/s)", Points((0d, 0d), (1d, .08d), (2d, .04d))) + }; + } + + private static VisualizationChart Chart(string id, string title, string xAxis, string yAxis, IReadOnlyList points) + { + return new VisualizationChart(id, title, xAxis, yAxis, + new[] { new VisualizationSeries(id + "-current", "当前轨迹", VisualizationLineStyle.Solid, points) }); + } + + private static IReadOnlyList Points(params (double x, double y)[] values) + { + var points = new List(values.Length); + foreach ((double x, double y) in values) + { + points.Add(new VisualizationPoint(x, y)); + } + + return points; + } +} diff --git a/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs new file mode 100644 index 0000000..63940df --- /dev/null +++ b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs @@ -0,0 +1,43 @@ +using System.Linq; +using TrajectoryPlanningVisualization; + +namespace TrajectoryPlanningVisualizationVerificationHost; + +internal static class WebAssetChecks +{ + public static void Run() + { + BuildsDeterministicSmokeSnapshots(); + + string html = EmbeddedWebAssets.ReadText("index.html"); + string css = EmbeddedWebAssets.ReadText("app.css"); + string js = EmbeddedWebAssets.ReadText("app.js"); + + Verification.True(html.Contains("路径总览"), "overview Chinese title"); + Verification.True(html.Contains("LS / ST"), "LS/ST tab"); + Verification.True(html.Contains("曲率与运动学"), "kinematics tab"); + Verification.True(html.Contains("周期历史"), "history tab"); + Verification.True(html.Contains("生效配置"), "configuration panel"); + Verification.True(css.Contains("--current-trajectory: #1769aa"), "scientific current color"); + Verification.True(css.Contains("stroke-width: 1.1"), "thin scientific line"); + Verification.True(js.Contains("末点后无时间区间"), "jerk terminal explanation"); + Verification.True(html.Contains("occupancy-grid"), "single occupancy canvas exists"); + Verification.True(html.Contains("world-overlay"), "SVG trajectory overlay exists"); + Verification.True(js.Contains("atob"), "compact occupancy bitset is decoded in browser"); + Verification.True(!html.Contains("http://") && !html.Contains("https://"), "page has no CDN URL"); + } + + private static void BuildsDeterministicSmokeSnapshots() + { + PlanningVisualizationStaticSnapshot staticSnapshot = SampleSnapshotFactory.CreateStaticSnapshot(); + PlanningVisualizationDynamicSnapshot dynamicSnapshot = SampleSnapshotFactory.CreateDynamicSnapshot(1L); + + Verification.Equal(2, staticSnapshot.DirectionSegments.Count, "smoke snapshot has two direction segments"); + Verification.True(staticSnapshot.StaticMarkers.Any(marker => marker.Kind == "gear-switch"), "smoke snapshot has a gear marker"); + Verification.True(staticSnapshot.ConfigurationGroups.Count > 0, "smoke snapshot has effective configuration"); + foreach (string chartId in new[] { "ls", "st", "curvature-s", "curvature-t", "velocity-t", "acceleration-t", "jerk-t", "yaw-rate-t" }) + { + Verification.True(dynamicSnapshot.Charts.Any(chart => chart.Id == chartId), "smoke snapshot includes chart " + chartId); + } + } +}