feat: add scientific planning dashboard

This commit is contained in:
梁薄云
2026-08-06 09:52:04 +08:00
parent 00501055eb
commit 1ad324ca64
9 changed files with 404 additions and 2 deletions
@@ -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();
}
}
@@ -162,9 +162,32 @@ internal sealed class LoopbackVisualizationServer : IDisposable
return; 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") 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; return;
} }
@@ -6,4 +6,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Web\index.html" LogicalName="TrajectoryPlanningVisualization.Web.index.html" />
<EmbeddedResource Include="Web\app.css" LogicalName="TrajectoryPlanningVisualization.Web.app.css" />
<EmbeddedResource Include="Web\app.js" LogicalName="TrajectoryPlanningVisualization.Web.app.js" />
</ItemGroup>
</Project> </Project>
@@ -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; } }
@@ -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();
})();
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>EM 轨迹规划观察台</title>
<link rel="stylesheet" href="/app.css?token=__SESSION_TOKEN__">
</head>
<body>
<header>
<div><p class="eyebrow">Trajectory Planning Observation</p><h1>EM 轨迹规划观察台</h1></div>
<div id="live-state" aria-live="polite">等待数据</div>
</header>
<nav aria-label="图表页签">
<button type="button" data-tab="overview" aria-selected="true">路径总览</button>
<button type="button" data-tab="ls-st" aria-selected="false">LS / ST</button>
<button type="button" data-tab="kinematics" aria-selected="false">曲率与运动学</button>
<button type="button" data-tab="history-config" aria-selected="false">周期历史与生效配置</button>
</nav>
<main>
<section id="overview" aria-label="路径总览">
<div id="overview-summary" class="summary-grid"></div>
<div class="world-stack">
<canvas id="occupancy-grid" aria-label="占用栅格底图"></canvas>
<svg id="world-overlay" role="img" aria-label="全局路径与当前规划段"></svg>
</div>
</section>
<section id="ls-st" hidden aria-label="LS / ST">
<div id="ls" class="chart"></div>
<div id="st" class="chart"></div>
</section>
<section id="kinematics" hidden aria-label="曲率与运动学">
<div id="curvature-s" class="chart"></div>
<div id="curvature-t" class="chart"></div>
<div id="velocity-t" class="chart"></div>
<div id="acceleration-t" class="chart"></div>
<div id="jerk-t" class="chart"></div>
<div id="yaw-rate-t" class="chart"></div>
</section>
<section id="history-config" hidden aria-label="周期历史与生效配置">
<div class="history-panel"><h2>周期历史</h2><div id="cycle-history"></div></div>
<div class="configuration-panel"><h2>生效配置</h2><div id="configuration"></div></div>
</section>
</main>
<script src="/app.js?token=__SESSION_TOKEN__"></script>
</body>
</html>
@@ -1,16 +1,26 @@
using System; using System;
using System.Diagnostics;
using System.Threading;
using TrajectoryPlanningVisualization;
namespace TrajectoryPlanningVisualizationVerificationHost; namespace TrajectoryPlanningVisualizationVerificationHost;
internal static class Program 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 try
{ {
ContractChecks.Run(); ContractChecks.Run();
RuntimeChecks.Run(); RuntimeChecks.Run();
ServerChecks.Run(); ServerChecks.Run();
WebAssetChecks.Run();
Console.WriteLine("PASS trajectory-planning-visualization"); Console.WriteLine("PASS trajectory-planning-visualization");
return 0; return 0;
} }
@@ -20,4 +30,36 @@ internal static class Program
return 1; 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;
}
}
} }
@@ -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<VisualizationChart> 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<VisualizationPoint> points)
{
return new VisualizationChart(id, title, xAxis, yAxis,
new[] { new VisualizationSeries(id + "-current", "当前轨迹", VisualizationLineStyle.Solid, points) });
}
private static IReadOnlyList<VisualizationPoint> Points(params (double x, double y)[] values)
{
var points = new List<VisualizationPoint>(values.Length);
foreach ((double x, double y) in values)
{
points.Add(new VisualizationPoint(x, y));
}
return points;
}
}
@@ -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);
}
}
}