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 轨迹规划观察台
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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);
+ }
+ }
+}