[real] 整理 ROS 2 v2 Odin 与站姿调参

This commit is contained in:
2026-07-27 17:22:28 +08:00
parent c8aa490b37
commit dc7a2c2a67
100 changed files with 328202 additions and 557120 deletions
@@ -0,0 +1,104 @@
# PCD Map Viewer
Offline Web tool for Odin PCD inspection and route editing.
This is not the Nano runtime Web UI. Use it before a run to inspect the map and save route YAML files.
## Start
From the repository root:
```powershell
python .\tools\pcd_map_viewer\server.py --http-port 8090
```
Open:
```text
http://127.0.0.1:8090
```
You can also run directly from this directory:
```powershell
python server.py --http-port 8090
```
## What It Does
- Scans `map/*.pcd`.
- Shows raw Odin PCD as a rotatable 3D point cloud.
- Supports z filtering, default `z=-2.0..1.0m`.
- Supports voxel display for structure checks.
- Provides a simplified 2D layer view.
- Lets you click waypoints in 3D or 2D.
- Lets you draw obstacle terrain rectangles and edit their x/y/yaw/size.
- Tags new waypoints with the matching obstacle terrain, defaulting to `flat`.
- Saves route YAML and JSON under `map/routes/<pcd_name>/`.
## Route Fields
Saved waypoint fields:
- `x`
- `y`
- `yaw_deg`
- `speed`
- `policy`
- `tolerance`
- `obstacle`
- `obstacle_name`
Not saved:
- `z`
- `action`
The browser may keep local `_viewZ` only for drawing markers in 3D. The runtime route runner is planar.
## Saved Format
```yaml
name: test_route
map: map1
frame_id: map
obstacles:
- id: 1
name: wall_1
obstacle: wall
x: 1.5000
y: 2.0000
yaw_deg: 0.00
length: 1.000
width: 0.500
policy: rough
waypoints:
- id: 1
x: 1.0000
y: 2.0000
yaw_deg: 0.00
speed: 0.350
policy: rough
tolerance: 0.150
obstacle: wall
obstacle_name: wall_1
```
Default runtime route:
```text
map/routes/map1/test_route.yaml
```
## Runtime Test
```bash
ros2 topic pub --once /route_runner/cmd std_msgs/msg/String "{data: reload}"
ros2 topic pub --once /route_runner/cmd std_msgs/msg/String "{data: start}"
ros2 topic pub --once /route_runner/cmd std_msgs/msg/String "{data: stop}"
```
See also:
- [Maps And Routes](../../docs/ROUTES_AND_MAPS.md)
- [Common Commands](../../docs/COMMANDS.md)
@@ -0,0 +1,28 @@
# PCD 地图查看和打点工具
这是离线 Web 工具,用于查看 Odin PCD 地图和保存路线点。它不是 Nano 运行时 Web。
## 启动
在仓库根目录:
```powershell
python .\tools\pcd_map_viewer\server.py --http-port 8090
```
打开:
```text
http://127.0.0.1:8090
```
路线点保存:
- `x`
- `y`
- `yaw_deg`
- `speed`
- `policy`
- `tolerance`
不保存 `z``action`
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PCD Obstacle Terrain Editor</title>
<link rel="stylesheet" href="./style.css?v=obstacle-terrain-2">
</head>
<body>
<aside class="sidebar">
<div class="title">
<h1>Obstacle Terrain Editor</h1>
<span id="serverState">obstacle-ui loading</span>
</div>
<label>Map</label>
<select id="mapSelect"></select>
<div id="layerControls" class="hidden">
<label>Layer</label>
<select id="layerSelect"></select>
</div>
<label>View</label>
<select id="viewSelect">
<option value="3d" selected>3D map</option>
<option value="2d">2D layers</option>
</select>
<label>3D Mode</label>
<select id="cloudModeSelect">
<option value="points" selected>Point cloud</option>
<option value="voxels">Occupied voxels</option>
</select>
<div class="buttonRow">
<button id="fitBtn" title="Fit map">Fit</button>
<button id="reloadBtn" title="Reload maps">Reload</button>
</div>
<section>
<h2>Point Cloud</h2>
<div class="formGrid">
<label>Z Min</label><input id="cloudZMin" type="number" step="0.1" value="-2.0">
<label>Z Max</label><input id="cloudZMax" type="number" step="0.1" value="1.0">
<label>Max Pts</label><input id="cloudMaxPoints" type="number" step="1000" value="120000">
<label>Voxel</label><input id="voxelSize" type="number" step="0.01" value="0.20">
<label>Min Pts</label><input id="voxelMinPoints" type="number" step="1" value="1">
<label>Cluster</label><input id="voxelMinCluster" type="number" step="1" value="1">
</div>
</section>
<section>
<h2>Cursor</h2>
<pre id="cursorInfo">-</pre>
</section>
<section>
<h2>Waypoint</h2>
<input id="routeName" placeholder="route name" value="test_route">
<div class="formGrid">
<label>X</label><input id="wpX" type="number" step="0.01">
<label>Y</label><input id="wpY" type="number" step="0.01">
<label>Yaw</label><input id="wpYaw" type="number" step="1" value="0">
<label>Terrain</label><input id="wpObstacle" value="flat" readonly>
<label>Speed</label><input id="wpSpeed" type="number" step="0.05" value="0.35">
<label>Policy</label>
<select id="wpPolicy">
<option value="rough">rough</option>
<option value="crawl">crawl</option>
</select>
<label>Tol</label><input id="wpTol" type="number" step="0.01" value="0.15">
<label>Yaw Tol</label><input id="wpYawTol" type="number" step="1" value="30">
<label>Require Yaw</label><input id="wpRequireYaw" type="checkbox">
<label>PreDock Dist</label><input id="wpPreDockDist" type="number" step="0.01" value="0.35">
<label>PreDock Tol</label><input id="wpPreDockTol" type="number" step="0.01" value="0.18">
</div>
<div class="buttonRow">
<button id="updateWpBtn">Update</button>
<button id="deleteWpBtn">Delete</button>
</div>
</section>
<section>
<h2>Obstacle</h2>
<label>Region</label>
<select id="obstacleSelect"></select>
<div class="formGrid">
<label>Name</label><input id="obstacleName" value="obstacle_1">
<label>Type</label>
<select id="obstacleType">
<option value="rough_pit">rough_pit</option>
<option value="wall">wall</option>
<option value="low_bar">low_bar</option>
<option value="stairs">stairs</option>
<option value="ramp">ramp</option>
<option value="bridge_a">bridge_a</option>
<option value="bridge_b">bridge_b</option>
<option value="slalom">slalom</option>
<option value="return_home">return_home</option>
</select>
<label>X</label><input id="obstacleX" type="number" step="0.01">
<label>Y</label><input id="obstacleY" type="number" step="0.01">
<label>Yaw</label><input id="obstacleYaw" type="number" step="1" value="0">
<label>Length</label><input id="obstacleLength" type="number" step="0.05" value="1.00">
<label>Width</label><input id="obstacleWidth" type="number" step="0.05" value="1.00">
<label>Policy</label>
<select id="obstaclePolicy">
<option value="rough">rough</option>
<option value="crawl">crawl</option>
</select>
</div>
<div class="buttonRow">
<button id="drawObstacleBtn">Draw</button>
<button id="updateObstacleBtn">Update</button>
<button id="deleteObstacleBtn">Delete</button>
</div>
<ol id="obstacleList"></ol>
</section>
<section>
<h2>Route</h2>
<div class="buttonRow">
<button id="clearRouteBtn">Clear</button>
<button id="exportJsonBtn">Export</button>
<button id="saveRouteBtn">Save</button>
</div>
<ol id="waypointList"></ol>
</section>
</aside>
<main class="main view-3d">
<canvas id="mapCanvas"></canvas>
<canvas id="cloudCanvas"></canvas>
<div class="hud">
<span>Click cloud: add/select waypoint</span>
<span>Draw: drag 2D / click 3D obstacle</span>
<span>Wheel: zoom</span>
<span>Drag: orbit 3D / pan 2D</span>
<span>Shift + drag: set yaw</span>
</div>
</main>
<script type="module" src="./app.js?v=obstacle-terrain-2"></script>
</body>
</html>
@@ -0,0 +1,191 @@
:root {
color-scheme: dark;
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
background: #0d1117;
color: #e5e7eb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
height: 100vh;
display: grid;
grid-template-columns: 340px 1fr;
overflow: hidden;
}
.sidebar {
background: #111827;
border-right: 1px solid #263244;
padding: 16px;
overflow: auto;
}
.title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
h1 {
font-size: 20px;
margin: 0;
letter-spacing: 0;
}
h2 {
font-size: 14px;
margin: 18px 0 8px;
color: #cbd5e1;
letter-spacing: 0;
}
label {
display: block;
margin: 10px 0 6px;
color: #94a3b8;
font-size: 12px;
}
select,
input,
button {
width: 100%;
height: 32px;
border: 1px solid #344256;
background: #0b1220;
color: #e5e7eb;
border-radius: 6px;
padding: 0 10px;
font: inherit;
}
button {
cursor: pointer;
background: #1f2937;
}
button:hover {
background: #2f3b4d;
}
.buttonRow {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 10px;
}
.buttonRow:has(button:nth-child(2):last-child) {
grid-template-columns: repeat(2, 1fr);
}
.formGrid {
display: grid;
grid-template-columns: 62px 1fr;
gap: 8px;
align-items: center;
}
.formGrid label {
margin: 0;
}
pre {
min-height: 112px;
margin: 0;
padding: 10px;
background: #070b12;
border: 1px solid #263244;
border-radius: 6px;
white-space: pre-wrap;
font-family: Consolas, monospace;
font-size: 12px;
color: #a7f3d0;
}
#waypointList,
#obstacleList {
padding-left: 22px;
margin: 10px 0 0;
}
#waypointList li,
#obstacleList li {
padding: 6px 4px;
border-bottom: 1px solid #263244;
cursor: pointer;
font-size: 12px;
}
#waypointList li.active,
#obstacleList li.active {
color: #67e8f9;
background: #102033;
}
.main {
position: relative;
background: #0b0f17;
overflow: hidden;
}
#mapCanvas,
#cloudCanvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.main.view-3d #cloudCanvas {
z-index: 1;
}
.main.view-3d #mapCanvas {
z-index: 2;
background: transparent;
pointer-events: none;
}
.main.view-2d #mapCanvas {
z-index: 1;
pointer-events: auto;
}
.main.view-2d #cloudCanvas {
z-index: 0;
}
#mapCanvas.hidden,
#cloudCanvas.hidden {
display: none;
}
.hud {
position: absolute;
z-index: 3;
left: 12px;
bottom: 12px;
display: flex;
gap: 8px;
flex-wrap: wrap;
pointer-events: none;
}
.hud span,
#serverState {
border: 1px solid #344256;
background: rgba(8, 13, 22, 0.82);
color: #cbd5e1;
border-radius: 6px;
padding: 5px 8px;
font-size: 12px;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,782 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import html
import http.server
import json
import math
import socket
import socketserver
from dataclasses import dataclass
from pathlib import Path
@dataclass
class RoutePoint:
id: int
x: float
y: float
yaw_deg: float | None
segment: str
SVG_SEGMENT_COLORS = [
"#d81b60",
"#1e88e5",
"#43a047",
"#fb8c00",
"#8e24aa",
"#00897b",
"#6d4c41",
"#546e7a",
"#e53935",
"#3949ab",
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Preview an ASCII PCD map with route points and yaw arrows."
)
parser.add_argument(
"--pcd",
default="map/map_b.pcd",
help="Path to the ASCII PCD file. Default: map/map_b.pcd",
)
parser.add_argument(
"--route",
default="tools/test_route.json",
help="Path to the route JSON file. Default: tools/test_route.json",
)
parser.add_argument(
"--floor-z-min",
type=float,
default=-1.6,
help="Minimum z value kept from the PCD floor points.",
)
parser.add_argument(
"--floor-z-max",
type=float,
default=0.4,
help="Maximum z value kept from the PCD floor points.",
)
parser.add_argument(
"--sample-step",
type=int,
default=25,
help="Keep one point every N points after filtering.",
)
parser.add_argument(
"--point-size",
type=float,
default=1.0,
help="Scatter point size for the PCD map.",
)
parser.add_argument(
"--arrow-len",
type=float,
default=0.35,
help="Arrow length used to visualize waypoint yaw.",
)
parser.add_argument(
"--show-id",
action="store_true",
help="Draw waypoint id labels.",
)
parser.add_argument(
"--show-yaw-text",
action="store_true",
help="Draw yawDeg text next to each waypoint.",
)
parser.add_argument(
"--connect",
action="store_true",
help="Connect route points in order.",
)
parser.add_argument(
"--renderer",
choices=["auto", "mpl", "html"],
default="html",
help="Rendering backend. Default: html.",
)
parser.add_argument(
"--output",
default="tools/pcd_route_preview.html",
help="Output html file used by the html renderer.",
)
parser.add_argument(
"--width",
type=int,
default=1400,
help="Canvas width for the html/svg renderer.",
)
parser.add_argument(
"--height",
type=int,
default=980,
help="Canvas height for the html/svg renderer.",
)
parser.add_argument(
"--serve",
action="store_true",
help="Serve the generated html over HTTP after rendering.",
)
parser.add_argument(
"--host",
default="0.0.0.0",
help="Host used by the built-in HTTP server. Default: 0.0.0.0",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port used by the built-in HTTP server. Default: 8000",
)
return parser.parse_args()
def load_ascii_pcd_xy(
path: Path,
floor_z_min: float,
floor_z_max: float,
sample_step: int,
) -> list[tuple[float, float]]:
if not path.exists():
raise FileNotFoundError(f"PCD file not found: {path}")
points: list[tuple[float, float]] = []
data_started = False
fields: list[str] = []
x_index = 0
y_index = 1
z_index = 2
with path.open("r", encoding="utf-8") as f:
for line in f:
stripped = line.strip()
if not stripped:
continue
if not data_started:
upper = stripped.upper()
if upper.startswith("FIELDS "):
fields = stripped.split()[1:]
if {"x", "y", "z"}.issubset(set(fields)):
x_index = fields.index("x")
y_index = fields.index("y")
z_index = fields.index("z")
elif upper.startswith("DATA"):
if "ascii" not in stripped.lower():
raise RuntimeError("Only ASCII PCD is supported.")
data_started = True
continue
parts = stripped.split()
needed_index = max(x_index, y_index, z_index)
if len(parts) <= needed_index:
continue
try:
x = float(parts[x_index])
y = float(parts[y_index])
z = float(parts[z_index])
except ValueError:
continue
if floor_z_min <= z <= floor_z_max:
points.append((x, y))
if not points:
raise RuntimeError("No usable floor points found after z filtering.")
return points[:: max(1, sample_step)]
def load_route_points(path: Path) -> list[RoutePoint]:
if not path.exists():
raise FileNotFoundError(f"Route file not found: {path}")
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
raw_segments = data.get("segments", [])
if not isinstance(raw_segments, list) or not raw_segments:
top_level_waypoints = data.get("waypoints", [])
if isinstance(top_level_waypoints, list) and top_level_waypoints:
raw_segments = [
{
"name": "segment_1",
"waypoints": top_level_waypoints,
}
]
else:
raise RuntimeError("Route JSON has no usable segments or waypoints.")
points: list[RoutePoint] = []
fallback_id = 1
for segment_index, segment in enumerate(raw_segments, start=1):
if not isinstance(segment, dict):
continue
segment_name = str(segment.get("name", f"segment_{segment_index}")).strip() or f"segment_{segment_index}"
raw_waypoints = segment.get("waypoints", [])
if not isinstance(raw_waypoints, list):
continue
for waypoint in raw_waypoints:
if not isinstance(waypoint, dict):
continue
try:
x = float(waypoint["x"])
y = float(waypoint["y"])
except (KeyError, TypeError, ValueError):
continue
yaw_deg = waypoint.get("yawDeg", waypoint.get("yaw_deg"))
if yaw_deg is not None:
try:
yaw_deg = float(yaw_deg)
except (TypeError, ValueError):
yaw_deg = None
point_id = waypoint.get("id", fallback_id)
try:
point_id = int(point_id)
except (TypeError, ValueError):
point_id = fallback_id
points.append(
RoutePoint(
id=point_id,
x=x,
y=y,
yaw_deg=yaw_deg,
segment=segment_name,
)
)
fallback_id += 1
if not points:
raise RuntimeError("Route JSON contains no valid waypoint coordinates.")
return points
def plot_preview(
map_points: list[tuple[float, float]],
route_points: list[RoutePoint],
point_size: float,
arrow_len: float,
show_id: bool,
show_yaw_text: bool,
connect: bool,
title: str,
) -> None:
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(12, 9))
map_x = [p[0] for p in map_points]
map_y = [p[1] for p in map_points]
ax.scatter(map_x, map_y, s=point_size, c="black", alpha=0.35, label="PCD floor")
segment_names: list[str] = []
for point in route_points:
if point.segment not in segment_names:
segment_names.append(point.segment)
segment_colors = {
name: plt.cm.tab10(index % 10) for index, name in enumerate(segment_names)
}
for index, point in enumerate(route_points):
color = segment_colors[point.segment]
ax.scatter([point.x], [point.y], s=55, c=[color], edgecolors="white", linewidths=0.8)
if connect and index > 0:
prev = route_points[index - 1]
ax.plot([prev.x, point.x], [prev.y, point.y], color=color, linewidth=1.4, alpha=0.9)
if point.yaw_deg is not None:
yaw_rad = math.radians(point.yaw_deg)
dx = arrow_len * math.cos(yaw_rad)
dy = arrow_len * math.sin(yaw_rad)
ax.arrow(
point.x,
point.y,
dx,
dy,
width=0.018,
head_width=0.12,
head_length=0.12,
length_includes_head=True,
color=color,
alpha=0.95,
)
label_parts: list[str] = []
if show_id:
label_parts.append(str(point.id))
if show_yaw_text and point.yaw_deg is not None:
label_parts.append(f"{point.yaw_deg:.1f}deg")
if label_parts:
ax.text(
point.x + 0.05,
point.y + 0.05,
" | ".join(label_parts),
color=color,
fontsize=9,
weight="bold",
)
ax.set_title(title)
ax.set_xlabel("map x")
ax.set_ylabel("map y")
ax.set_aspect("equal", adjustable="box")
ax.grid(True, alpha=0.2)
plt.tight_layout()
plt.show()
def compute_bounds(
map_points: list[tuple[float, float]],
route_points: list[RoutePoint],
) -> tuple[float, float, float, float]:
xs = [p[0] for p in map_points] + [p.x for p in route_points]
ys = [p[1] for p in map_points] + [p.y for p in route_points]
min_x = min(xs)
max_x = max(xs)
min_y = min(ys)
max_y = max(ys)
if math.isclose(min_x, max_x):
max_x = min_x + 1.0
if math.isclose(min_y, max_y):
max_y = min_y + 1.0
return min_x, max_x, min_y, max_y
def build_segment_color_map(route_points: list[RoutePoint]) -> dict[str, str]:
segment_names: list[str] = []
for point in route_points:
if point.segment not in segment_names:
segment_names.append(point.segment)
return {
name: SVG_SEGMENT_COLORS[index % len(SVG_SEGMENT_COLORS)]
for index, name in enumerate(segment_names)
}
def compute_canvas_transform(
min_x: float,
max_x: float,
min_y: float,
max_y: float,
width: int,
height: int,
padding: int,
) -> tuple[float, float, float, float, float]:
usable_width = max(1.0, float(width - padding * 2))
usable_height = max(1.0, float(height - padding * 2))
scale_x = usable_width / max(1e-9, max_x - min_x)
scale_y = usable_height / max(1e-9, max_y - min_y)
scale = min(scale_x, scale_y)
draw_width = (max_x - min_x) * scale
draw_height = (max_y - min_y) * scale
offset_x = padding + (usable_width - draw_width) * 0.5
offset_y = padding + (usable_height - draw_height) * 0.5
return scale, draw_width, draw_height, offset_x, offset_y
def map_to_canvas(
x: float,
y: float,
min_x: float,
max_x: float,
min_y: float,
max_y: float,
width: int,
height: int,
padding: int,
) -> tuple[float, float]:
scale, _draw_width, _draw_height, offset_x, offset_y = compute_canvas_transform(
min_x=min_x,
max_x=max_x,
min_y=min_y,
max_y=max_y,
width=width,
height=height,
padding=padding,
)
canvas_x = offset_x + (x - min_x) * scale
canvas_y = height - (offset_y + (y - min_y) * scale)
return canvas_x, canvas_y
def svg_arrow_polygon(
start_x: float,
start_y: float,
end_x: float,
end_y: float,
color: str,
) -> str:
dx = end_x - start_x
dy = end_y - start_y
length = math.hypot(dx, dy)
if length < 1e-6:
return ""
ux = dx / length
uy = dy / length
px = -uy
py = ux
head_len = min(16.0, max(8.0, length * 0.35))
shaft_half = 2.5
head_half = 7.0
base_x = end_x - ux * head_len
base_y = end_y - uy * head_len
p1 = (start_x + px * shaft_half, start_y + py * shaft_half)
p2 = (base_x + px * shaft_half, base_y + py * shaft_half)
p3 = (base_x + px * head_half, base_y + py * head_half)
p4 = (end_x, end_y)
p5 = (base_x - px * head_half, base_y - py * head_half)
p6 = (base_x - px * shaft_half, base_y - py * shaft_half)
p7 = (start_x - px * shaft_half, start_y - py * shaft_half)
points_text = " ".join(f"{x:.2f},{y:.2f}" for x, y in [p1, p2, p3, p4, p5, p6, p7])
return f'<polygon points="{points_text}" fill="{color}" fill-opacity="0.95" />'
def write_html_preview(
output_path: Path,
map_points: list[tuple[float, float]],
route_points: list[RoutePoint],
point_size: float,
arrow_len: float,
show_id: bool,
show_yaw_text: bool,
connect: bool,
title: str,
width: int,
height: int,
) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
min_x, max_x, min_y, max_y = compute_bounds(map_points, route_points)
padding = 48
segment_colors = build_segment_color_map(route_points)
scale, draw_width, draw_height, offset_x, offset_y = compute_canvas_transform(
min_x=min_x,
max_x=max_x,
min_y=min_y,
max_y=max_y,
width=width,
height=height,
padding=padding,
)
svg_parts: list[str] = []
svg_parts.append(
f'<svg id="pcd-map-svg" viewBox="0 0 {width} {height}" width="{width}" height="{height}" '
f'data-min-x="{min_x:.10f}" data-min-y="{min_y:.10f}" data-scale="{scale:.10f}" '
f'data-offset-x="{offset_x:.10f}" data-offset-y="{offset_y:.10f}" '
f'data-canvas-height="{float(height):.10f}" data-draw-width="{draw_width:.10f}" '
f'data-draw-height="{draw_height:.10f}" xmlns="http://www.w3.org/2000/svg">'
)
svg_parts.append(f'<rect x="0" y="0" width="{width}" height="{height}" fill="#f7f7f5" />')
for x, y in map_points:
cx, cy = map_to_canvas(x, y, min_x, max_x, min_y, max_y, width, height, padding)
radius = max(0.35, point_size * 0.7)
svg_parts.append(
f'<circle cx="{cx:.2f}" cy="{cy:.2f}" r="{radius:.2f}" fill="#1f1f1f" fill-opacity="0.35" />'
)
if connect:
for prev, curr in zip(route_points, route_points[1:]):
color = segment_colors[curr.segment]
x1, y1 = map_to_canvas(prev.x, prev.y, min_x, max_x, min_y, max_y, width, height, padding)
x2, y2 = map_to_canvas(curr.x, curr.y, min_x, max_x, min_y, max_y, width, height, padding)
svg_parts.append(
f'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
f'stroke="{color}" stroke-width="2" stroke-opacity="0.85" />'
)
for point in route_points:
color = segment_colors[point.segment]
cx, cy = map_to_canvas(point.x, point.y, min_x, max_x, min_y, max_y, width, height, padding)
svg_parts.append(
f'<circle class="route-point" data-id="{point.id}" data-segment="{html.escape(point.segment)}" '
f'data-map-x="{point.x:.6f}" data-map-y="{point.y:.6f}" '
+ (
f'data-yaw-deg="{point.yaw_deg:.3f}" '
if point.yaw_deg is not None else
""
)
+ f'cx="{cx:.2f}" cy="{cy:.2f}" r="6.5" fill="{color}" stroke="#ffffff" stroke-width="1.5">'
f"<title>ID {point.id} | x={point.x:.3f} y={point.y:.3f}"
+ (f" | yaw={point.yaw_deg:.1f}deg" if point.yaw_deg is not None else "")
+ "</title></circle>"
)
if point.yaw_deg is not None:
yaw_rad = math.radians(point.yaw_deg)
end_x, end_y = map_to_canvas(
point.x + arrow_len * math.cos(yaw_rad),
point.y + arrow_len * math.sin(yaw_rad),
min_x,
max_x,
min_y,
max_y,
width,
height,
padding,
)
svg_parts.append(svg_arrow_polygon(cx, cy, end_x, end_y, color))
label_parts: list[str] = []
if show_id:
label_parts.append(str(point.id))
if show_yaw_text and point.yaw_deg is not None:
label_parts.append(f"{point.yaw_deg:.1f}deg")
if label_parts:
svg_parts.append(
f'<text x="{cx + 8:.2f}" y="{cy - 8:.2f}" font-size="12" font-weight="700" '
f'fill="{color}">{html.escape(" | ".join(label_parts))}</text>'
)
svg_parts.append(
f'<text x="24" y="30" font-size="20" font-weight="700" fill="#222">{html.escape(title)}</text>'
)
svg_parts.append(
f'<text x="24" y="{height - 24}" font-size="13" fill="#444">'
f'{html.escape(f"route points: {len(route_points)} | pcd samples: {len(map_points)} | scale: {scale:.2f}px/m")}'
"</text>"
)
svg_parts.append(
'<g id="click-marker" visibility="hidden">'
'<circle cx="0" cy="0" r="8" fill="none" stroke="#ff1744" stroke-width="2" />'
'<line x1="-12" y1="0" x2="12" y2="0" stroke="#ff1744" stroke-width="2" />'
'<line x1="0" y1="-12" x2="0" y2="12" stroke="#ff1744" stroke-width="2" />'
"</g>"
)
svg_parts.append("</svg>")
html_text = "\n".join(
[
"<!DOCTYPE html>",
'<html lang="zh-CN">',
"<head>",
'<meta charset="utf-8" />',
f"<title>{html.escape(title)}</title>",
"<style>",
"body { margin: 0; background: #ece9e1; font-family: 'Segoe UI', sans-serif; color: #222; }",
".wrap { padding: 20px; }",
".panel { background: #ffffff; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.10); overflow: auto; }",
".toolbar { display: flex; flex-wrap: wrap; gap: 12px 18px; align-items: center; padding: 14px 20px 0 20px; font-size: 14px; }",
".toolbar strong { color: #111; }",
".meta { padding: 10px 20px 6px 20px; color: #444; font-size: 14px; }",
".chip { display: inline-flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 999px; background: #f3f1eb; }",
".swatch { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }",
"#click-coords { font-family: Consolas, 'Courier New', monospace; }",
"#point-detail { font-family: Consolas, 'Courier New', monospace; }",
"svg { display: block; margin: 0 auto; cursor: crosshair; user-select: none; }",
".hint { padding: 0 20px 12px 20px; color: #666; font-size: 13px; }",
"</style>",
"</head>",
"<body>",
'<div class="wrap">',
'<div class="panel">',
'<div class="toolbar">',
'<div class="chip"><strong>点击坐标</strong><span id="click-coords">尚未点击</span></div>',
'<div class="chip"><strong>点位信息</strong><span id="point-detail">点击路线点可查看 id / yaw</span></div>',
"</div>",
'<div class="meta">这是纯浏览器预览页。点击 PCD 或空白位置会显示当前地图坐标,点击路线点还会显示该点的 id、segment 和 yaw。</div>',
'<div class="hint">浏览器可直接缩放页面查看细节,图上的红色十字为你最近一次点击的位置。</div>',
"\n".join(svg_parts),
"<script>",
"(() => {",
" const svg = document.getElementById('pcd-map-svg');",
" const marker = document.getElementById('click-marker');",
" const clickCoords = document.getElementById('click-coords');",
" const pointDetail = document.getElementById('point-detail');",
" const minX = parseFloat(svg.dataset.minX);",
" const minY = parseFloat(svg.dataset.minY);",
" const scale = parseFloat(svg.dataset.scale);",
" const offsetX = parseFloat(svg.dataset.offsetX);",
" const offsetY = parseFloat(svg.dataset.offsetY);",
" const canvasHeight = parseFloat(svg.dataset.canvasHeight);",
" function svgPointFromEvent(evt) {",
" const pt = svg.createSVGPoint();",
" pt.x = evt.clientX;",
" pt.y = evt.clientY;",
" return pt.matrixTransform(svg.getScreenCTM().inverse());",
" }",
" function svgToMap(px, py) {",
" const mapX = minX + (px - offsetX) / scale;",
" const mapY = minY + ((canvasHeight - py) - offsetY) / scale;",
" return { x: mapX, y: mapY };",
" }",
" function formatNum(v) {",
" return Number.isFinite(v) ? v.toFixed(3) : 'NaN';",
" }",
" function updateFromEvent(evt) {",
" const svgPoint = svgPointFromEvent(evt);",
" const mapPoint = svgToMap(svgPoint.x, svgPoint.y);",
" marker.setAttribute('transform', `translate(${svgPoint.x} ${svgPoint.y})`);",
" marker.setAttribute('visibility', 'visible');",
" clickCoords.textContent = `x=${formatNum(mapPoint.x)}, y=${formatNum(mapPoint.y)}`;",
" const routePoint = evt.target.closest('.route-point');",
" if (routePoint) {",
" const yaw = routePoint.dataset.yawDeg;",
" pointDetail.textContent = `id=${routePoint.dataset.id}, segment=${routePoint.dataset.segment}, x=${routePoint.dataset.mapX}, y=${routePoint.dataset.mapY}` + (yaw ? `, yaw=${Number(yaw).toFixed(1)}deg` : '');",
" } else {",
" pointDetail.textContent = '未点击路线点';",
" }",
" }",
" svg.addEventListener('click', updateFromEvent);",
"})();",
"</script>",
"</div>",
"</div>",
"</body>",
"</html>",
]
)
output_path.write_text(html_text, encoding="utf-8")
def guess_local_ip() -> str:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.connect(("8.8.8.8", 80))
return str(sock.getsockname()[0])
except OSError:
return "127.0.0.1"
finally:
sock.close()
def serve_output_file(output_path: Path, host: str, port: int) -> None:
output_path = output_path.resolve()
web_root = output_path.parent.resolve()
relative_url = output_path.relative_to(web_root).as_posix()
class PreviewHttpHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(web_root), **kwargs)
server_address = (host, int(port))
with socketserver.TCPServer(server_address, PreviewHttpHandler) as httpd:
local_ip = guess_local_ip()
bound_host = host if host not in {"0.0.0.0", "::"} else local_ip
print("Preview server is running.")
print(f"Local URL: http://127.0.0.1:{port}/{relative_url}")
print(f"LAN URL: http://{bound_host}:{port}/{relative_url}")
print("Open the LAN URL from your Windows browser.")
print("Press Ctrl+C to stop the server.")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nServer stopped.")
def render_preview(
renderer: str,
output_path: Path,
map_points: list[tuple[float, float]],
route_points: list[RoutePoint],
point_size: float,
arrow_len: float,
show_id: bool,
show_yaw_text: bool,
connect: bool,
title: str,
width: int,
height: int,
) -> str:
if renderer in {"auto", "mpl"}:
try:
plot_preview(
map_points=map_points,
route_points=route_points,
point_size=point_size,
arrow_len=arrow_len,
show_id=show_id,
show_yaw_text=show_yaw_text,
connect=connect,
title=title,
)
return "mpl"
except Exception as exc:
if renderer == "mpl":
raise RuntimeError(
"matplotlib 渲染失败。当前环境很可能存在 numpy / matplotlib 二进制不兼容问题。"
) from exc
print(f"[info] matplotlib 不可用,自动切换到 html 渲染: {exc}")
write_html_preview(
output_path=output_path,
map_points=map_points,
route_points=route_points,
point_size=point_size,
arrow_len=arrow_len,
show_id=show_id,
show_yaw_text=show_yaw_text,
connect=connect,
title=title,
width=width,
height=height,
)
return "html"
def main() -> None:
args = parse_args()
pcd_path = Path(args.pcd)
route_path = Path(args.route)
output_path = Path(args.output)
map_points = load_ascii_pcd_xy(
path=pcd_path,
floor_z_min=float(args.floor_z_min),
floor_z_max=float(args.floor_z_max),
sample_step=max(1, int(args.sample_step)),
)
route_points = load_route_points(route_path)
title = f"PCD Route Preview: {pcd_path.name} + {route_path.name}"
used_renderer = render_preview(
renderer=str(args.renderer),
output_path=output_path,
map_points=map_points,
route_points=route_points,
point_size=float(args.point_size),
arrow_len=float(args.arrow_len),
show_id=bool(args.show_id),
show_yaw_text=bool(args.show_yaw_text),
connect=bool(args.connect),
title=title,
width=max(600, int(args.width)),
height=max(400, int(args.height)),
)
if used_renderer == "html":
print(f"HTML preview written to: {output_path.resolve()}")
if bool(args.serve):
if used_renderer != "html":
raise RuntimeError("Built-in server currently supports html output only.")
serve_output_file(
output_path=output_path,
host=str(args.host),
port=int(args.port),
)
if __name__ == "__main__":
main()
@@ -0,0 +1,204 @@
{
"name": "test_route",
"map": "map_b",
"frame_id": "map",
"yawToleranceDegDefault": 30.0,
"createdAt": "2026-07-02T12:18:22.647Z",
"segments": [
{
"name": "segment_1",
"obstacle": "slalom",
"waypoints": [
{
"id": 1,
"x": 5.359,
"y": 2.442,
"yawDeg": 0,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.15
},
{
"id": 2,
"x": 6.924,
"y": 2.388,
"yawDeg": -90,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.15
},
{
"id": 3,
"x": 6.827,
"y": 0.712,
"yawDeg": -5,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.15
},
{
"id": 4,
"x": 9.368,
"y": 0.706,
"yawDeg": -3,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.15
},
{
"id": 5,
"x": 11.373,
"y": 0.43,
"yawDeg": 0,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.15
},
{
"id": 6,
"x": 12.877,
"y": 0.432,
"yawDeg": 90,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.1
},
{
"id": 7,
"x": 12.935,
"y": 2.241,
"yawDeg": 88.2,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.1
},
{
"id": 8,
"x": 12.991,
"y": 4.277,
"yawDeg": 180,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.1
},
{
"id": 9,
"x": 12.004,
"y": 4.339,
"yawDeg": -90,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.15
},
{
"id": 10,
"x": 11.872,
"y": 2.583,
"yawDeg": 180,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.15
},
{
"id": 11,
"x": 9.5719,
"y": 2.7972,
"yawDeg": 174.7,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.15
},
{
"id": 12,
"x": 9.508,
"y": 3.562,
"yawDeg": 180,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.15
},
{
"id": 13,
"x": 8.414,
"y": 3.573,
"yawDeg": 125,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.15
},
{
"id": 14,
"x": 7.867,
"y": 4.528,
"yawDeg": 180,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.15
},
{
"id": 15,
"x": 4.475,
"y": 4.743,
"yawDeg": 180,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.15
},
{
"id": 16,
"x": 4.926,
"y": 4.785,
"yawDeg": 180,
"speed": 0.4,
"policy": "crawl",
"tolerance": 0.1
},
{
"id": 17,
"x": 3.2951,
"y": 4.7963,
"yawDeg": 179.6,
"speed": 0.4,
"policy": "crawl",
"tolerance": 0.1
},
{
"id": 18,
"x": 3.092,
"y": 4.777,
"yawDeg": -180,
"speed": 0.4,
"policy": "crawl",
"tolerance": 0.1
},
{
"id": 19,
"x": 1.694,
"y": 4.886,
"yawDeg": 210,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.1
},
{
"id": 20,
"x": 1.055,
"y": 4.548,
"yawDeg": -90,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.1
},
{
"id": 21,
"x": 1.042,
"y": 2.92,
"yawDeg": -90.4,
"speed": 0.4,
"policy": "rough",
"tolerance": 0.1
}
]
}
]
}
@@ -0,0 +1,64 @@
{
"name": "test_route",
"map": "map_b",
"frame_id": "map",
"yawToleranceDegDefault": 45.0,
"requireYawDefault": false,
"preDockDistanceDefault": 0.35,
"preDockToleranceDefault": 0.18,
"createdAt": "2026-07-02T15:07:00.219Z",
"segments": [
{
"name": "segment_1",
"obstacle": "slalom",
"waypoints": [
{
"id": 1,
"x": 1.2,
"y": 0.5,
"yawDeg": 90,
"speed": 0.35,
"policy": "rough",
"tolerance": 0.25
},
{
"id": 2,
"x": 1.247,
"y": 1.748,
"yawDeg": 0,
"speed": 0.35,
"policy": "rough",
"tolerance": 0.25
},
{
"id": 3,
"x": 2.312,
"y": 1.673,
"yawDeg": -90,
"speed": 0.35,
"policy": "rough",
"tolerance": 0.25
},
{
"id": 4,
"x": 2.33,
"y": 0.496,
"yawDeg": -180,
"speed": 0.35,
"policy": "rough",
"tolerance": 0.25
},
{
"id": 5,
"x": 0.4626,
"y": 0.6271,
"yawDeg": 176,
"speed": 0.35,
"policy": "rough",
"tolerance": 0.25,
"requireYaw": true
}
]
}
]
}
@@ -0,0 +1,44 @@
# Windows Runtime Web Debug
This folder contains a small Windows-side HTTP server for debugging the Nano runtime Web bridge through UDP.
Most of the time you can open the Nano runtime Web directly:
```text
http://<nano-ip>:18080
```
Use this tool only when you want the browser and HTTP server to run on Windows while Nano communicates over UDP.
## Start
From the repository root:
```powershell
python .\tools\win_web_debug\server.py --nano-host <nano-ip> --http-port 8088
```
Or from this folder:
```powershell
python server.py --nano-host <nano-ip> --http-port 8088
```
Open:
```text
http://127.0.0.1:8088
```
## Related Runtime Node
Nano side:
```text
src/sim2real_runtime/src/web_udp_bridge_node.py
```
See:
- [Runtime Web](../../docs/RUNTIME_WEB.md)
- [Common Commands](../../docs/COMMANDS.md)
@@ -0,0 +1,23 @@
# Windows Runtime Web 调试桥
大多数时候直接打开 Nano Web 即可:
```text
http://<nano-ip>:18080
```
这个工具只在你希望 HTTP 服务跑在 Windows、Nano 通过 UDP 传状态时使用。
## 启动
在仓库根目录:
```powershell
python .\tools\win_web_debug\server.py --nano-host <nano-ip> --http-port 8088
```
打开:
```text
http://127.0.0.1:8088
```
@@ -12,6 +12,7 @@ from typing import Optional
STATE_LOCK = threading.Lock()
LATEST_STATE: dict = {"type": "state", "connected": False}
LATEST_MAP: Optional[dict] = None
NANO_ADDR: tuple[str, int]
UDP_SOCK: socket.socket
@@ -28,7 +29,7 @@ class Handler(SimpleHTTPRequestHandler):
deadline = time.time() + 1.0
while time.time() < deadline:
with STATE_LOCK:
maybe_map = LATEST_STATE.get("map")
maybe_map = LATEST_MAP
if isinstance(maybe_map, dict):
data = json.dumps(maybe_map).encode("utf-8")
self._json(200, data)
@@ -69,7 +70,7 @@ def send_udp(payload: dict) -> None:
def udp_rx_loop(sock: socket.socket) -> None:
global LATEST_STATE
global LATEST_STATE, LATEST_MAP
while True:
try:
data, _ = sock.recvfrom(65535)
@@ -77,7 +78,10 @@ def udp_rx_loop(sock: socket.socket) -> None:
payload["connected"] = True
payload["local_receive_time"] = time.time()
with STATE_LOCK:
LATEST_STATE = payload
if payload.get("type") == "map":
LATEST_MAP = payload
else:
LATEST_STATE = payload
except Exception:
time.sleep(0.01)
@@ -15,20 +15,36 @@ let dragging = false;
let mapModel = null;
let latestPose = null;
let lastGoal = null;
let latestConnected = false;
let pendingModePromise = null;
let currentGoalName = '';
let currentGoalIndex = -1;
let currentGoalTotal = 0;
let latestModelState = { current_model: '--', switch_state: '--', backend: '--', switching: false };
let goalPointsByName = {};
let defaultMissionName = '';
let defaultMissionGoals = [];
let routeAlignmentInfo = {};
let activeNavPath = { goal_name: '', stage: 'idle', path_index: 0, points: [] };
const mapCanvas = $('map-canvas');
const mapCtx = mapCanvas ? mapCanvas.getContext('2d') : null;
async function post(payload) {
try {
await fetch('/api/control', {
const res = await fetch('/api/control', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data.error || `HTTP ${res.status}`);
}
return { ok: true, data };
} catch (e) {
appendEvent('API_ERROR', e.message, 'bad');
return { ok: false, error: e.message };
}
}
@@ -68,32 +84,71 @@ function highlightMode(mode) {
el.className = 'stage ' + mode;
}
currentMode = mode;
updateTaskButtonState();
}
async function setMode(mode) {
if (mode === 'WEB') {
const ok = confirm('Confirm switch to WEB control?\nMake sure the robot is safe and velocity is zero.');
if (!ok) return false;
function updateTaskButtonState() {
const btn = $('btn-run-task');
if (!btn) return;
const hasMission = Boolean(defaultMissionName);
const enabled = latestConnected && currentMode === 'NAV' && hasMission;
btn.disabled = !enabled;
btn.textContent = hasMission ? `Run ${defaultMissionName}` : 'Run Mission';
if (!hasMission) {
btn.title = 'No default mission loaded from backend';
return;
}
await post({ type: 'mode', mode });
highlightMode(mode);
appendEvent('MODE_SET', `-> ${mode}`, 'ok');
btn.title = enabled
? `Run default mission ${defaultMissionName}`
: 'Available only in NAV mode while connected';
}
function setText(id, text, cls) {
const el = $(id);
if (!el) return;
el.textContent = text;
if (cls !== undefined) el.className = 'diag-value ' + cls;
}
function formatNumber(value, digits = 2) {
return Number.isFinite(value) ? Number(value).toFixed(digits) : '--';
}
function setLastGoalByName(goalName) {
const goal = goalPointsByName[goalName];
if (!goal) return false;
lastGoal = { x: goal.x, y: goal.y };
currentGoalName = goalName;
currentGoalIndex = defaultMissionGoals.findIndex(goalItem => goalItem.name === goalName);
setText('d-active-goal', goalName, 'active');
renderTaskGoals();
return true;
}
async function ensureMode(mode) {
if (currentMode === mode) return true;
if (pendingModePromise) {
const activeMode = await pendingModePromise;
if (activeMode === mode) return true;
function syncGoalMarkerFromNavStatus(statusText) {
if (!statusText) return;
const missionStatusMatch = statusText.match(/waypoint\s+(\d+)\/(\d+)\s*->\s*([A-Za-z0-9_()=.,-]+)\b/i);
if (missionStatusMatch) {
currentGoalIndex = Math.max(0, Number.parseInt(missionStatusMatch[1], 10) - 1);
currentGoalTotal = Math.max(0, Number.parseInt(missionStatusMatch[2], 10));
setLastGoalByName(missionStatusMatch[3]);
return;
}
const nextGoalMatch = statusText.match(/->\s*([A-Za-z0-9_()=.,-]+)\b/);
if (nextGoalMatch && setLastGoalByName(nextGoalMatch[1])) {
return;
}
const reachedMatch = statusText.match(/reached\s+([A-Za-z0-9_()=.,-]+)\b/i);
if (reachedMatch && setLastGoalByName(reachedMatch[1])) {
return;
}
if (/navigation stopped:/i.test(statusText)) {
currentGoalName = '';
currentGoalIndex = -1;
currentGoalTotal = 0;
setText('d-active-goal', '--');
renderTaskGoals();
}
pendingModePromise = (async () => {
const ok = await setMode(mode);
return ok ? mode : currentMode;
})();
const resolvedMode = await pendingModePromise;
pendingModePromise = null;
return resolvedMode === mode;
}
function worldToCanvas(x, y) {
@@ -115,12 +170,16 @@ function canvasToWorld(px, py) {
}
function buildMapModel(points) {
if (!points || !points.length || !mapCanvas) return null;
if (!mapCanvas) return null;
const boundsPoints = [...(points || [])];
for (const goal of defaultMissionGoals) boundsPoints.push([goal.x, goal.y]);
if (!boundsPoints.length) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const [x, y] of points) {
for (const [x, y] of boundsPoints) {
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
@@ -132,7 +191,102 @@ function buildMapModel(points) {
const spanX = Math.max(maxX - minX, 1e-6);
const spanY = Math.max(maxY - minY, 1e-6);
const scale = Math.min(usableW / spanX, usableH / spanY);
return { minX, minY, maxX, maxY, scale, pad, drawH: usableH };
return { minX, minY, maxX, maxY, scale, pad, drawH: usableH, points: points || [] };
}
function goalColor(goal) {
return goal.policy === 'crawl' ? '#30d158' : '#ffd60a';
}
function drawMissionPath() {
if (!mapCtx || defaultMissionGoals.length < 2) return;
mapCtx.save();
mapCtx.lineWidth = 2;
mapCtx.strokeStyle = 'rgba(34, 197, 94, 0.95)';
mapCtx.beginPath();
for (let i = 0; i < defaultMissionGoals.length; i += 1) {
const goal = defaultMissionGoals[i];
const p = worldToCanvas(goal.x, goal.y);
if (!p) continue;
if (i === 0) mapCtx.moveTo(p.x, p.y);
else mapCtx.lineTo(p.x, p.y);
}
mapCtx.stroke();
if (currentGoalIndex > 0) {
mapCtx.strokeStyle = 'rgba(10, 132, 255, 0.95)';
mapCtx.lineWidth = 3;
mapCtx.beginPath();
let started = false;
for (let i = 0; i <= Math.min(currentGoalIndex, defaultMissionGoals.length - 1); i += 1) {
const goal = defaultMissionGoals[i];
const p = worldToCanvas(goal.x, goal.y);
if (!p) continue;
if (!started) {
mapCtx.moveTo(p.x, p.y);
started = true;
} else {
mapCtx.lineTo(p.x, p.y);
}
}
if (started) mapCtx.stroke();
}
mapCtx.restore();
}
function drawActiveNavPath() {
if (!mapCtx || !activeNavPath || !Array.isArray(activeNavPath.points) || activeNavPath.points.length < 2) return;
mapCtx.save();
mapCtx.lineWidth = 2.5;
mapCtx.strokeStyle = 'rgba(168, 85, 247, 0.95)';
mapCtx.beginPath();
let started = false;
for (const point of activeNavPath.points) {
if (!Array.isArray(point) || point.length < 2) continue;
const p = worldToCanvas(point[0], point[1]);
if (!p) continue;
if (!started) {
mapCtx.moveTo(p.x, p.y);
started = true;
} else {
mapCtx.lineTo(p.x, p.y);
}
}
if (started) mapCtx.stroke();
const highlightIndex = Math.min(
Math.max(0, Number(activeNavPath.path_index || 0)),
activeNavPath.points.length - 1,
);
const highlightPoint = activeNavPath.points[highlightIndex];
if (Array.isArray(highlightPoint) && highlightPoint.length >= 2) {
const p = worldToCanvas(highlightPoint[0], highlightPoint[1]);
if (p) {
mapCtx.fillStyle = '#c084fc';
mapCtx.beginPath();
mapCtx.arc(p.x, p.y, 5, 0, Math.PI * 2);
mapCtx.fill();
}
}
mapCtx.restore();
}
function drawGoal(goal, index) {
const p = worldToCanvas(goal.x, goal.y);
if (!p || !mapCtx) return;
const active = goal.name === currentGoalName;
const completed = currentGoalIndex > index;
mapCtx.fillStyle = active ? '#ff9f0a' : completed ? '#67e8f9' : goalColor(goal);
mapCtx.beginPath();
mapCtx.arc(p.x, p.y, active ? 7 : 5, 0, Math.PI * 2);
mapCtx.fill();
mapCtx.strokeStyle = '#111827';
mapCtx.lineWidth = 1.5;
mapCtx.stroke();
mapCtx.fillStyle = active ? '#ffcf66' : '#ffe680';
mapCtx.font = '12px sans-serif';
mapCtx.fillText(`${index + 1}.${goal.name}`, p.x + 8, p.y - 8);
}
function drawMap() {
@@ -155,6 +309,13 @@ function drawMap() {
mapCtx.fillRect(p.x, p.y, 1.5, 1.5);
}
drawMissionPath();
drawActiveNavPath();
for (const [index, goal] of defaultMissionGoals.entries()) {
drawGoal(goal, index);
}
if (latestPose) {
const p = worldToCanvas(latestPose.x, latestPose.y);
if (p) {
@@ -186,16 +347,74 @@ function drawMap() {
}
}
function formatRouteAlignment(info) {
if (!info || Object.keys(info).length === 0) return '--';
const applied = Number.isFinite(info.applied_deg) ? `${Number(info.applied_deg).toFixed(2)}deg` : '--';
const hits = Number.isFinite(info.hits) && Number.isFinite(info.total) ? `${info.hits}/${info.total}` : '--';
const reason = info.reason || (info.enabled ? 'auto align' : 'fixed');
return `${applied} hits=${hits} ${reason}`;
}
function renderTaskGoals() {
const list = $('task-goals-list');
if (!list) return;
if (!defaultMissionGoals.length) {
list.innerHTML = '<div class="task-goal-item empty">No mission goals loaded</div>';
return;
}
list.innerHTML = defaultMissionGoals.map((goal, index) => {
const yawText = Number.isFinite(goal.yaw_deg) ? `${Number(goal.yaw_deg).toFixed(1)}deg` : '--';
const yawTolText = Number.isFinite(goal.yaw_tolerance_deg) ? `${Number(goal.yaw_tolerance_deg).toFixed(1)}deg` : '--';
const tolText = Number.isFinite(goal.tolerance) ? Number(goal.tolerance).toFixed(2) : '--';
const policyText = goal.policy || 'rough';
const requireYawText = goal.require_yaw ? ' strict_yaw' : '';
const preDockText = Number.isFinite(goal.pre_dock_distance) ? ` pre_dock=${Number(goal.pre_dock_distance).toFixed(2)}` : '';
const activeClass = goal.name === currentGoalName ? ' active' : '';
return `
<div class="task-goal-item${activeClass}">
<div class="task-goal-head">
<span class="task-goal-index">${index + 1}</span>
<span class="task-goal-name">${goal.name}</span>
<span class="task-goal-policy ${policyText}">${policyText}</span>
</div>
<div class="task-goal-meta">x=${formatNumber(goal.x)} y=${formatNumber(goal.y)} yaw=${yawText} yaw_tol=${yawTolText} tol=${tolText}${requireYawText}${preDockText}</div>
</div>`;
}).join('');
}
function applyMapMetadata(data) {
const goalSpecs = Array.isArray(data.goal_specs) ? data.goal_specs : [];
goalPointsByName = {};
for (const goal of goalSpecs) {
if (!goal || !goal.name) continue;
goalPointsByName[goal.name] = goal;
}
defaultMissionName = data.default_mission_name || '';
defaultMissionGoals = Array.isArray(data.default_mission_goals) ? data.default_mission_goals : [];
routeAlignmentInfo = data.route_alignment || {};
activeNavPath = data.nav_path || activeNavPath;
currentGoalIndex = currentGoalName
? defaultMissionGoals.findIndex(goal => goal.name === currentGoalName)
: -1;
currentGoalTotal = defaultMissionGoals.length;
setText('d-task-mission', defaultMissionName || '--', defaultMissionName ? 'ok' : '');
setText('d-task-goals', String(defaultMissionGoals.length || 0), defaultMissionGoals.length ? 'ok' : '');
setText('d-route-align', formatRouteAlignment(routeAlignmentInfo), routeAlignmentInfo.enabled ? 'ok' : 'warn');
updateTaskButtonState();
renderTaskGoals();
}
async function fetchMap() {
try {
const res = await fetch('/api/map');
const data = await res.json();
const points = data.points || [];
applyMapMetadata(data);
const points = Array.isArray(data.points) ? data.points : [];
mapModel = buildMapModel(points);
if (mapModel) mapModel.points = points;
if (data.pose) latestPose = data.pose;
drawMap();
appendEvent('MAP', `loaded ${points.length} filtered points`, 'ok');
appendEvent('MAP', `loaded ${points.length} filtered points, mission=${defaultMissionName || 'none'}`, 'ok');
} catch (e) {
appendEvent('MAP_ERROR', e.message, 'bad');
}
@@ -209,27 +428,25 @@ function onMapClick(event) {
const world = canvasToWorld(px, py);
if (!world) return;
lastGoal = world;
currentGoalName = '';
setText('d-active-goal', 'direct goal', 'active');
renderTaskGoals();
drawMap();
ensureMode('NAV').then(ok => {
ensureMode('NAV').then(async ok => {
if (!ok) return;
post({ type: 'go_to', x: world.x, y: world.y });
appendEvent('NAV_GO', `x=${world.x.toFixed(2)} y=${world.y.toFixed(2)}`, 'ok');
const result = await post({ type: 'go_to', x: world.x, y: world.y });
if (result.ok) {
appendEvent('NAV_GO', `x=${world.x.toFixed(2)} y=${world.y.toFixed(2)}`, 'ok');
}
});
}
function setText(id, text, cls) {
const el = $(id);
if (!el) return;
el.textContent = text;
if (cls !== undefined) el.className = 'diag-value ' + cls;
}
function initJointsGrid() {
const grid = $('joints-grid');
if (!grid) return;
grid.innerHTML = JOINT_NAMES.map((name, i) => `
<div class="motor-row" id="mi-${i}">
<span class="stale" id="ms-${i}" style="color:#ef4444"></span>
<span class="stale" id="ms-${i}" style="color:#ef4444">*</span>
<span class="name">${name}</span>
<span class="val pos" id="mp-${i}">0.00</span>
<span class="val vel" id="mv-${i}">0.00</span>
@@ -261,14 +478,17 @@ function updateJointsGrid(robot) {
function applyState(data) {
const connected = data.connected && (!data.local_receive_time || Date.now() / 1000 - data.local_receive_time < 2.5);
latestConnected = connected;
if (!connected) {
$('stage').textContent = 'DISCONNECTED';
$('stage').className = 'stage DISCONNECTED';
updateTaskButtonState();
return;
}
const mode = data.mode || 'UNKNOWN';
if (mode !== currentMode) highlightMode(mode);
updateTaskButtonState();
const rt = data.runtime || {};
const src = rt.target_source || '--';
@@ -280,6 +500,18 @@ function applyState(data) {
setText('d-estop', String(data.estop ?? '--'), data.estop ? 'bad' : 'ok');
setText('d-mux', data.mux_status || '--');
const model = data.model || {};
latestModelState = {
current_model: model.current_model || '--',
switch_state: model.switch_state || '--',
backend: model.backend || '--',
switching: Boolean(model.switching),
};
setText('d-model', latestModelState.current_model, latestModelState.current_model === 'crawl' ? 'active' : 'ok');
setText('d-model-switch', latestModelState.switch_state, latestModelState.switching ? 'warn' : 'ok');
setText('d-model-backend', latestModelState.backend || '--');
setText('d-model-switching', String(latestModelState.switching), latestModelState.switching ? 'warn' : 'ok');
const robot = data.robot || {};
const imuAge = robot.imu_age_ms ?? null;
setText('d-imu-fresh', String(robot.imu_fresh ?? '--'), robot.imu_fresh ? 'ok' : 'bad');
@@ -292,13 +524,15 @@ function applyState(data) {
const lp = robot.odom_local_pos;
setText('d-odom-pos', lp ? `x=${Number(lp[0]).toFixed(2)} y=${Number(lp[1]).toFixed(2)}` : '--');
setText('d-nav-status', data.nav_status || '--');
syncGoalMarkerFromNavStatus(data.nav_status || '');
const nav = data.nav || {};
if (nav.pose) {
latestPose = nav.pose;
setText('d-nav-pose', `x=${nav.pose.x.toFixed(2)} y=${nav.pose.y.toFixed(2)} yaw=${(nav.pose.yaw * 57.2958).toFixed(1)}deg`);
drawMap();
}
if (nav.path) activeNavPath = nav.path;
drawMap();
const cv = data.cmd_vel || {};
const lin = cv.linear || {};
@@ -328,31 +562,96 @@ function appendEvent(kind, detail, cls) {
el.scrollTop = el.scrollHeight;
}
$('btn-disabled').onclick = () => {
async function setMode(mode) {
if (mode === 'WEB') {
const ok = confirm('Confirm switch to WEB control?\nMake sure the robot is safe and velocity is zero.');
if (!ok) return false;
}
const result = await post({ type: 'mode', mode });
if (!result.ok) return false;
highlightMode(mode);
appendEvent('MODE_SET', `-> ${mode}`, 'ok');
return true;
}
async function ensureMode(mode) {
if (currentMode === mode) return true;
if (pendingModePromise) {
const activeMode = await pendingModePromise;
if (activeMode === mode) return true;
}
pendingModePromise = (async () => {
const ok = await setMode(mode);
return ok ? mode : currentMode;
})();
const resolvedMode = await pendingModePromise;
pendingModePromise = null;
return resolvedMode === mode;
}
$('btn-disabled').onclick = async () => {
zeroAll();
setMode('DISABLED');
await setMode('DISABLED');
};
$('btn-remote').onclick = () => setMode('REMOTE');
$('btn-web').onclick = () => setMode('WEB');
$('btn-nav').onclick = () => setMode('NAV');
$('btn-zero').onclick = zeroAll;
$('btn-estop').onclick = () => {
$('btn-estop').onclick = async () => {
if (confirm('Confirm soft e-stop?')) {
post({ type: 'estop', data: true });
const result = await post({ type: 'estop', data: true });
zeroAll();
appendEvent('ESTOP', 'soft e-stop triggered', 'bad');
if (result.ok) appendEvent('ESTOP', 'soft e-stop triggered', 'bad');
}
};
$('btn-refresh-map').onclick = fetchMap;
$('btn-record').onclick = () => ensureMode('NAV').then(ok => {
if (ok) post({ type: 'nav_cmd', command: 'record web_p1' });
$('btn-run-task').onclick = async () => {
if (!latestConnected) {
appendEvent('TASK', 'bridge not connected', 'warn');
updateTaskButtonState();
return;
}
if (currentMode !== 'NAV') {
appendEvent('TASK', 'only available in NAV mode', 'warn');
updateTaskButtonState();
return;
}
if (!defaultMissionName) {
appendEvent('TASK', 'no default mission loaded', 'warn');
return;
}
if (defaultMissionGoals.length) {
setLastGoalByName(defaultMissionGoals[0].name);
drawMap();
}
const result = await post({ type: 'nav_cmd', command: `run ${defaultMissionName}` });
if (result.ok) {
appendEvent('TASK', `run mission ${defaultMissionName}`, 'ok');
}
};
$('btn-record').onclick = () => ensureMode('NAV').then(async ok => {
if (!ok) return;
const result = await post({ type: 'nav_cmd', command: 'record web_p1' });
if (result.ok) appendEvent('NAV_RECORD', 'record web_p1', 'ok');
});
$('btn-stop-nav').onclick = () => ensureMode('NAV').then(ok => {
if (ok) post({ type: 'nav_cmd', command: 'stop' });
$('btn-stop-nav').onclick = () => ensureMode('NAV').then(async ok => {
if (!ok) return;
const result = await post({ type: 'nav_cmd', command: 'stop' });
if (result.ok) appendEvent('NAV_STOP', 'simple_nav stop', 'ok');
});
$('btn-go-rel').onclick = () => ensureMode('NAV').then(ok => {
if (ok) post({ type: 'go_rel', dx: 0.3, dy: 0.0 });
$('btn-go-rel').onclick = () => ensureMode('NAV').then(async ok => {
if (!ok) return;
const result = await post({ type: 'go_rel', dx: 0.3, dy: 0.0 });
if (result.ok) appendEvent('NAV_REL', 'forward 0.3m', 'ok');
});
$('btn-model-switch').onclick = async () => {
if (latestModelState.switching) {
appendEvent('MODEL', 'switch already in progress', 'warn');
return;
}
const result = await post({ type: 'model_toggle' });
if (result.ok) appendEvent('MODEL', `toggle requested from ${latestModelState.current_model}`, 'ok');
};
for (const [id, key] of [['cmd-vx', 'vx'], ['cmd-vy', 'vy'], ['cmd-yaw', 'yaw']]) {
$(id).addEventListener('input', e => {
@@ -1,10 +1,10 @@
<!doctype html>
<html lang="zh-CN" data-theme="dark">
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>sim2real ROS2 控制台</title>
<link rel="stylesheet" href="style.css">
<title>sim2real ROS2 Console</title>
<link rel="stylesheet" href="style.css?v=mission-sync-2">
</head>
<body>
<header class="glass-panel top-bar">
@@ -13,32 +13,37 @@
<span class="stage" id="stage">DISCONNECTED</span>
</div>
<div class="top-bar-center">
<span class="label">控制模式</span>
<span class="label">Control Mode</span>
<button class="btn" id="btn-disabled">DISABLED</button>
<button class="btn btn-remote" id="btn-remote">REMOTE</button>
<button class="btn btn-web" id="btn-web">WEB</button>
<button class="btn btn-nav" id="btn-nav">NAV</button>
<div class="divider"></div>
<button class="btn" id="btn-zero">速度归零</button>
<button class="btn" id="btn-zero">Zero Velocity</button>
</div>
<div class="top-bar-right">
<button class="btn btn-danger" id="btn-estop">软急停</button>
<button class="btn btn-danger" id="btn-estop">Soft E-Stop</button>
</div>
</header>
<div class="glass-panel side-panel left-panel">
<div class="panel-section">
<h2 class="panel-title">运行状态</h2>
<h2 class="panel-title">Runtime</h2>
<div class="diag-row"><span class="diag-label">target_source</span><span class="diag-value" id="d-source">--</span></div>
<div class="diag-row"><span class="diag-label">runtime_released</span><span class="diag-value" id="d-released">--</span></div>
<div class="diag-row"><span class="diag-label">release_alpha</span><span class="diag-value" id="d-alpha">--</span></div>
<div class="diag-row"><span class="diag-label">zero_command</span><span class="diag-value" id="d-zero">--</span></div>
<div class="diag-row"><span class="diag-label">estop</span><span class="diag-value" id="d-estop">--</span></div>
<div class="diag-row"><span class="diag-label">mux</span><span class="diag-value" id="d-mux">--</span></div>
<div class="diag-row"><span class="diag-label">model</span><span class="diag-value" id="d-model">--</span></div>
<div class="diag-row"><span class="diag-label">switch_state</span><span class="diag-value" id="d-model-switch">--</span></div>
<div class="diag-row"><span class="diag-label">backend</span><span class="diag-value" id="d-model-backend">--</span></div>
<div class="diag-row"><span class="diag-label">switching</span><span class="diag-value" id="d-model-switching">--</span></div>
<div class="diag-row"><button class="btn" id="btn-model-switch">Toggle Model</button></div>
</div>
<div class="panel-section">
<h2 class="panel-title">IMU &amp; 里程计</h2>
<h2 class="panel-title">IMU and Odom</h2>
<div class="diag-row"><span class="diag-label">IMU fresh</span><span class="diag-value" id="d-imu-fresh">--</span></div>
<div class="diag-row"><span class="diag-label">IMU age ms</span><span class="diag-value" id="d-imu-age">--</span></div>
<div class="diag-row"><span class="diag-label">projected_gravity</span><span class="diag-value" id="d-gravity">--</span></div>
@@ -47,10 +52,14 @@
<div class="diag-row"><span class="diag-label">odom local pos</span><span class="diag-value" id="d-odom-pos">--</span></div>
<div class="diag-row"><span class="diag-label">nav pose</span><span class="diag-value" id="d-nav-pose">--</span></div>
<div class="diag-row"><span class="diag-label">nav status</span><span class="diag-value" id="d-nav-status">--</span></div>
<div class="diag-row"><span class="diag-label">task mission</span><span class="diag-value" id="d-task-mission">--</span></div>
<div class="diag-row"><span class="diag-label">active goal</span><span class="diag-value" id="d-active-goal">--</span></div>
<div class="diag-row"><span class="diag-label">goal count</span><span class="diag-value" id="d-task-goals">--</span></div>
<div class="diag-row"><span class="diag-label">route align</span><span class="diag-value" id="d-route-align">--</span></div>
</div>
<div class="panel-section flex-1">
<h2 class="panel-title">关节状态 (16)</h2>
<h2 class="panel-title">Joint State (16)</h2>
<div id="joints-grid" class="motors-grid-list"></div>
</div>
</div>
@@ -58,25 +67,26 @@
<main class="glass-panel center-panel">
<div class="panel-section map-section">
<div class="map-header">
<h2 class="panel-title">PCD 2D 导航</h2>
<h2 class="panel-title">PCD 2D Nav</h2>
<div class="map-actions">
<button class="btn" id="btn-refresh-map">刷新地图</button>
<button class="btn" id="btn-record">记录当前位置</button>
<button class="btn" id="btn-stop-nav">停止导航</button>
<button class="btn" id="btn-go-rel">前进 0.3m</button>
<button class="btn" id="btn-refresh-map">Refresh Map</button>
<button class="btn btn-nav" id="btn-run-task" disabled>Run Mission</button>
<button class="btn" id="btn-record">Record Pose</button>
<button class="btn" id="btn-stop-nav">Stop Nav</button>
<button class="btn" id="btn-go-rel">Forward 0.3m</button>
</div>
</div>
<canvas id="map-canvas" width="900" height="680"></canvas>
<div class="map-help">左键点击地图发送绝对目标;蓝点是当前机器人,红叉是最近目标。</div>
<div class="map-help">Left click sends an absolute goal. Green line is the mission path, purple is the active A* path, cyan marks completed waypoints, orange is the current target, and blue is the robot pose.</div>
</div>
</main>
<div class="glass-panel side-panel right-panel">
<div class="panel-section">
<h2 class="panel-title">Web 手动控制</h2>
<h2 class="panel-title">Web Manual Control</h2>
<div class="joystick-area">
<div class="joystick" id="joystick"><div id="stick"></div></div>
<p class="hint">拖动控制前后(vx)和横移(vy),松开归零</p>
<p class="hint">Drag to control vx and vy. Release to return to zero.</p>
</div>
<div class="slider-group">
<div class="slider-row">
@@ -91,7 +101,7 @@
</div>
<div class="slider-row">
<span class="slider-label">yaw</span>
<input type="range" id="cmd-yaw" class="glass-slider" min="-0.5" max="0.5" step="0.01" value="0">
<input type="range" id="cmd-yaw" class="glass-slider" min="-0.8" max="0.8" step="0.01" value="0">
<span class="slider-val" id="cmd-yaw-v">0.00</span>
</div>
</div>
@@ -99,18 +109,18 @@
</div>
<div class="panel-section">
<h2 class="panel-title">当前输出 /cmd_vel</h2>
<h2 class="panel-title">Current /cmd_vel</h2>
<div class="diag-row"><span class="diag-label">linear.x</span><span class="diag-value" id="cv-vx">--</span></div>
<div class="diag-row"><span class="diag-label">linear.y</span><span class="diag-value" id="cv-vy">--</span></div>
<div class="diag-row"><span class="diag-label">angular.z</span><span class="diag-value" id="cv-yaw">--</span></div>
</div>
<div class="panel-section log-section flex-1">
<h2 class="panel-title">事件流</h2>
<h2 class="panel-title">Events</h2>
<div id="events-log" class="log"></div>
</div>
</div>
<script src="app.js"></script>
<script src="app.js?v=mission-sync-2"></script>
</body>
</html>
@@ -107,6 +107,18 @@ body {
.btn-nav { background: rgba(255,214,10,0.2); border-color: rgba(255,214,10,0.4); color: #ffe680; }
.btn.active-mode { box-shadow: 0 0 0 2px white; }
.route-select {
min-width: 220px;
height: 30px;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
background: rgba(0,0,0,0.32);
color: var(--text-primary);
font-size: 12px;
font-family: inherit;
padding: 0 8px;
}
.label { font-size: 11px; color: var(--text-tertiary); }
.side-panel,
@@ -172,6 +184,85 @@ body {
flex-wrap: wrap;
gap: 8px;
}
.route-section {
flex-shrink: 0;
}
.task-goals-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.task-goal-item {
padding: 10px 12px;
border-radius: 8px;
background: rgba(0,0,0,0.24);
border: 1px solid rgba(255,255,255,0.06);
}
.task-goal-item.active {
border-color: rgba(255,214,10,0.55);
box-shadow: inset 0 0 0 1px rgba(255,214,10,0.18);
}
.task-goal-item.empty {
grid-column: 1 / -1;
color: var(--text-tertiary);
text-align: center;
}
.task-goal-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.task-goal-index {
min-width: 20px;
height: 20px;
border-radius: 999px;
background: rgba(10,132,255,0.22);
color: #82c4ff;
font-size: 11px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.task-goal-name {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
font-family: monospace;
flex: 1;
}
.task-goal-policy {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
}
.task-goal-policy.rough {
color: #ffe680;
}
.task-goal-policy.crawl {
color: #8deda7;
}
.task-goal-meta {
font-size: 11px;
color: var(--text-tertiary);
font-family: monospace;
}
.obstacle-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.obstacle-grid .btn {
padding-left: 6px;
padding-right: 6px;
overflow: hidden;
text-overflow: ellipsis;
}
#map-canvas {
width: 100%;
height: calc(100% - 56px);
@@ -296,4 +387,5 @@ body {
}
.center-panel { min-height: 700px; }
#map-canvas { min-height: 420px; }
.task-goals-list { grid-template-columns: 1fr; }
}