fix: clarify EM overview path layers

This commit is contained in:
梁薄云
2026-08-09 19:17:22 +08:00
parent 76fa2900ce
commit bc76ff45c8
3 changed files with 126 additions and 53 deletions
@@ -6,6 +6,10 @@
--previous-trajectory: #8d959d;
--handoff: #d87918;
--failure: #b42318;
--world-coarse-path: #7e8790;
--world-smoothed-path: #168b83;
--world-active-segment: #3f78a8;
--world-em-trajectory: #df6b1f;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--paper); color: var(--text); font: 14px/1.45 "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; }
@@ -43,22 +47,25 @@ main > section[hidden] { display: none !important; }
.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; }
.world-previous { stroke: #8d959d; stroke-dasharray: 5 4; stroke-width: .85; }
.world-coarse { stroke: #9aa1a8; stroke-opacity: .48; stroke-dasharray: 5 4; stroke-width: .85; }
.world-local-g2 { stroke: #4f5963; stroke-width: .95; }
.world-segment-active { stroke: #5d91bd; stroke-opacity: .62; stroke-width: 1.0; }
.world-direction-inactive { stroke: #c4cad0; stroke-opacity: .52; stroke-dasharray: 4 4; stroke-width: .8; }
.world-current { stroke: #1769aa; stroke-width: 1.15; }
.world-marker { fill: #d87918; stroke: #ffffff; stroke-width: .8; }
.marker-vehicle .vehicle-outline { fill: none; stroke: #20252b; stroke-width: .7; }
.marker-vehicle .vehicle-heading { stroke: #20252b; stroke-width: .8; }
.marker-plan-start { fill: #3f7d46; stroke: #ffffff; stroke-width: .6; }
.marker-gear-switch { fill: #d87918; stroke: #ffffff; stroke-width: .7; }
.marker-final-goal { fill: #1769aa; stroke: #ffffff; stroke-width: .7; }
.world-empty-state { fill: #66707a; font-size: .42; }
.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; }
.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); }
@@ -102,46 +102,86 @@
}
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"; };
const dynamicClass = line => { const kind = String(line.kind || "").toLowerCase(); if (kind === "current") return "world-current"; if (kind === "previous" || line.lineStyle === 1) return "world-previous"; return "world-current"; };
safeArray(snapshot.staticPolylines).forEach(line => addLine(line, staticClass(line)));
safeArray(snapshot.directionSegments).forEach(segment => addLine(segment, segment.segmentIndex === frame.activeSegmentIndex ? "world-segment-active" : "world-direction-inactive"));
safeArray(frame.dynamicPolylines).forEach(line => addLine(line, dynamicClass(line)));
const hasPath = safeArray(snapshot.staticPolylines).concat(safeArray(snapshot.directionSegments), safeArray(frame.dynamicPolylines))
.some(line => safeArray(line && line.points).some(point => point && finite(point.x) && finite(point.y)));
const appendMarker = marker => {
if (!marker.position || !finite(marker.position.x) || !finite(marker.position.y)) return;
const x = marker.position.x, y = -marker.position.y; const kind = String(marker.kind || "").toLowerCase();
if (kind === "vehicle") {
const pose = frame.vehiclePose; const heading = pose && finite(pose.headingRadians) ? pose.headingRadians : 0;
const cos = Math.cos(heading), sin = Math.sin(heading);
const corners = [[.38, .18], [-.38, .18], [-.38, -.18], [.38, -.18]].map(([dx, dy]) => {
const wx = marker.position.x + dx * cos - dy * sin; const wy = marker.position.y + dx * sin + dy * cos; return `${wx} ${-wy}`;
});
const group = svg("g", { class: "marker-vehicle" });
group.append(svg("path", { d: `M ${corners[0]} L ${corners[1]} L ${corners[2]} L ${corners[3]} Z`, class: "vehicle-outline" }));
group.append(svg("line", { x1: x, y1: y, x2: x + .22 * cos, y2: y - .22 * sin, class: "vehicle-heading" }));
overlay.append(group);
} else if (kind === "plan-start") {
overlay.append(svg("circle", { cx: x, cy: y, r: ".12", class: "marker-plan-start" }));
} else if (kind === "gear-switch" || kind === "gear-switch-end") {
const size = ".14"; overlay.append(svg("path", { d: `M ${x} ${y - size} L ${x + size} ${y} L ${x} ${y + size} L ${x - size} ${y} Z`, class: "marker-gear-switch" }));
} else if (kind === "final-goal") {
const size = ".13"; overlay.append(svg("path", { d: `M ${x - size} ${y - size} L ${x + size} ${y - size} L ${x + size} ${y + size} L ${x - size} ${y + size} Z`, class: "marker-final-goal" }));
} else {
const size = ".18"; overlay.append(svg("path", { d: `M ${x} ${y - size} L ${x + size} ${y} L ${x} ${y + size} L ${x - size} ${y} Z`, class: "world-marker" }));
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})`;
}
const label = svg("text", { x: x + .2, y: y - .2, fill: "#20252b", "font-size": ".32" }); label.textContent = marker.labelChinese || ""; overlay.append(label);
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);
}
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 }));
};
safeArray(snapshot.staticMarkers).concat(safeArray(frame.dynamicMarkers)).forEach(appendMarker);
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 empty = svg("text", { x: (bounds.xMin + bounds.xMax) / 2, y: -((bounds.yMin + bounds.yMax) / 2), class: "world-empty-state", "text-anchor": "middle" });
empty.textContent = "未收到 Local G2 路径"; overlay.append(empty);
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);
}
}
@@ -323,6 +363,14 @@
function receiveFrame(payload) { state.frame = payload.snapshot || null; state.history = safeArray(payload.history); state.lastFrameAt = Date.now(); if (!state.ended) setLiveState(state.frame && state.frame.sessionStateChinese || "运行中"); renderAll(); }
function startEvents() { const events = new EventSource(authorized("/api/events")); events.addEventListener("frame", event => { try { receiveFrame(JSON.parse(event.data)); } catch (error) { state.redrawEnabled[state.activeTab] = false; setLiveState("页面绘图异常", "failure"); } }); events.addEventListener("end", () => { state.ended = true; events.close(); setLiveState("会话已结束"); }); events.onerror = () => { if (!state.ended) setLiveState("连接中断,正在重连", "notice"); }; }
async function boot() { installTabs(); ["overview", "ls-st", "kinematics", "history-config"].forEach(tab => { state.redrawEnabled[tab] = true; }); try { const response = await fetch(authorized("/api/bootstrap"), { cache: "no-store" }); if (!response.ok) throw new Error("bootstrap failed"); const bootstrap = await response.json(); state.staticSnapshot = bootstrap.staticSnapshot || bootstrap; state.refreshRateHz = finite(bootstrap.refreshRateHz) && bootstrap.refreshRateHz > 0 ? bootstrap.refreshRateHz : 10; renderAll(); startEvents(); window.setInterval(updateConnectionState, 250); } catch (error) { setLiveState("页面绘图异常", "failure"); } }
let overviewResizeFrame = 0;
window.addEventListener("resize", () => {
if (overviewResizeFrame || state.activeTab !== "overview" || !state.redrawEnabled.overview) return;
overviewResizeFrame = window.requestAnimationFrame(() => {
overviewResizeFrame = 0;
renderWorld();
});
});
window.addEventListener("error", () => { state.redrawEnabled[state.activeTab] = false; setLiveState("页面绘图异常", "failure"); }); window.addEventListener("unhandledrejection", () => { state.redrawEnabled[state.activeTab] = false; setLiveState("页面绘图异常", "failure"); });
if (window.__TRAJECTORY_VISUALIZATION_TEST__ === true) {
["overview", "ls-st", "kinematics", "history-config"].forEach(tab => { state.redrawEnabled[tab] = true; });
@@ -45,6 +45,24 @@ internal static class WebAssetChecks
"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("--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");
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");