Files
ParkingRobot/docs/superpowers/plans/2026-08-09-movementtest-web-path-visualization.md
T

695 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MovementTest Web Path Visualization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Improve the MovementTest web path overview so coarse, smoothed, and EM paths remain distinguishable, while adding a metric coordinate system, a data-driven legend, fixed-size vehicle rendering, and explicit smoothed-path endpoints.
**Architecture:** Keep the existing Canvas occupancy layer and SVG geometry layer. Extend the SVG renderer with world-to-screen metrics, metric grid/axis rendering, fixed-pixel marker transforms, and deterministic path layering; add one HTML legend overlay and one static `smooth-start` marker without changing the visualization JSON contract or planner behavior.
**Tech Stack:** C# 10, .NET `netstandard2.0` visualization library, .NET 10 Windows verification hosts, embedded HTML/CSS/vanilla JavaScript, SVG, Canvas.
## Global Constraints
- Do not modify coarse-path, Local G2, EMPlanner, FullDirection, Rolling, or multi-segment state-machine behavior.
- Do not add actuator calls, trajectory tracking, or any execution closed loop; `OBSERVE_ONLY` remains unchanged.
- Do not introduce third-party JavaScript, CSS, map, chart, CDN, or NuGet dependencies.
- Do not replace or version the visualization JSON contract; only add `smooth-start` to the existing static marker collection.
- Keep X and Y at equal world scale and label coordinates in meters.
- Keep all path strokes and marker geometry visually stable across world extents.
- Do not add overview pan, zoom, fullscreen, or layer toggles.
- Preserve unrelated dirty-worktree changes; stage only the files named by the current task.
---
## File Structure
- `ClumsyPilot/ParkrobTrajplanner/tarjplanner_movementtest/TrajectoryObservationStaticSnapshotBuilder.cs`
- Owns MovementTest-to-generic-visualization static geometry conversion; it will add the smoothed-path start marker.
- `ClumsyPilot/TrajectoryPlanningVisualization/Web/index.html`
- Owns overview DOM structure; it will add the screen-space legend container.
- `ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js`
- Owns browser rendering; it will add coordinate ticks, world/screen metrics, fixed-size markers, ordered paths, and legend population.
- `ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css`
- Owns semantic visual styling; it will define coordinate, path, marker, and legend appearance.
- `ClumsyPilot/tests/EMPlannerVerificationHost/TrajectoryObservationVisualizationChecks.cs`
- Verifies MovementTest snapshot semantics, including the new `smooth-start` marker.
- `ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs`
- Verifies embedded web assets expose coordinate, fixed-marker, layer, and legend capabilities.
- `ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/SampleSnapshotFactory.cs`
- Supplies deterministic overlapping coarse, smoothed, and EM geometry for the local browser smoke test.
## Task 1: Export the Smoothed-Path Start Marker
**Files:**
- Modify: `ClumsyPilot/ParkrobTrajplanner/tarjplanner_movementtest/TrajectoryObservationStaticSnapshotBuilder.cs:56-76`
- Test: `ClumsyPilot/tests/EMPlannerVerificationHost/TrajectoryObservationVisualizationChecks.cs:82-110`
**Interfaces:**
- Consumes: `TrajectoryObservationBootstrapResult.SmoothedPath.Path`, whose first element supplies `X` and `Y`.
- Produces: one existing-contract `VisualizationMarker` with `Id = "smooth-start"`, `Kind = "smooth-start"`, and `LabelChinese = "平滑路径起点"` when the smoothed path is non-empty.
- [ ] **Step 1: Write the failing snapshot assertions**
Add these assertions immediately after the existing `local-g2-path` assertion in `VerifiesStaticSnapshotExportsFrozenConfigurationAndGeometry`:
```csharp
VisualizationMarker smoothStart = snapshot.StaticMarkers.Single(x => x.Kind == "smooth-start");
Verification.Equal("平滑路径起点", smoothStart.LabelChinese,
"static snapshot labels the smoothed-path start");
Verification.NearlyEqual(bootstrap.SmoothedPath.Path[0].X, smoothStart.Position.X,
"smoothed-path start marker X matches the first smoothed point");
Verification.NearlyEqual(bootstrap.SmoothedPath.Path[0].Y, smoothStart.Position.Y,
"smoothed-path start marker Y matches the first smoothed point");
```
- [ ] **Step 2: Run the focused test and verify failure**
Run:
```powershell
dotnet run --project ClumsyPilot/tests/EMPlannerVerificationHost/EMPlannerVerificationHost.csproj -- trajectory-observation
```
Expected: FAIL in `VerifiesStaticSnapshotExportsFrozenConfigurationAndGeometry` because no marker has `Kind == "smooth-start"`.
- [ ] **Step 3: Add the minimal static marker**
Insert this block at the start of `CreateMarkers`, after `var markers = new List<VisualizationMarker>();` and before iterating direction segments:
```csharp
if (bootstrap.SmoothedPath.Path.Count > 0)
{
var start = bootstrap.SmoothedPath.Path[0];
markers.Add(new VisualizationMarker("smooth-start", "smooth-start",
"平滑路径起点", new VisualizationPoint(start.X, start.Y)));
}
```
- [ ] **Step 4: Run the focused test and verify success**
Run:
```powershell
dotnet run --project ClumsyPilot/tests/EMPlannerVerificationHost/EMPlannerVerificationHost.csproj -- trajectory-observation
```
Expected: `PASS trajectory-observation`.
- [ ] **Step 5: Commit the marker contract change**
```powershell
git add -- ClumsyPilot/ParkrobTrajplanner/tarjplanner_movementtest/TrajectoryObservationStaticSnapshotBuilder.cs ClumsyPilot/tests/EMPlannerVerificationHost/TrajectoryObservationVisualizationChecks.cs
git commit -m "feat: mark smoothed path start in EM overview"
```
## Task 2: Add the Metric Coordinate System
**Files:**
- Modify: `ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js:40-94`
- Modify: `ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css:41-57`
- Test: `ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs:21-43`
**Interfaces:**
- Consumes: the existing `getBounds()` result and `#world-overlay.getBoundingClientRect()`.
- Produces: `niceWorldStep(span, targetTicks)`, `worldMetrics(bounds)`, and `renderWorldCoordinates(bounds, metrics)`; later tasks consume `metrics.unitsPerPixel` for fixed-size markers.
- [ ] **Step 1: Write failing embedded-asset checks**
Add the following assertions in `WebAssetChecks.Run` after the existing `.world-current` check:
```csharp
Verification.True(css.Contains(".world-coordinate-grid"),
"overview exposes a metric grid semantic class");
Verification.True(css.Contains(".world-axis-zero"),
"overview exposes a zero-axis semantic class");
Verification.True(css.Contains(".world-axis-tick"),
"overview exposes coordinate tick semantics");
Verification.True(css.Contains(".world-axis-label"),
"overview exposes coordinate axis-label semantics");
Verification.True(js.Contains("function niceWorldStep("),
"overview selects readable metric tick steps");
Verification.True(js.Contains("function worldMetrics("),
"overview derives stable world-to-screen metrics");
Verification.True(js.Contains("function renderWorldCoordinates("),
"overview renders its own metric coordinate system");
Verification.True(js.Contains("X (m)") && js.Contains("Y (m)"),
"overview labels both metric axes");
```
- [ ] **Step 2: Run the web asset host and verify failure**
Run:
```powershell
dotnet run --project ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/TrajectoryPlanningVisualizationVerificationHost.csproj
```
Expected: FAIL with `overview exposes a metric grid semantic class`.
- [ ] **Step 3: Add world/screen metrics and coordinate rendering**
Insert the following functions after `drawOccupancy` and before `worldPath` in `app.js`:
```javascript
function niceWorldStep(span, targetTicks = 7) {
if (!finite(span) || span <= 0) return 1;
const rough = span / Math.max(2, targetTicks);
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
const normalized = rough / magnitude;
const factor = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return factor * magnitude;
}
function worldMetrics(bounds) {
const box = overlay.getBoundingClientRect();
const pixelWidth = box.width > 0 ? box.width : 800;
const pixelHeight = box.height > 0 ? box.height : 520;
const spanX = Math.max(bounds.xMax - bounds.xMin, 1e-9);
const spanY = Math.max(bounds.yMax - bounds.yMin, 1e-9);
return {
pixelWidth,
pixelHeight,
spanX,
spanY,
unitsPerPixel: Math.max(spanX / pixelWidth, spanY / pixelHeight, 1e-9)
};
}
function renderWorldCoordinates(bounds, metrics) {
const group = svg("g", { class: "world-coordinate-system", "aria-hidden": "true" });
const xStep = niceWorldStep(metrics.spanX, 8);
const yStep = niceWorldStep(metrics.spanY, 8);
const epsilon = Math.max(xStep, yStep) * 1e-9;
let count = 0;
for (let x = Math.ceil(bounds.xMin / xStep) * xStep; x <= bounds.xMax + epsilon && count < 100; x += xStep, count += 1) {
const normalized = Math.abs(x) < epsilon ? 0 : x;
group.append(svg("line", { x1: normalized, y1: -bounds.yMax, x2: normalized, y2: -bounds.yMin, class: "world-coordinate-grid" }));
const tick = svg("text", { x: normalized, y: -bounds.yMin - 7 * metrics.unitsPerPixel, class: "world-axis-tick", "font-size": 11 * metrics.unitsPerPixel, "text-anchor": "middle" });
tick.textContent = formatTick(normalized);
group.append(tick);
}
count = 0;
for (let y = Math.ceil(bounds.yMin / yStep) * yStep; y <= bounds.yMax + epsilon && count < 100; y += yStep, count += 1) {
const normalized = Math.abs(y) < epsilon ? 0 : y;
group.append(svg("line", { x1: bounds.xMin, y1: -normalized, x2: bounds.xMax, y2: -normalized, class: "world-coordinate-grid" }));
const tick = svg("text", { x: bounds.xMin + 6 * metrics.unitsPerPixel, y: -normalized - 3 * metrics.unitsPerPixel, class: "world-axis-tick", "font-size": 11 * metrics.unitsPerPixel, "text-anchor": "start" });
tick.textContent = formatTick(normalized);
group.append(tick);
}
if (bounds.xMin <= 0 && bounds.xMax >= 0)
group.append(svg("line", { x1: 0, y1: -bounds.yMax, x2: 0, y2: -bounds.yMin, class: "world-axis-zero" }));
if (bounds.yMin <= 0 && bounds.yMax >= 0)
group.append(svg("line", { x1: bounds.xMin, y1: 0, x2: bounds.xMax, y2: 0, class: "world-axis-zero" }));
const xLabel = svg("text", { x: bounds.xMax - 8 * metrics.unitsPerPixel, y: -bounds.yMin - 8 * metrics.unitsPerPixel, class: "world-axis-label", "font-size": 12 * metrics.unitsPerPixel, "text-anchor": "end" });
xLabel.textContent = "X (m)";
const yLabel = svg("text", { x: bounds.xMin + 8 * metrics.unitsPerPixel, y: -bounds.yMax + 16 * metrics.unitsPerPixel, class: "world-axis-label", "font-size": 12 * metrics.unitsPerPixel, "text-anchor": "start" });
yLabel.textContent = "Y (m)";
group.append(xLabel, yLabel);
overlay.append(group);
}
```
Replace the first statement sequence in `renderWorld` with:
```javascript
const bounds = getBounds();
const metrics = worldMetrics(bounds);
drawOccupancy(bounds);
overlay.replaceChildren();
overlay.setAttribute("viewBox", `${bounds.xMin} ${-bounds.yMax} ${bounds.xMax - bounds.xMin} ${bounds.yMax - bounds.yMin}`);
overlay.setAttribute("preserveAspectRatio", "xMidYMid meet");
renderWorldCoordinates(bounds, metrics);
```
Add the following CSS after `.world-line`:
```css
.world-coordinate-grid { stroke: #dfe4e8; stroke-opacity: .62; stroke-width: .7; vector-effect: non-scaling-stroke; }
.world-axis-zero { stroke: #7c8791; stroke-opacity: .72; stroke-width: 1.1; vector-effect: non-scaling-stroke; }
.world-axis-tick { fill: #68737d; font-family: ui-monospace, Consolas, monospace; }
.world-axis-label { fill: #48535d; font-family: "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; font-weight: 600; }
```
- [ ] **Step 4: Run the web asset host and verify success**
Run:
```powershell
dotnet run --project ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/TrajectoryPlanningVisualizationVerificationHost.csproj
```
Expected: `PASS trajectory-planning-visualization`.
- [ ] **Step 5: Commit the coordinate system**
```powershell
git add -- ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs
git commit -m "feat: add metric axes to trajectory overview"
```
## Task 3: Fix Path Layering and Screen-Size Markers
**Files:**
- Modify: `ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js:52-93`
- Modify: `ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css:1-9,41-57`
- Test: `ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs:21-43`
**Interfaces:**
- Consumes: `worldMetrics(bounds).unitsPerPixel` from Task 2 and existing polyline/marker kinds.
- Produces: `lineKind(line)`, `markerTransform(position, unitsPerPixel, headingRadians)`, and `appendWorldMarker(marker, frame, metrics)`; the current EM trajectory is rendered after every static/reference layer.
- [ ] **Step 1: Add failing checks for fixed-size rendering and semantic layers**
Add these assertions after the coordinate checks in `WebAssetChecks.Run`:
```csharp
Verification.True(css.Contains("--world-smoothed-path"),
"overview defines a dedicated smoothed-path color");
Verification.True(css.Contains("--world-em-trajectory"),
"overview defines a dedicated EM-path color");
Verification.True(css.Contains(".world-marker-shape"),
"overview marker shapes use a shared screen-space class");
Verification.True(css.Contains(".marker-smooth-start"),
"overview styles the smoothed-path start marker");
Verification.True(js.Contains("function markerTransform("),
"overview anchors fixed-size markers in world coordinates");
Verification.True(js.Contains("function appendWorldMarker("),
"overview centralizes fixed-size marker rendering");
Verification.True(!js.Contains("const corners = [[.38, .18]"),
"vehicle marker no longer uses a large world-size rectangle");
Verification.True(!css.Contains(".marker-vehicle .vehicle-outline { fill: none; stroke: #20252b; stroke-width: .7; }"),
"vehicle outline no longer uses a world-scale heavy stroke");
Verification.True(!css.Contains(".marker-vehicle .vehicle-heading { stroke: #20252b; stroke-width: .8; }"),
"vehicle marker no longer uses world-scale heavy strokes");
```
- [ ] **Step 2: Run the web asset host and verify failure**
Run:
```powershell
dotnet run --project ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/TrajectoryPlanningVisualizationVerificationHost.csproj
```
Expected: FAIL with `overview defines a dedicated smoothed-path color`.
- [ ] **Step 3: Define path and marker visual variables**
Add these variables to `:root` without changing the chart color variables:
```css
--world-coarse-path: #7e8790;
--world-smoothed-path: #168b83;
--world-active-segment: #3f78a8;
--world-em-trajectory: #df6b1f;
```
Replace the existing world path and marker CSS rules with:
```css
.world-line { fill: none; vector-effect: non-scaling-stroke; }
.world-static { stroke: #8d959d; stroke-opacity: .38; stroke-width: 1.4; }
.world-future { stroke: #b6bec6; stroke-dasharray: 5 4; stroke-width: 1.2; }
.world-active { stroke: var(--world-active-segment); stroke-width: 1.5; }
.world-previous { stroke: #887493; stroke-opacity: .66; stroke-dasharray: 7 5; stroke-width: 2; }
.world-coarse { stroke: var(--world-coarse-path); stroke-opacity: .40; stroke-dasharray: 7 5; stroke-width: 2; }
.world-local-g2 { stroke: var(--world-smoothed-path); stroke-opacity: .65; stroke-width: 4; }
.world-segment-active { stroke: var(--world-active-segment); stroke-opacity: .50; stroke-width: 1.5; }
.world-direction-inactive { stroke: #aab2b9; stroke-opacity: .30; stroke-dasharray: 5 5; stroke-width: 1.1; }
.world-current { stroke: var(--world-em-trajectory); stroke-opacity: .96; stroke-width: 2.4; }
.world-marker-shape { vector-effect: non-scaling-stroke; }
.world-marker-label { fill: #20252b; font: 11px/1.2 "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; paint-order: stroke; stroke: rgba(255, 255, 255, .88); stroke-width: 3px; stroke-linejoin: round; }
.world-marker { fill: #d87918; stroke: #ffffff; stroke-width: 1.4; }
.marker-vehicle .vehicle-outline { fill: rgba(32, 37, 43, .16); stroke: #20252b; stroke-width: 1.5; }
.marker-vehicle .vehicle-heading { stroke: #20252b; stroke-width: 1.5; stroke-linecap: round; vector-effect: non-scaling-stroke; }
.marker-smooth-start { fill: #2f8f56; stroke: #ffffff; stroke-width: 1.5; }
.marker-plan-start { fill: #ffffff; stroke: #2f8f56; stroke-width: 2; }
.marker-gear-switch { fill: #d87918; stroke: #ffffff; stroke-width: 1.5; }
.marker-final-goal { fill: #1769aa; stroke: #ffffff; stroke-width: 1.5; }
.world-empty-state { fill: #66707a; font: 14px/1.2 "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; }
```
- [ ] **Step 4: Replace world marker and layer rendering**
Add the following functions after `worldPath`:
```javascript
function lineKind(line) { return String(line && line.kind || "").toLowerCase(); }
function markerTransform(position, unitsPerPixel, headingRadians = 0) {
const headingDegrees = (finite(headingRadians) ? headingRadians : 0) * 180 / Math.PI;
return `translate(${position.x} ${-position.y}) rotate(${-headingDegrees}) scale(${unitsPerPixel})`;
}
function appendWorldMarker(marker, frame, metrics) {
if (!marker.position || !finite(marker.position.x) || !finite(marker.position.y)) return;
const kind = String(marker.kind || "").toLowerCase();
const pose = frame.vehiclePose;
const heading = kind === "vehicle" && pose && finite(pose.headingRadians) ? pose.headingRadians : 0;
const group = svg("g", {
class: `world-marker-group marker-${kind || "generic"}`,
transform: markerTransform(marker.position, metrics.unitsPerPixel, heading)
});
if (kind === "vehicle") {
group.append(svg("path", { d: "M -9 -5 L 5 -5 L 9 0 L 5 5 L -9 5 Z", class: "world-marker-shape vehicle-outline" }));
group.append(svg("line", { x1: -4, y1: 0, x2: 7, y2: 0, class: "vehicle-heading" }));
} else if (kind === "smooth-start") {
group.append(svg("circle", { cx: 0, cy: 0, r: 5, class: "world-marker-shape marker-smooth-start" }));
} else if (kind === "plan-start") {
group.append(svg("circle", { cx: 0, cy: 0, r: 5, class: "world-marker-shape marker-plan-start" }));
} else if (kind === "gear-switch" || kind === "gear-switch-end") {
group.append(svg("path", { d: "M 0 -6 L 6 0 L 0 6 L -6 0 Z", class: "world-marker-shape marker-gear-switch" }));
} else if (kind === "final-goal") {
group.append(svg("path", { d: "M -6 -6 L 6 -6 L 6 6 L -6 6 Z", class: "world-marker-shape marker-final-goal" }));
} else {
group.append(svg("path", { d: "M 0 -6 L 6 0 L 0 6 L -6 0 Z", class: "world-marker-shape world-marker" }));
}
overlay.append(group);
const labelY = kind === "plan-start" ? 17 : -9;
const labelGroup = svg("g", { transform: `translate(${marker.position.x} ${-marker.position.y}) scale(${metrics.unitsPerPixel})` });
const label = svg("text", { x: 10, y: labelY, class: "world-marker-label" });
label.textContent = marker.labelChinese || "";
labelGroup.append(label);
overlay.append(labelGroup);
}
```
Replace the complete `renderWorld` function with:
```javascript
function renderWorld() {
const bounds = getBounds();
const metrics = worldMetrics(bounds);
drawOccupancy(bounds);
overlay.replaceChildren();
overlay.setAttribute("viewBox", `${bounds.xMin} ${-bounds.yMax} ${bounds.xMax - bounds.xMin} ${bounds.yMax - bounds.yMin}`);
overlay.setAttribute("preserveAspectRatio", "xMidYMid meet");
renderWorldCoordinates(bounds, metrics);
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 }));
};
const staticLines = safeArray(snapshot.staticPolylines);
staticLines.filter(line => lineKind(line) === "global" || lineKind(line) === "coarse")
.forEach(line => addLine(line, "world-coarse"));
staticLines.filter(line => lineKind(line).includes("local-g2") || lineKind(line).includes("g2"))
.forEach(line => addLine(line, "world-local-g2"));
staticLines.filter(line => lineKind(line) !== "global" && lineKind(line) !== "coarse" && !lineKind(line).includes("local-g2") && !lineKind(line).includes("g2"))
.forEach(line => addLine(line, "world-static"));
safeArray(snapshot.directionSegments).forEach(segment => addLine(segment,
segment.segmentIndex === frame.activeSegmentIndex ? "world-segment-active" : "world-direction-inactive"));
const dynamicLines = safeArray(frame.dynamicPolylines);
dynamicLines.filter(line => lineKind(line) === "active-segment")
.forEach(line => addLine(line, "world-segment-active"));
dynamicLines.filter(line => lineKind(line) === "previous" || line.lineStyle === 1)
.forEach(line => addLine(line, "world-previous"));
dynamicLines.filter(line => lineKind(line) !== "active-segment" && lineKind(line) !== "previous" && line.lineStyle !== 1)
.forEach(line => addLine(line, "world-current"));
const allLines = staticLines.concat(safeArray(snapshot.directionSegments), dynamicLines);
const hasPath = allLines.some(line => safeArray(line && line.points)
.some(point => point && finite(point.x) && finite(point.y)));
safeArray(snapshot.staticMarkers).concat(safeArray(frame.dynamicMarkers))
.forEach(marker => appendWorldMarker(marker, frame, metrics));
if (!hasPath) {
const emptyGroup = svg("g", { transform: `translate(${(bounds.xMin + bounds.xMax) / 2} ${-((bounds.yMin + bounds.yMax) / 2)}) scale(${metrics.unitsPerPixel})` });
const empty = svg("text", { x: 0, y: 0, class: "world-empty-state", "text-anchor": "middle" });
empty.textContent = "未收到 Local G2 路径";
emptyGroup.append(empty);
overlay.append(emptyGroup);
}
}
```
Add resize-aware redraw beside the existing window error listeners:
```javascript
let overviewResizeFrame = 0;
window.addEventListener("resize", () => {
if (overviewResizeFrame || state.activeTab !== "overview" || !state.redrawEnabled.overview) return;
overviewResizeFrame = window.requestAnimationFrame(() => {
overviewResizeFrame = 0;
renderWorld();
});
});
```
- [ ] **Step 5: Run the web asset host and verify success**
Run:
```powershell
dotnet run --project ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/TrajectoryPlanningVisualizationVerificationHost.csproj
```
Expected: `PASS trajectory-planning-visualization`.
- [ ] **Step 6: Commit fixed markers and path layering**
```powershell
git add -- ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs
git commit -m "fix: clarify EM overview path layers"
```
## Task 4: Add a Data-Driven World Legend
**Files:**
- Modify: `ClumsyPilot/TrajectoryPlanningVisualization/Web/index.html:21-26`
- Modify: `ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js:7-17,52-94`
- Modify: `ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css:22-24,41-70`
- Test: `ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs:16-43`
- Test fixture: `ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/SampleSnapshotFactory.cs:11-33`
**Interfaces:**
- Consumes: existing `LegendChinese`, `Kind`, `staticPolylines`, `directionSegments`, `dynamicPolylines`, `staticMarkers`, and `dynamicMarkers`.
- Produces: `renderWorldLegend(snapshot, frame)` and `#world-legend`; entries are deduplicated by semantic key and only emitted when corresponding data exists.
- [ ] **Step 1: Write failing legend asset checks**
Add these assertions in `WebAssetChecks.Run` after the `world-overlay` assertion:
```csharp
Verification.True(html.Contains("id=\"world-legend\""),
"overview contains a screen-space legend host");
Verification.True(html.Contains("aria-label=\"路径图例\""),
"overview legend has a Chinese accessible name");
Verification.True(css.Contains(".world-legend"),
"overview legend has a screen-space visual container");
Verification.True(css.Contains(".world-legend-swatch"),
"overview legend exposes semantic line and marker samples");
Verification.True(js.Contains("function renderWorldLegend("),
"overview builds legend entries from current snapshot data");
Verification.True(js.Contains("legendChinese"),
"overview reuses exported Chinese polyline legends");
```
Add these assertions in `BuildsDeterministicSmokeSnapshots` after creating `staticSnapshot`:
```csharp
Verification.True(staticSnapshot.StaticPolylines.Any(line => line.Kind == "coarse"),
"smoke snapshot exposes the coarse comparison path");
Verification.True(staticSnapshot.StaticPolylines.Any(line => line.Kind == "local-g2"),
"smoke snapshot exposes the smoothed comparison path");
Verification.True(staticSnapshot.StaticMarkers.Any(marker => marker.Kind == "smooth-start"),
"smoke snapshot exposes the smoothed-path start marker");
```
- [ ] **Step 2: Run the web asset host and verify failure**
Run:
```powershell
dotnet run --project ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/TrajectoryPlanningVisualizationVerificationHost.csproj
```
Expected: FAIL with `smoke snapshot exposes the coarse comparison path`; the legend assertions also remain red until Steps 36 are complete.
- [ ] **Step 3: Add the accessible legend host**
Add this element inside `.world-stack`, after `#world-overlay`:
```html
<aside id="world-legend" class="world-legend" aria-label="路径图例" hidden></aside>
```
- [ ] **Step 4: Expand the deterministic smoke snapshot**
Replace the single static `global` polyline in `SampleSnapshotFactory.CreateStaticSnapshot` with:
```csharp
new[]
{
new VisualizationPolyline("coarse-path", "粗路径", "coarse", VisualizationLineStyle.Dashed,
Points((0d, 1d), (3d, 1.2d), (6d, 1.85d), (8d, 4.2d), (11d, 6d))),
new VisualizationPolyline("local-g2-path", "完整 Local G2 路径", "local-g2", VisualizationLineStyle.Solid,
Points((0d, 1d), (3d, 1d), (6d, 2d), (8d, 4d), (11d, 6d)))
},
```
Add this marker as the first item in the static marker array:
```csharp
new VisualizationMarker("smooth-start", "smooth-start", "平滑路径起点", new VisualizationPoint(0d, 1d)),
```
- [ ] **Step 5: Implement data-driven legend population**
Add the legend reference after the existing overlay reference:
```javascript
const worldLegend = document.getElementById("world-legend");
```
Add this function after `appendWorldMarker`:
```javascript
function renderWorldLegend(snapshot, frame) {
if (!worldLegend) return;
worldLegend.replaceChildren();
const staticLines = safeArray(snapshot.staticPolylines);
const directionSegments = safeArray(snapshot.directionSegments);
const dynamicLines = safeArray(frame.dynamicPolylines);
const markers = safeArray(snapshot.staticMarkers).concat(safeArray(frame.dynamicMarkers));
const entries = [];
const seen = new Set();
const add = (key, label, swatchClass) => {
if (seen.has(key)) return;
seen.add(key);
entries.push({ label, swatchClass });
};
const coarse = staticLines.find(line => lineKind(line) === "global" || lineKind(line) === "coarse");
const smooth = staticLines.find(line => lineKind(line).includes("local-g2") || lineKind(line).includes("g2"));
const active = dynamicLines.find(line => lineKind(line) === "active-segment");
const previous = dynamicLines.find(line => lineKind(line) === "previous" || line.lineStyle === 1);
const current = dynamicLines.find(line => lineKind(line) !== "active-segment" && lineKind(line) !== "previous" && line.lineStyle !== 1);
if (coarse) add("coarse", coarse.legendChinese || "粗路径", "legend-line legend-coarse");
if (smooth) add("smooth", smooth.legendChinese || "完整 Local G2 路径", "legend-line legend-smooth");
if (active || directionSegments.length)
add("segment", active && active.legendChinese || "当前方向段", "legend-line legend-segment");
if (previous) add("previous", previous.legendChinese || "上一条 EM 轨迹", "legend-line legend-previous");
if (current) add("current", current.legendChinese || "当前 EM 轨迹", "legend-line legend-current");
if (markers.some(marker => lineKind(marker) === "smooth-start"))
add("smooth-start", "平滑路径起点", "legend-marker legend-smooth-start");
if (markers.some(marker => lineKind(marker) === "plan-start"))
add("plan-start", "EM 规划起点", "legend-marker legend-plan-start");
if (markers.some(marker => lineKind(marker) === "vehicle"))
add("vehicle", "当前车辆", "legend-marker legend-vehicle");
if (markers.some(marker => lineKind(marker) === "gear-switch" || lineKind(marker) === "gear-switch-end"))
add("gear", "换向点 / s_end", "legend-marker legend-gear");
if (markers.some(marker => lineKind(marker) === "final-goal"))
add("goal", "平滑路径终点", "legend-marker legend-goal");
entries.forEach(entry => {
const item = element("div", "world-legend-item");
item.append(element("span", "world-legend-swatch " + entry.swatchClass),
element("span", "world-legend-label", entry.label));
worldLegend.append(item);
});
worldLegend.hidden = entries.length === 0;
}
```
Add this call in `renderWorld` after all markers are appended and before the empty-state branch:
```javascript
renderWorldLegend(snapshot, frame);
```
- [ ] **Step 6: Style the legend with matching semantic samples**
Add this CSS after the world marker rules:
```css
.world-legend { position: absolute; top: 14px; right: 14px; z-index: 3; display: grid; gap: 6px; min-width: 154px; max-width: min(260px, calc(100% - 28px)); padding: 10px 12px; border: 1px solid rgba(112, 123, 133, .38); border-radius: 6px; background: rgba(255, 255, 255, .88); box-shadow: 0 2px 10px rgba(32, 37, 43, .08); color: #36404a; font-size: 12px; pointer-events: none; backdrop-filter: blur(2px); }
.world-legend[hidden] { display: none; }
.world-legend-item { display: grid; grid-template-columns: 30px minmax(0, 1fr); align-items: center; gap: 8px; min-height: 16px; }
.world-legend-swatch { display: block; justify-self: center; }
.legend-line { width: 28px; height: 0; border-top-style: solid; }
.legend-coarse { border-top: 2px dashed rgba(126, 135, 144, .62); }
.legend-smooth { border-top: 4px solid rgba(22, 139, 131, .65); }
.legend-segment { border-top: 2px solid rgba(63, 120, 168, .50); }
.legend-previous { border-top: 2px dashed rgba(136, 116, 147, .72); }
.legend-current { border-top: 3px solid rgba(223, 107, 31, .96); }
.legend-marker { width: 11px; height: 11px; border: 1.5px solid #ffffff; box-shadow: 0 0 0 1px rgba(32, 37, 43, .22); }
.legend-smooth-start { border-radius: 50%; background: #2f8f56; }
.legend-plan-start { border: 2px solid #2f8f56; border-radius: 50%; background: #ffffff; }
.legend-vehicle { width: 17px; height: 9px; border: 1.5px solid #20252b; border-radius: 2px; background: rgba(32, 37, 43, .16); }
.legend-gear { transform: rotate(45deg); background: #d87918; }
.legend-goal { background: #1769aa; }
.world-legend-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
```
Replace the existing mobile media query with the following complete rule:
```css
@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; }
.world-legend { top: 8px; right: 8px; min-width: 138px; padding: 8px 9px; font-size: 11px; }
.config-entry { grid-template-columns: 1fr 1fr; }
}
```
- [ ] **Step 7: Run focused and integration verification**
Run:
```powershell
dotnet run --project ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/TrajectoryPlanningVisualizationVerificationHost.csproj
dotnet run --project ClumsyPilot/tests/EMPlannerVerificationHost/EMPlannerVerificationHost.csproj -- trajectory-observation
dotnet build ClumsyPilot/ClumsyPilot.csproj -p:ExcludeLegacyAutoAvoidance=true --no-restore
```
Expected output contains:
```text
PASS trajectory-planning-visualization
PASS trajectory-observation
Build succeeded.
```
- [ ] **Step 8: Run the local visual smoke test**
Run the sample server for 60 seconds:
```powershell
dotnet run --project ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/TrajectoryPlanningVisualizationVerificationHost.csproj -- --smoke-seconds 60
```
Open the printed loopback URL and verify every item:
- the vehicle is approximately 1820 screen pixels long and does not cover the route;
- X and Y retain equal spatial scale;
- metric ticks, grid, `X (m)`, and `Y (m)` are readable;
- coarse gray dashes, wide translucent teal smooth path, and narrow orange EM path remain visible where they overlap;
- the legend matches only elements visible in the snapshot;
- smoothed start, final goal, current vehicle, and EM plan-start markers are visible;
- no browser console error or “页面绘图异常” status appears.
- [ ] **Step 9: Check scoped diff hygiene and commit**
Run:
```powershell
git diff --check -- ClumsyPilot/TrajectoryPlanningVisualization/Web/index.html ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/SampleSnapshotFactory.cs
git status --short -- ClumsyPilot/TrajectoryPlanningVisualization/Web/index.html ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/SampleSnapshotFactory.cs
git add -- ClumsyPilot/TrajectoryPlanningVisualization/Web/index.html ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/SampleSnapshotFactory.cs
git commit -m "feat: add legend to EM path overview"
```
Expected: `git diff --check` emits no error, scoped status lists only the intended files before staging, and the commit contains only those five paths.
## Final Verification
After all task commits, run:
```powershell
dotnet run --project ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/TrajectoryPlanningVisualizationVerificationHost.csproj
dotnet run --project ClumsyPilot/tests/EMPlannerVerificationHost/EMPlannerVerificationHost.csproj -- trajectory-observation
dotnet build ClumsyPilot/ClumsyPilot.csproj -p:ExcludeLegacyAutoAvoidance=true --no-restore
git diff --check -- ClumsyPilot/ParkrobTrajplanner/tarjplanner_movementtest/TrajectoryObservationStaticSnapshotBuilder.cs ClumsyPilot/TrajectoryPlanningVisualization/Web/index.html ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css ClumsyPilot/tests/EMPlannerVerificationHost/TrajectoryObservationVisualizationChecks.cs ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/SampleSnapshotFactory.cs
```
Expected: both verification hosts print their PASS lines, the main project reports `Build succeeded.`, and the scoped diff check produces no errors.