feat: add legend to EM path overview
This commit is contained in:
@@ -65,8 +65,31 @@ main > section[hidden] { display: none !important; }
|
|||||||
.marker-plan-start { fill: #ffffff; stroke: #2f8f56; stroke-width: 2; }
|
.marker-plan-start { fill: #ffffff; stroke: #2f8f56; stroke-width: 2; }
|
||||||
.marker-gear-switch { fill: #d87918; stroke: #ffffff; stroke-width: 1.5; }
|
.marker-gear-switch { fill: #d87918; stroke: #ffffff; stroke-width: 1.5; }
|
||||||
.marker-final-goal { fill: #1769aa; 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; }
|
.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; }
|
.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; }
|
.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); }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
const liveState = document.getElementById("live-state");
|
const liveState = document.getElementById("live-state");
|
||||||
const occupancyCanvas = document.getElementById("occupancy-grid");
|
const occupancyCanvas = document.getElementById("occupancy-grid");
|
||||||
const overlay = document.getElementById("world-overlay");
|
const overlay = document.getElementById("world-overlay");
|
||||||
|
const worldLegend = document.getElementById("world-legend");
|
||||||
|
|
||||||
function authorized(path) { return path + "?token=" + encodeURIComponent(token); }
|
function authorized(path) { return path + "?token=" + encodeURIComponent(token); }
|
||||||
function finite(value) { return typeof value === "number" && Number.isFinite(value); }
|
function finite(value) { return typeof value === "number" && Number.isFinite(value); }
|
||||||
@@ -141,6 +142,50 @@
|
|||||||
overlay.append(labelGroup);
|
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() {
|
function renderWorld() {
|
||||||
const bounds = getBounds();
|
const bounds = getBounds();
|
||||||
const metrics = worldMetrics(bounds);
|
const metrics = worldMetrics(bounds);
|
||||||
@@ -176,6 +221,7 @@
|
|||||||
.some(point => point && finite(point.x) && finite(point.y)));
|
.some(point => point && finite(point.x) && finite(point.y)));
|
||||||
safeArray(snapshot.staticMarkers).concat(safeArray(frame.dynamicMarkers))
|
safeArray(snapshot.staticMarkers).concat(safeArray(frame.dynamicMarkers))
|
||||||
.forEach(marker => appendWorldMarker(marker, frame, metrics));
|
.forEach(marker => appendWorldMarker(marker, frame, metrics));
|
||||||
|
renderWorldLegend(snapshot, frame);
|
||||||
if (!hasPath) {
|
if (!hasPath) {
|
||||||
const emptyGroup = svg("g", { transform: `translate(${(bounds.xMin + bounds.xMax) / 2} ${-((bounds.yMin + bounds.yMax) / 2)}) scale(${metrics.unitsPerPixel})` });
|
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" });
|
const empty = svg("text", { x: 0, y: 0, class: "world-empty-state", "text-anchor": "middle" });
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<div class="world-stack">
|
<div class="world-stack">
|
||||||
<canvas id="occupancy-grid" aria-label="占用栅格底图"></canvas>
|
<canvas id="occupancy-grid" aria-label="占用栅格底图"></canvas>
|
||||||
<svg id="world-overlay" role="img" aria-label="全局路径与当前规划段"></svg>
|
<svg id="world-overlay" role="img" aria-label="全局路径与当前规划段"></svg>
|
||||||
|
<aside id="world-legend" class="world-legend" aria-label="路径图例" hidden></aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="ls-st" hidden aria-label="LS / ST">
|
<section id="ls-st" hidden aria-label="LS / ST">
|
||||||
|
|||||||
+4
-1
@@ -17,11 +17,14 @@ internal static class SampleSnapshotFactory
|
|||||||
new byte[] { 0b00100001, 0b00000100, 0b00000010 }),
|
new byte[] { 0b00100001, 0b00000100, 0b00000010 }),
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
new VisualizationPolyline("global", "全局参考路径", "global", VisualizationLineStyle.Solid,
|
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)))
|
Points((0d, 1d), (3d, 1d), (6d, 2d), (8d, 4d), (11d, 6d)))
|
||||||
},
|
},
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
|
new VisualizationMarker("smooth-start", "smooth-start", "平滑路径起点", new VisualizationPoint(0d, 1d)),
|
||||||
new VisualizationMarker("gear-1", "gear-switch", "换向 1", new VisualizationPoint(6d, 2d)),
|
new VisualizationMarker("gear-1", "gear-switch", "换向 1", new VisualizationPoint(6d, 2d)),
|
||||||
new VisualizationMarker("gear-1-end", "gear-switch-end", "换向点 1 / s_end", new VisualizationPoint(6d, 2d)),
|
new VisualizationMarker("gear-1-end", "gear-switch-end", "换向点 1 / s_end", new VisualizationPoint(6d, 2d)),
|
||||||
new VisualizationMarker("final-goal", "final-goal", "终点 / s_end", new VisualizationPoint(11d, 6d))
|
new VisualizationMarker("final-goal", "final-goal", "终点 / s_end", new VisualizationPoint(11d, 6d))
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ internal static class WebAssetChecks
|
|||||||
Verification.True(js.Contains("未收到 Local G2 路径"), "empty overview names the missing source");
|
Verification.True(js.Contains("未收到 Local G2 路径"), "empty overview names the missing source");
|
||||||
Verification.True(html.Contains("occupancy-grid"), "single occupancy canvas exists");
|
Verification.True(html.Contains("occupancy-grid"), "single occupancy canvas exists");
|
||||||
Verification.True(html.Contains("world-overlay"), "SVG trajectory overlay exists");
|
Verification.True(html.Contains("world-overlay"), "SVG trajectory overlay exists");
|
||||||
|
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");
|
||||||
Verification.True(js.Contains("atob"), "compact occupancy bitset is decoded in browser");
|
Verification.True(js.Contains("atob"), "compact occupancy bitset is decoded in browser");
|
||||||
Verification.True(!html.Contains("http://") && !html.Contains("https://"), "page has no CDN URL");
|
Verification.True(!html.Contains("http://") && !html.Contains("https://"), "page has no CDN URL");
|
||||||
}
|
}
|
||||||
@@ -82,6 +94,12 @@ internal static class WebAssetChecks
|
|||||||
PlanningVisualizationStaticSnapshot staticSnapshot = SampleSnapshotFactory.CreateStaticSnapshot();
|
PlanningVisualizationStaticSnapshot staticSnapshot = SampleSnapshotFactory.CreateStaticSnapshot();
|
||||||
PlanningVisualizationDynamicSnapshot dynamicSnapshot = SampleSnapshotFactory.CreateDynamicSnapshot(1L);
|
PlanningVisualizationDynamicSnapshot dynamicSnapshot = SampleSnapshotFactory.CreateDynamicSnapshot(1L);
|
||||||
|
|
||||||
|
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");
|
||||||
Verification.Equal(2, staticSnapshot.DirectionSegments.Count, "smoke snapshot has two direction segments");
|
Verification.Equal(2, staticSnapshot.DirectionSegments.Count, "smoke snapshot has two direction segments");
|
||||||
Verification.True(staticSnapshot.StaticMarkers.Any(marker => marker.Kind == "gear-switch"), "smoke snapshot has a gear marker");
|
Verification.True(staticSnapshot.StaticMarkers.Any(marker => marker.Kind == "gear-switch"), "smoke snapshot has a gear marker");
|
||||||
Verification.True(staticSnapshot.ConfigurationGroups.Count > 0, "smoke snapshot has effective configuration");
|
Verification.True(staticSnapshot.ConfigurationGroups.Count > 0, "smoke snapshot has effective configuration");
|
||||||
|
|||||||
Reference in New Issue
Block a user