feat: add scientific planning dashboard
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,4 +6,9 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</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>
|
||||
|
||||
@@ -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.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+100
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user