diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css index 646265b..5eb7bcb 100644 --- a/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.css @@ -65,8 +65,31 @@ main > section[hidden] { display: none !important; } .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-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; } .world-empty-state { fill: #66707a; font: 14px/1.2 "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; } .history-panel, .configuration-panel { margin-bottom: 26px; } #cycle-history { overflow-x: auto; } table { width: 100%; border-collapse: collapse; font-size: 12px; } th, td { padding: 6px 8px; border-bottom: 1px solid var(--grid); text-align: left; white-space: nowrap; } th { color: #59636d; font-weight: 600; } .config-group { margin: 13px 0; border-top: 1px solid var(--grid); } .config-group h3 { margin: 8px 0; font-size: 13px; } .config-entry { display: grid; grid-template-columns: minmax(110px, 1fr) minmax(130px, 1fr) minmax(90px, 1fr) 70px; gap: 8px; padding: 4px 0; border-bottom: 1px dotted #e5e8eb; } .raw { color: #66707a; font-family: ui-monospace, Consolas, monospace; } .failure { color: var(--failure); } .notice { color: var(--handoff); } -@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; } .config-entry { grid-template-columns: 1fr 1fr; } } +@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; } +} diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js index 56f279c..721832b 100644 --- a/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Web/app.js @@ -8,6 +8,7 @@ const liveState = document.getElementById("live-state"); const occupancyCanvas = document.getElementById("occupancy-grid"); const overlay = document.getElementById("world-overlay"); + const worldLegend = document.getElementById("world-legend"); function authorized(path) { return path + "?token=" + encodeURIComponent(token); } function finite(value) { return typeof value === "number" && Number.isFinite(value); } @@ -141,6 +142,50 @@ overlay.append(labelGroup); } + 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; + } + function renderWorld() { const bounds = getBounds(); const metrics = worldMetrics(bounds); @@ -176,6 +221,7 @@ .some(point => point && finite(point.x) && finite(point.y))); safeArray(snapshot.staticMarkers).concat(safeArray(frame.dynamicMarkers)) .forEach(marker => appendWorldMarker(marker, frame, metrics)); + renderWorldLegend(snapshot, frame); 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" }); diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Web/index.html b/ClumsyPilot/TrajectoryPlanningVisualization/Web/index.html index af7007c..838e8c4 100644 --- a/ClumsyPilot/TrajectoryPlanningVisualization/Web/index.html +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Web/index.html @@ -23,6 +23,7 @@