diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css index 1120262..331bd05 100644 --- a/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css @@ -39,6 +39,10 @@ main > section[hidden] { display: none !important; } .chart-annotation-point { fill: var(--handoff); stroke: #ffffff; stroke-width: .6; } .chart-annotation-label { fill: var(--handoff); font: 10px sans-serif; } .world-line { fill: none; vector-effect: non-scaling-stroke; } +.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; } .world-static { stroke: #8d959d; stroke-width: .9; } .world-future { stroke: #b6bec6; stroke-dasharray: 5 4; stroke-width: .85; } .world-active { stroke: #1769aa; stroke-width: 1.1; } diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js index c2ab419..40992c9 100644 --- a/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js @@ -49,9 +49,62 @@ for (let row = 0; row < grid.rows; row += 1) for (let column = 0; column < grid.columns; column += 1) { const index = row * grid.columns + column; if ((bits[index >> 3] & (1 << (index & 7))) !== 0) { const x = offsetX + (grid.bounds.xMin + column * grid.resolutionMeters - bounds.xMin) * factor; const y = offsetY + (bounds.yMax - (grid.bounds.yMin + (row + 1) * grid.resolutionMeters)) * factor; context.fillRect(x, y, grid.resolutionMeters * factor, grid.resolutionMeters * factor); } } } + 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); + } + function worldPath(points) { return safeArray(points).filter(point => finite(point.x) && finite(point.y)).map((point, index) => (index ? "L" : "M") + point.x + " " + (-point.y)).join(" "); } function renderWorld() { const bounds = getBounds(); drawOccupancy(bounds); overlay.replaceChildren(); overlay.setAttribute("viewBox", `${bounds.xMin} ${-bounds.yMax} ${bounds.xMax - bounds.xMin} ${bounds.yMax - bounds.yMin}`); overlay.setAttribute("preserveAspectRatio", "xMidYMid meet"); + const metrics = worldMetrics(bounds); 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 staticClass = line => { const kind = String(line.kind || "").toLowerCase(); if (kind === "global" || kind === "coarse") return "world-coarse"; if (kind.includes("local-g2") || kind.includes("g2")) return "world-local-g2"; return "world-static"; }; diff --git a/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs index 4a9bde5..c253a1c 100644 --- a/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs +++ b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/WebAssetChecks.cs @@ -29,6 +29,22 @@ internal static class WebAssetChecks Verification.True(css.Contains(".world-local-g2"), "Local G2 semantic class"); Verification.True(css.Contains(".world-segment-active"), "active direction semantic class"); Verification.True(css.Contains(".world-current"), "current trajectory semantic class"); + 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"); Verification.True(css.Contains(".marker-vehicle"), "vehicle marker semantic class"); Verification.True(css.Contains(".marker-plan-start"), "plan start marker semantic class"); Verification.True(css.Contains(".marker-gear-switch"), "gear-switch marker semantic class");