36 KiB
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_ONLYremains 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-startto 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-startmarker.
- Verifies MovementTest snapshot semantics, including the new
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 suppliesXandY. -
Produces: one existing-contract
VisualizationMarkerwithId = "smooth-start",Kind = "smooth-start", andLabelChinese = "平滑路径起点"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:
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:
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:
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:
dotnet run --project ClumsyPilot/tests/EMPlannerVerificationHost/EMPlannerVerificationHost.csproj -- trajectory-observation
Expected: PASS trajectory-observation.
- Step 5: Commit the marker contract change
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), andrenderWorldCoordinates(bounds, metrics); later tasks consumemetrics.unitsPerPixelfor fixed-size markers. -
Step 1: Write failing embedded-asset checks
Add the following assertions in WebAssetChecks.Run after the existing .world-current check:
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:
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:
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:
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:
.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:
dotnet run --project ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/TrajectoryPlanningVisualizationVerificationHost.csproj
Expected: PASS trajectory-planning-visualization.
- Step 5: Commit the coordinate system
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).unitsPerPixelfrom Task 2 and existing polyline/marker kinds. -
Produces:
lineKind(line),markerTransform(position, unitsPerPixel, headingRadians), andappendWorldMarker(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:
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:
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:
--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:
.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:
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:
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:
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:
dotnet run --project ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/TrajectoryPlanningVisualizationVerificationHost.csproj
Expected: PASS trajectory-planning-visualization.
- Step 6: Commit fixed markers and path layering
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, anddynamicMarkers. -
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:
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:
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:
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:
<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:
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:
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:
const worldLegend = document.getElementById("world-legend");
Add this function after appendWorldMarker:
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:
renderWorldLegend(snapshot, frame);
- Step 6: Style the legend with matching semantic samples
Add this CSS after the world marker rules:
.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:
@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:
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:
PASS trajectory-planning-visualization
PASS trajectory-observation
Build succeeded.
- Step 8: Run the local visual smoke test
Run the sample server for 60 seconds:
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), andY (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:
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:
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.