414 lines
23 KiB
HTML
414 lines
23 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>轨迹规划全链路 Demo</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: light dark;
|
||
--ink: light-dark(#172033, #e8eefb);
|
||
--muted: light-dark(#516078, #a9b6cc);
|
||
--surface: light-dark(#ffffff, #151c2a);
|
||
--canvas: light-dark(#f6f8fc, #0d1420);
|
||
--grid: light-dark(#d8dfeb, #2a374b);
|
||
--line: light-dark(#c9d2e1, #36445b);
|
||
--obstacle: light-dark(#67748a, #64748b);
|
||
--coarse: light-dark(#ca5a14, #ff9b5a);
|
||
--smooth: light-dark(#21835b, #64d99a);
|
||
--trajectory: light-dark(#1664c0, #71b5ff);
|
||
--selected: light-dark(#a33d95, #f58ce9);
|
||
--gear: light-dark(#a05800, #ffc65b);
|
||
--goal: light-dark(#a92f3b, #ff8995);
|
||
--start: light-dark(#2d7c4f, #74dd9b);
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
background: var(--canvas);
|
||
color: var(--ink);
|
||
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||
}
|
||
main { max-width: 1120px; margin: 0 auto; padding: 22px; }
|
||
h1 { margin: 0 0 6px; font-size: clamp(1.4rem, 3vw, 2rem); }
|
||
h2 { margin: 0; font-size: 1.05rem; }
|
||
p { margin: 8px 0; line-height: 1.6; }
|
||
.muted { color: var(--muted); }
|
||
.stage-nav { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0; }
|
||
button {
|
||
border: 1px solid var(--line);
|
||
border-radius: 7px;
|
||
background: var(--surface);
|
||
color: var(--ink);
|
||
padding: 8px 12px;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
}
|
||
button[aria-pressed="true"] { background: var(--trajectory); color: white; border-color: var(--trajectory); }
|
||
.layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); gap: 16px; }
|
||
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
|
||
.map-panel { min-width: 0; }
|
||
#planning-map { display: block; width: 100%; height: auto; background: var(--canvas); border: 1px solid var(--line); border-radius: 7px; }
|
||
.legend { display: flex; flex-wrap: wrap; gap: 8px 14px; margin-top: 10px; font-size: .85rem; }
|
||
.legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||
.swatch { width: 20px; height: 0; border-top: 3px solid currentColor; display: inline-block; }
|
||
.dot { width: 11px; height: 11px; border-radius: 50%; display: inline-block; background: currentColor; }
|
||
.coarse { color: var(--coarse); } .smooth { color: var(--smooth); } .trajectory { color: var(--trajectory); }
|
||
.gear { color: var(--gear); } .goal { color: var(--goal); } .obstacle { color: var(--obstacle); }
|
||
.timeline-row { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
|
||
#timeline { flex: 1; accent-color: var(--trajectory); }
|
||
.cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 16px 0; }
|
||
.metric { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 10px; }
|
||
.metric b { display: block; font-size: 1.05rem; margin-top: 4px; overflow-wrap: anywhere; }
|
||
.bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||
pre { margin: 10px 0 0; padding: 12px; overflow: auto; background: var(--canvas); border: 1px solid var(--line); border-radius: 7px; font-size: .78rem; line-height: 1.45; }
|
||
.boundary { border-left: 4px solid var(--trajectory); padding-left: 10px; }
|
||
.publish-rule { border-left: 4px solid var(--goal); padding-left: 10px; font-size: .9rem; }
|
||
.flow { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; font-size: .9rem; margin: 12px 0; }
|
||
.flow span { padding: 4px 7px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface); }
|
||
.flow i { color: var(--muted); font-style: normal; }
|
||
.map-label { font-size: 12px; fill: var(--ink); paint-order: stroke; stroke: var(--canvas); stroke-width: 4px; stroke-linejoin: round; }
|
||
.axis-label { font-size: 12px; fill: var(--muted); }
|
||
@media (max-width: 760px) {
|
||
main { padding: 14px; }
|
||
.layout, .bottom { grid-template-columns: 1fr; }
|
||
.cards { grid-template-columns: 1fr; }
|
||
.timeline-row { align-items: stretch; flex-direction: column; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<header>
|
||
<h1>轨迹规划全链路 Demo</h1>
|
||
<p class="muted">内置说明数据:同一米制地图中的地图、粗路径、平滑路径、EM 轨迹与控制输出。它用于理解数据边界,不调用 OSQP、定位、硬件或真实控制器。</p>
|
||
</header>
|
||
|
||
<div class="flow" aria-label="规划数据流">
|
||
<span>PlanningGridMap</span><i>→</i><span>Hybrid A*</span><i>→</i><span>Local G2</span><i>→</i><span>EmTrajectory</span><i>→</i><span>TrajectoryControlCommand</span>
|
||
</div>
|
||
|
||
<nav class="stage-nav" aria-label="规划阶段">
|
||
<button type="button" data-stage="map" aria-pressed="false">1. 地图</button>
|
||
<button type="button" data-stage="coarse" aria-pressed="false">2. 粗路径</button>
|
||
<button type="button" data-stage="smooth" aria-pressed="false">3. 平滑路径</button>
|
||
<button type="button" data-stage="em" aria-pressed="true">4. EM 轨迹</button>
|
||
<button type="button" data-stage="control" aria-pressed="false">5. 控制接口</button>
|
||
</nav>
|
||
|
||
<section class="layout" aria-label="规划地图与数据">
|
||
<section class="panel map-panel">
|
||
<h2 id="stage-title">EM 最终轨迹:可发布的时间参数化参考</h2>
|
||
<p id="stage-description" class="muted"></p>
|
||
<svg id="planning-map" viewBox="0 0 800 520" role="img" aria-label="停车场地图与轨迹"></svg>
|
||
<div class="legend" aria-label="路径图例">
|
||
<span class="obstacle"><i class="dot"></i>静态障碍物</span>
|
||
<span class="coarse coarse-path"><i class="swatch"></i>Hybrid A* 粗路径</span>
|
||
<span class="smooth smooth-path"><i class="swatch"></i>Local G2 平滑路径</span>
|
||
<span class="trajectory"><i class="swatch"></i>EmTrajectory</span>
|
||
<span class="gear"><i class="dot"></i>换挡点</span>
|
||
<span class="goal"><i class="dot"></i>最终 Goal</span>
|
||
</div>
|
||
<div class="timeline-row">
|
||
<label for="timeline">时间采样点</label>
|
||
<input id="timeline" type="range" min="0" max="5" value="0" aria-label="EM 轨迹时间点">
|
||
<output id="time-output" for="timeline">t = 0.0 s</output>
|
||
</div>
|
||
</section>
|
||
|
||
<aside class="panel">
|
||
<h2>规划身份</h2>
|
||
<div class="cards">
|
||
<div class="metric"><span class="muted">地图版本</span><b id="map-id"></b></div>
|
||
<div class="metric"><span class="muted">路径版本</span><b id="path-id"></b></div>
|
||
<div class="metric"><span class="muted">车辆状态序号</span><b id="state-id"></b></div>
|
||
</div>
|
||
<div class="metric"><span class="muted">当前轨迹 ID</span><b id="trajectory-id"></b></div>
|
||
<div class="metric"><span class="muted">effectiveAtUtc</span><b id="effective-at"></b></div>
|
||
<p class="publish-rule">仅当状态为 <code>Success</code> 或 <code>SuccessWithFallback</code>,且结果携带完整 <code>EmTrajectory</code> 时,才允许发布给执行层;其他状态只能输出诊断。</p>
|
||
</aside>
|
||
</section>
|
||
|
||
<section class="bottom" aria-label="阶段数据和控制边界">
|
||
<section class="panel">
|
||
<h2 id="data-title">EmTrajectory 点数据</h2>
|
||
<p id="data-note" class="muted"></p>
|
||
<pre id="data-sample" aria-live="polite"></pre>
|
||
</section>
|
||
<section class="panel">
|
||
<h2>控制层消费边界</h2>
|
||
<p id="control-note" class="boundary"></p>
|
||
<pre id="control-sample" aria-live="polite"></pre>
|
||
</section>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
(() => {
|
||
"use strict";
|
||
const svgNamespace = "http:" + "//www.w3.org/2000/svg";
|
||
const identity = Object.freeze({
|
||
mapSnapshotId: 2026081001,
|
||
referencePathId: "parking-path-v17",
|
||
vehicleStateSequenceId: 42,
|
||
trajectoryId: "trajectory-42-parking-demo",
|
||
effectiveAtUtc: "2026-08-10T09:00:00.000Z"
|
||
});
|
||
const map = Object.freeze({
|
||
width: 4, height: 3,
|
||
start: { x: 0.55, y: 0.55, yaw: 0 },
|
||
goal: { x: 2.72, y: 2.18, yaw: 1.57 },
|
||
obstacles: [
|
||
{ x: 0.10, y: 1.82, width: 1.42, height: 0.40, label: "车位隔离带" },
|
||
{ x: 2.45, y: 0.18, width: 1.20, height: 0.48, label: "静态车辆" },
|
||
{ x: 3.08, y: 1.50, width: 0.64, height: 1.18, label: "墙体" }
|
||
]
|
||
});
|
||
const coarsePath = Object.freeze([
|
||
{ x: 0.55, y: 0.55, direction: "Forward" },
|
||
{ x: 1.05, y: 0.56, direction: "Forward" },
|
||
{ x: 1.55, y: 0.78, direction: "Forward" },
|
||
{ x: 2.05, y: 1.42, direction: "Forward", gear: true },
|
||
{ x: 2.24, y: 1.63, direction: "Reverse" },
|
||
{ x: 2.50, y: 1.91, direction: "Reverse" },
|
||
{ x: 2.72, y: 2.18, direction: "Reverse", goal: true }
|
||
]);
|
||
const smoothPath = Object.freeze([
|
||
{ x: 0.55, y: 0.55, curvature: 0.00, segment: 0 },
|
||
{ x: 0.95, y: 0.56, curvature: 0.02, segment: 0 },
|
||
{ x: 1.32, y: 0.68, curvature: 0.18, segment: 0 },
|
||
{ x: 1.68, y: 0.95, curvature: 0.31, segment: 0 },
|
||
{ x: 2.05, y: 1.42, curvature: 0.00, segment: 0, gear: true },
|
||
{ x: 2.20, y: 1.58, curvature: -0.10, segment: 1 },
|
||
{ x: 2.42, y: 1.78, curvature: -0.16, segment: 1 },
|
||
{ x: 2.58, y: 1.99, curvature: -0.09, segment: 1 },
|
||
{ x: 2.72, y: 2.18, curvature: 0.00, segment: 1, goal: true }
|
||
]);
|
||
const trajectory = Object.freeze([
|
||
{ timeFromStart: 0.0, x: 0.55, y: 0.55, yaw: 0.00, signedVelocity: 0.45, acceleration: 0.22, yawRate: 0.00, direction: "Forward", segmentIndex: 0, boundaryType: "None" },
|
||
{ timeFromStart: 1.0, x: 1.18, y: 0.63, yaw: 0.20, signedVelocity: 0.38, acceleration: -0.08, yawRate: 0.13, direction: "Forward", segmentIndex: 0, boundaryType: "None" },
|
||
{ timeFromStart: 2.0, x: 2.05, y: 1.42, yaw: 0.64, signedVelocity: 0.00, acceleration: 0.00, yawRate: 0.00, direction: "Forward", segmentIndex: 0, boundaryType: "GearSwitchApproach" },
|
||
{ timeFromStart: 2.2, x: 2.05, y: 1.42, yaw: 0.64, signedVelocity: 0.00, acceleration: 0.00, yawRate: 0.00, direction: "Reverse", segmentIndex: 1, boundaryType: "GearSwitchDeparture" },
|
||
{ timeFromStart: 3.2, x: 2.42, y: 1.78, yaw: 0.88, signedVelocity: -0.28, acceleration: -0.04, yawRate: -0.09, direction: "Reverse", segmentIndex: 1, boundaryType: "None" },
|
||
{ timeFromStart: 4.3, x: 2.72, y: 2.18, yaw: 1.57, signedVelocity: 0.00, acceleration: 0.00, yawRate: 0.00, direction: "Reverse", segmentIndex: 1, boundaryType: "Goal" }
|
||
]);
|
||
const stages = Object.freeze({
|
||
map: {
|
||
title: "地图:规划前冻结的世界模型",
|
||
description: "PlanningGridMap、车辆外廓、起点与终点属于同一业务快照;规划服务不自行读取传感器或系统时钟。",
|
||
dataTitle: "地图与状态输入",
|
||
dataNote: "这些输入绑定 mapSnapshotId 与车辆状态序号,是所有下游数据的一致性基础。"
|
||
},
|
||
coarse: {
|
||
title: "Hybrid A* 粗路径:可行驶的离散搜索结果",
|
||
description: "粗路径以离散姿态节点给出拓扑可行解,并在方向变化处标出换挡点。",
|
||
dataTitle: "CoarsePathResult(离散节点)",
|
||
dataNote: "粗路径用于后续平滑和诊断;它不是控制器按时间执行的命令。"
|
||
},
|
||
smooth: {
|
||
title: "Local G2 平滑路径:连续几何与方向段",
|
||
description: "平滑阶段把离散节点变成连续、曲率可控的参考路径,并明确前进段和倒车段边界。",
|
||
dataTitle: "PathSmoothingResult(连续几何)",
|
||
dataNote: "平滑路径提供 Frenet 参考和走廊构建依据;它仍不携带控制执行所需的时间信息。"
|
||
},
|
||
em: {
|
||
title: "EM 最终轨迹:可发布的时间参数化参考",
|
||
description: "EM 将平滑几何与纵向速度规划组合为按时间排序的 EmTrajectory 点;蓝色车辆为当前选中点。",
|
||
dataTitle: "EmTrajectoryPoint(当前时间采样)",
|
||
dataNote: "位置单位 m,航向 rad,速度 m/s,纵向加速度 m/s²,yaw rate rad/s。"
|
||
},
|
||
control: {
|
||
title: "控制接口:仅消费已验证轨迹的当前采样",
|
||
description: "执行层从完整 EmTrajectory 采样并映射为控制器中立 TrajectoryControlCommand;换挡和终点强制零运动。",
|
||
dataTitle: "EmTrajectory → 执行层采样",
|
||
dataNote: "轨迹身份用于安全交接与追溯;控制器并不以粗路径、平滑路径或优化中间量作为逐周期命令。"
|
||
}
|
||
});
|
||
const state = { stage: "em", selectedIndex: 0 };
|
||
const svg = document.getElementById("planning-map");
|
||
const timeline = document.getElementById("timeline");
|
||
const stageButtons = Array.from(document.querySelectorAll("[data-stage]"));
|
||
const stageTitle = document.getElementById("stage-title");
|
||
const stageDescription = document.getElementById("stage-description");
|
||
const dataTitle = document.getElementById("data-title");
|
||
const dataNote = document.getElementById("data-note");
|
||
const dataSample = document.getElementById("data-sample");
|
||
const controlSample = document.getElementById("control-sample");
|
||
const controlNote = document.getElementById("control-note");
|
||
const timeOutput = document.getElementById("time-output");
|
||
|
||
document.getElementById("map-id").textContent = identity.mapSnapshotId;
|
||
document.getElementById("path-id").textContent = identity.referencePathId;
|
||
document.getElementById("state-id").textContent = identity.vehicleStateSequenceId;
|
||
document.getElementById("trajectory-id").textContent = identity.trajectoryId;
|
||
document.getElementById("effective-at").textContent = identity.effectiveAtUtc;
|
||
|
||
function element(name, attributes, text) {
|
||
const node = document.createElementNS(svgNamespace, name);
|
||
Object.entries(attributes || {}).forEach(([key, value]) => node.setAttribute(key, String(value)));
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
function world(point) {
|
||
return { x: 85 + point.x * 155, y: 455 - point.y * 125 };
|
||
}
|
||
|
||
function appendText(x, y, text, extra) {
|
||
svg.appendChild(element("text", Object.assign({ x, y, class: "map-label" }, extra || {}), text));
|
||
}
|
||
|
||
function drawPolyline(points, stroke, width, dash) {
|
||
const coordinates = points.map(point => {
|
||
const p = world(point);
|
||
return `${p.x},${p.y}`;
|
||
}).join(" ");
|
||
svg.appendChild(element("polyline", {
|
||
points: coordinates, fill: "none", stroke, "stroke-width": width,
|
||
"stroke-linecap": "round", "stroke-linejoin": "round", "stroke-dasharray": dash || ""
|
||
}));
|
||
}
|
||
|
||
function drawCircle(point, radius, fill, label) {
|
||
const p = world(point);
|
||
svg.appendChild(element("circle", { cx: p.x, cy: p.y, r: radius, fill, stroke: "var(--surface)", "stroke-width": 2 }));
|
||
if (label) appendText(p.x + 9, p.y - 8, label);
|
||
}
|
||
|
||
function drawVehicle(point) {
|
||
const p = world(point);
|
||
const angle = -point.yaw * 180 / Math.PI;
|
||
const vehicle = element("g", { transform: `translate(${p.x} ${p.y}) rotate(${angle})` });
|
||
vehicle.appendChild(element("rect", { x: -21, y: -12, width: 42, height: 24, rx: 4, fill: "var(--trajectory)", stroke: "var(--surface)", "stroke-width": 2 }));
|
||
vehicle.appendChild(element("path", { d: "M 18 0 L 8 -6 L 8 6 Z", fill: "var(--surface)" }));
|
||
svg.appendChild(vehicle);
|
||
}
|
||
|
||
function drawMapBase() {
|
||
for (let x = 0; x <= map.width; x += 1) {
|
||
const a = world({ x, y: 0 }); const b = world({ x, y: map.height });
|
||
svg.appendChild(element("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: "var(--grid)", "stroke-width": 1 }));
|
||
appendText(a.x - 4, a.y + 20, `${x} m`, { class: "axis-label" });
|
||
}
|
||
for (let y = 0; y <= map.height; y += 1) {
|
||
const a = world({ x: 0, y }); const b = world({ x: map.width, y });
|
||
svg.appendChild(element("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: "var(--grid)", "stroke-width": 1 }));
|
||
appendText(a.x - 35, a.y + 4, `${y} m`, { class: "axis-label" });
|
||
}
|
||
const origin = world({ x: 0, y: 0 });
|
||
svg.appendChild(element("rect", { x: origin.x, y: origin.y - map.height * 125, width: map.width * 155, height: map.height * 125, fill: "none", stroke: "var(--line)", "stroke-width": 2 }));
|
||
map.obstacles.forEach(obstacle => {
|
||
const topLeft = world({ x: obstacle.x, y: obstacle.y + obstacle.height });
|
||
svg.appendChild(element("rect", { x: topLeft.x, y: topLeft.y, width: obstacle.width * 155, height: obstacle.height * 125, rx: 4, fill: "var(--obstacle)", opacity: .85 }));
|
||
appendText(topLeft.x + 4, topLeft.y + 16, obstacle.label, { fill: "white", stroke: "var(--obstacle)" });
|
||
});
|
||
drawCircle(map.start, 8, "var(--start)", "起点");
|
||
drawCircle(map.goal, 9, "var(--goal)", "Goal");
|
||
}
|
||
|
||
function drawCoarse() {
|
||
drawPolyline(coarsePath, "var(--coarse)", 3, "7 5");
|
||
coarsePath.forEach((point, index) => {
|
||
drawCircle(point, 4.5, "var(--coarse)", index === 0 ? "" : String(index));
|
||
if (index > 0 && index < coarsePath.length - 1) {
|
||
const p = world(point);
|
||
const sign = point.direction === "Forward" ? "↑ 前进" : "↓ 倒车";
|
||
appendText(p.x + 7, p.y + 17, sign, { class: "axis-label" });
|
||
}
|
||
});
|
||
}
|
||
|
||
function drawSmooth() {
|
||
drawPolyline(smoothPath, "var(--smooth)", 4);
|
||
const forward = smoothPath.filter(point => point.segment === 0);
|
||
const reverse = smoothPath.filter(point => point.segment === 1);
|
||
const f = world(forward[2]); const r = world(reverse[1]);
|
||
appendText(f.x - 4, f.y - 19, "方向段 0:Forward,κmax = 0.31 1/m", { class: "axis-label" });
|
||
appendText(r.x + 9, r.y + 23, "方向段 1:Reverse,κmax = 0.16 1/m", { class: "axis-label" });
|
||
}
|
||
|
||
function drawTrajectory() {
|
||
drawPolyline(trajectory, "var(--trajectory)", 4);
|
||
trajectory.forEach((point, index) => {
|
||
const selected = index === state.selectedIndex;
|
||
drawCircle(point, selected ? 8 : 4.5, selected ? "var(--selected)" : "var(--trajectory)", selected ? `t=${point.timeFromStart.toFixed(1)} s` : "");
|
||
});
|
||
}
|
||
|
||
function drawGearAndGoal() {
|
||
const gear = trajectory[2];
|
||
drawCircle(gear, 10, "var(--gear)", "停车换挡:s_end");
|
||
const goal = trajectory[trajectory.length - 1];
|
||
drawCircle(goal, 10, "var(--goal)", "最终终端:Goal");
|
||
}
|
||
|
||
function stageData(selectedPoint) {
|
||
if (state.stage === "map") {
|
||
return { PlanningGridMap: { boundsMeters: [0, map.width, 0, map.height], obstacleCount: map.obstacles.length, mapSnapshotId: identity.mapSnapshotId }, VehicleMotionState: { pose: map.start, signedLongitudinalSpeedMetersPerSecond: 0, sequenceId: identity.vehicleStateSequenceId } };
|
||
}
|
||
if (state.stage === "coarse") {
|
||
return { CoarsePathResult: { nodes: coarsePath, gearSwitchNodeIndex: 3, startDirection: "Forward", goalDirection: "Reverse" } };
|
||
}
|
||
if (state.stage === "smooth") {
|
||
return { PathSmoothingResult: { referencePathId: identity.referencePathId, directionSegments: [{ segmentIndex: 0, direction: "Forward" }, { segmentIndex: 1, direction: "Reverse" }], samples: smoothPath } };
|
||
}
|
||
return { EmTrajectoryMetadata: identity, EmTrajectoryPoint: selectedPoint };
|
||
}
|
||
|
||
function controlCommandForPoint(point) {
|
||
const holding = point.boundaryType === "GearSwitchApproach" ||
|
||
point.boundaryType === "GearSwitchDeparture" || point.boundaryType === "Goal";
|
||
return {
|
||
SignedLongitudinalVelocity: holding ? 0 : point.signedVelocity,
|
||
YawRate: holding ? 0 : point.yawRate,
|
||
Direction: point.direction,
|
||
RequestDirectionChange: point.boundaryType === "GearSwitchApproach",
|
||
HoldBrake: holding,
|
||
IsTrajectoryComplete: point.boundaryType === "Goal"
|
||
};
|
||
}
|
||
|
||
function render() {
|
||
const selectedPoint = trajectory[state.selectedIndex];
|
||
const stage = stages[state.stage];
|
||
svg.replaceChildren();
|
||
drawMapBase();
|
||
if (state.stage === "coarse" || state.stage === "smooth" || state.stage === "em" || state.stage === "control") drawCoarse();
|
||
if (state.stage === "smooth" || state.stage === "em" || state.stage === "control") drawSmooth();
|
||
if (state.stage === "em" || state.stage === "control") drawTrajectory();
|
||
if (state.stage !== "map") drawGearAndGoal();
|
||
drawVehicle(state.stage === "map" ? map.start : selectedPoint);
|
||
stageTitle.textContent = stage.title;
|
||
stageDescription.textContent = stage.description;
|
||
dataTitle.textContent = stage.dataTitle;
|
||
dataNote.textContent = stage.dataNote;
|
||
dataSample.textContent = JSON.stringify(stageData(selectedPoint), null, 2);
|
||
timeOutput.textContent = `t = ${selectedPoint.timeFromStart.toFixed(1)} s`;
|
||
timeline.disabled = state.stage === "map" || state.stage === "coarse" || state.stage === "smooth";
|
||
if (state.stage === "control") {
|
||
controlNote.textContent = "控制层当前只消费完整轨迹的采样结果或执行层导出的 TrajectoryControlCommand。粗路径、平滑路径、地图和优化诊断属于观测、回放与问题定位证据链,不是逐周期底盘命令。";
|
||
controlSample.textContent = JSON.stringify({ trajectoryId: identity.trajectoryId, effectiveAtUtc: identity.effectiveAtUtc, command: controlCommandForPoint(selectedPoint) }, null, 2);
|
||
} else {
|
||
controlNote.textContent = "此阶段尚未形成控制命令。只有完整且已验证的 EmTrajectory 才能进入执行层采样。";
|
||
controlSample.textContent = "等待控制接口阶段:TrajectoryControlCommand 未生成。";
|
||
}
|
||
}
|
||
|
||
stageButtons.forEach(button => {
|
||
button.addEventListener("click", () => {
|
||
state.stage = button.dataset.stage;
|
||
stageButtons.forEach(candidate => candidate.setAttribute("aria-pressed", String(candidate === button)));
|
||
render();
|
||
});
|
||
});
|
||
timeline.addEventListener("input", () => {
|
||
state.selectedIndex = Number(timeline.value);
|
||
render();
|
||
});
|
||
render();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|