diff --git a/docs/superpowers/plans/2026-08-09-movementtest-web-path-visualization.md b/docs/superpowers/plans/2026-08-09-movementtest-web-path-visualization.md new file mode 100644 index 0000000..078c399 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-movementtest-web-path-visualization.md @@ -0,0 +1,694 @@ +# 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();` 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 3–6 are complete. + +- [ ] **Step 3: Add the accessible legend host** + +Add this element inside `.world-stack`, after `#world-overlay`: + +```html + +``` + +- [ ] **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 18–20 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.