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