docs: plan MovementTest path visualization refresh
This commit is contained in:
@@ -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<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 3–6 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 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.
|
||||
Reference in New Issue
Block a user