# 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.