Add IMU layout

This commit is contained in:
2026-05-29 17:12:22 +07:00
parent 194fffcdf7
commit 4f8d8148f7
7 changed files with 827 additions and 43 deletions

View File

@@ -2,8 +2,9 @@
Chức năng:
- Đăng ký danh sách cảm biến LiDAR (tên, ip, port)
- Kéo thả icon LiDAR trên canvas để set vị trí tương đối (theo px)
- Lưu cấu hình xuống file JSON
- Đăng ký IMU (tên, frame_id, topic, nguồn) và pose trên robot
- Kéo thả icon LiDAR/IMU trên canvas để set vị trí (robot frame)
- Nhiều layout — mỗi layout lưu tại `data/models/{id}.json`; catalog trong `data/state.json`
## Build

View File

@@ -1,7 +1,27 @@
{
"created_at": "2026-05-29T08:27:25Z",
"id": "a07ab938d9029ef1",
"imus": [
{
"enabled": true,
"frame_id": "imu_link",
"id": "f7ddb6d2c3c1c5cf",
"name": "IMU test",
"rate_hz": 100,
"source": "onboard",
"topic": "imu/data"
}
],
"layout": {
"imuPoses": {
"f7ddb6d2c3c1c5cf": {
"x": 196.14886948882076,
"y": 0.1286840744156286,
"yaw_deg": 0,
"z": 0.1
}
},
"imuPosesFrame": "robot",
"lidarPoses": {
"02c4b7f4de7bd639": {
"theta_deg": 45,
@@ -200,5 +220,5 @@
}
],
"name": "Mặc định",
"updated_at": "2026-05-29T08:39:03Z"
"updated_at": "2026-05-29T10:09:07Z"
}

View File

@@ -1,7 +1,27 @@
{
"created_at": "2026-05-29T08:40:51Z",
"id": "ea89e39c835c0557",
"imus": [
{
"enabled": true,
"frame_id": "imu_link",
"id": "719a21772e114466",
"name": "IMU",
"rate_hz": 100,
"source": "external",
"topic": "imu/data"
}
],
"layout": {
"imuPoses": {
"719a21772e114466": {
"x": 0.06910131801805619,
"y": 0.8135664703630141,
"yaw_deg": 0,
"z": 0.1
}
},
"imuPosesFrame": "robot",
"lidarPoses": {
"40235913b52d8101": {
"theta_deg": -135,
@@ -173,5 +193,5 @@
}
],
"name": "T800",
"updated_at": "2026-05-29T08:44:03Z"
"updated_at": "2026-05-29T10:11:49Z"
}

View File

@@ -1,21 +1,23 @@
{
"active_layout_id": "a07ab938d9029ef1",
"active_layout_id": "ea89e39c835c0557",
"layouts": [
{
"created_at": "2026-05-29T08:27:25Z",
"id": "a07ab938d9029ef1",
"imu_count": 1,
"lidar_count": 3,
"model": "bicycle",
"name": "Mặc định",
"updated_at": "2026-05-29T08:39:03Z"
"updated_at": "2026-05-29T10:09:07Z"
},
{
"created_at": "2026-05-29T08:40:51Z",
"id": "ea89e39c835c0557",
"imu_count": 1,
"lidar_count": 2,
"model": "diff",
"name": "T800",
"updated_at": "2026-05-29T08:44:03Z"
"updated_at": "2026-05-29T10:11:49Z"
}
],
"version": 3

View File

@@ -197,10 +197,15 @@ static json default_layout_object()
{"map", {{"width", 800}, {"height", 600}}},
{"lidarPositions", json::object()},
{"lidarPoses", json::object()},
{"lidarPosesFrame", "robot"}};
{"lidarPosesFrame", "robot"},
{"imuPoses", json::object()},
{"imuPosesFrame", "robot"}};
}
static json make_layout_profile(const std::string& name, const json& layout, const json& lidars)
static json make_layout_profile(const std::string& name,
const json& layout,
const json& lidars,
const json& imus = json::array())
{
const std::string ts = now_iso8601();
return json{{"id", new_id()},
@@ -208,7 +213,8 @@ static json make_layout_profile(const std::string& name, const json& layout, con
{"created_at", ts},
{"updated_at", ts},
{"layout", layout},
{"lidars", lidars}};
{"lidars", lidars},
{"imus", imus}};
}
static std::string trim_copy(const std::string& s)
@@ -271,12 +277,15 @@ static json catalog_entry_from_profile(const json& profile)
const json& layout = profile.contains("layout") ? profile["layout"] : json::object();
const size_t lidar_count =
profile.contains("lidars") && profile["lidars"].is_array() ? profile["lidars"].size() : 0;
const size_t imu_count =
profile.contains("imus") && profile["imus"].is_array() ? profile["imus"].size() : 0;
return json{{"id", profile["id"]},
{"name", profile["name"]},
{"model", profile_model_from_layout(layout)},
{"created_at", profile.value("created_at", "")},
{"updated_at", profile.value("updated_at", "")},
{"lidar_count", lidar_count}};
{"lidar_count", lidar_count},
{"imu_count", imu_count}};
}
static std::optional<json> load_profile_from_disk(const AppState& app, const std::string& id)
@@ -337,10 +346,13 @@ static void load_active_cache(AppState& app)
profile["layout"] = default_layout_object();
if (!profile.contains("lidars") || !profile["lidars"].is_array())
profile["lidars"] = json::array();
if (!profile.contains("imus") || !profile["imus"].is_array())
profile["imus"] = json::array();
ensure_layout_schema(profile["layout"]);
state["layout"] = profile["layout"];
state["lidars"] = profile["lidars"];
state["imus"] = profile["imus"];
}
static bool persist_active_profile(AppState& app)
@@ -358,6 +370,7 @@ static bool persist_active_profile(AppState& app)
profile["updated_at"] = now_iso8601();
profile["layout"] = state["layout"];
profile["lidars"] = state["lidars"];
profile["imus"] = state.contains("imus") && state["imus"].is_array() ? state["imus"] : json::array();
ensure_layout_schema(profile["layout"]);
if (!save_profile_to_disk(app, profile))
return false;
@@ -530,6 +543,7 @@ static void bootstrap_default_state(AppState& app)
{"layouts", json::array({catalog_entry_from_profile(profile)})}};
app.state["layout"] = profile["layout"];
app.state["lidars"] = profile["lidars"];
app.state["imus"] = profile.contains("imus") ? profile["imus"] : json::array();
}
static void ensure_layout_schema(json& layout)
@@ -546,6 +560,10 @@ static void ensure_layout_schema(json& layout)
layout["lidarPoses"] = json::object();
if (!layout.contains("lidarPosesFrame"))
layout["lidarPosesFrame"] = "robot";
if (!layout.contains("imuPoses") || !layout["imuPoses"].is_object())
layout["imuPoses"] = json::object();
if (!layout.contains("imuPosesFrame"))
layout["imuPosesFrame"] = "robot";
auto& robot = layout["robot"];
if (!robot.contains("x"))
@@ -803,7 +821,9 @@ static bool save_app_state(AppState& app)
static std::optional<size_t> find_lidar_index(const json& state, const std::string& id)
{
const auto& lidars = state.at("lidars");
if (!state.contains("lidars") || !state["lidars"].is_array())
return std::nullopt;
const auto& lidars = state["lidars"];
for (size_t i = 0; i < lidars.size(); i++)
{
const auto& l = lidars[i];
@@ -813,6 +833,20 @@ static std::optional<size_t> find_lidar_index(const json& state, const std::stri
return std::nullopt;
}
static std::optional<size_t> find_imu_index(const json& state, const std::string& id)
{
if (!state.contains("imus") || !state["imus"].is_array())
return std::nullopt;
const auto& imus = state["imus"];
for (size_t i = 0; i < imus.size(); i++)
{
const auto& im = imus[i];
if (im.is_object() && im.contains("id") && im["id"].is_string() && im["id"].get<std::string>() == id)
return i;
}
return std::nullopt;
}
static void json_error(httplib::Response& res, int status, const std::string& msg)
{
res.status = status;
@@ -884,6 +918,67 @@ static bool lidar_triplet_exists(const json& state,
return false;
}
static bool validate_imu_payload(const json& payload, std::string& err)
{
if (!payload.is_object())
{
err = "payload must be a JSON object";
return false;
}
if (!payload.contains("name") || !payload["name"].is_string() || payload["name"].get<std::string>().empty())
{
err = "name is required";
return false;
}
if (!payload.contains("frame_id") || !payload["frame_id"].is_string() ||
payload["frame_id"].get<std::string>().empty())
{
err = "frame_id is required";
return false;
}
if (!payload.contains("topic") || !payload["topic"].is_string() || payload["topic"].get<std::string>().empty())
{
err = "topic is required";
return false;
}
if (payload.contains("source") && payload["source"].is_string())
{
const std::string src = payload["source"].get<std::string>();
if (src != "external" && src != "lidar_builtin" && src != "onboard")
{
err = "source must be external, lidar_builtin, or onboard";
return false;
}
}
if (payload.contains("rate_hz") && !payload["rate_hz"].is_number())
{
err = "rate_hz must be a number";
return false;
}
return true;
}
static bool imu_frame_exists(const json& state,
const std::string& frame_id,
const std::string* exclude_id = nullptr)
{
if (!state.contains("imus") || !state["imus"].is_array())
return false;
const std::string f = trim_copy(frame_id);
for (const auto& im : state["imus"])
{
if (!im.is_object())
continue;
if (exclude_id && im.contains("id") && im["id"].get<std::string>() == *exclude_id)
continue;
if (!im.contains("frame_id"))
continue;
if (trim_copy(im["frame_id"].get<std::string>()) == f)
return true;
}
return false;
}
static void mount_static(httplib::Server& svr, const fs::path& www_root)
{
svr.Get(R"(/(.*))", [www_root](const httplib::Request& req, httplib::Response& res) {
@@ -959,7 +1054,8 @@ int main(int argc, char** argv)
{"active_layout_name", active_name},
{"layouts", build_layouts_catalog(app.state)},
{"layout", app.state["layout"]},
{"lidars", app.state["lidars"]}};
{"lidars", app.state["lidars"]},
{"imus", app.state.contains("imus") ? app.state["imus"] : json::array()}};
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = response.dump();
});
@@ -996,12 +1092,14 @@ int main(int argc, char** argv)
const bool clone = payload.contains("clone") && payload["clone"].is_boolean() && payload["clone"].get<bool>();
json layout = default_layout_object();
json lidars = json::array();
json imus = json::array();
if (clone)
{
layout = app.state["layout"];
lidars = app.state["lidars"];
imus = app.state.contains("imus") && app.state["imus"].is_array() ? app.state["imus"] : json::array();
}
json profile = make_layout_profile(name, layout, lidars);
json profile = make_layout_profile(name, layout, lidars, imus);
ensure_layout_schema(profile["layout"]);
if (!save_profile_to_disk(app, profile))
return json_error(res, 500, "failed to save layout file");
@@ -1217,6 +1315,10 @@ int main(int argc, char** argv)
profile["layout"] = payload["layout"];
if (payload.contains("lidars") && payload["lidars"].is_array())
profile["lidars"] = payload["lidars"];
if (payload.contains("imus") && payload["imus"].is_array())
profile["imus"] = payload["imus"];
if (!profile.contains("imus") || !profile["imus"].is_array())
profile["imus"] = json::array();
ensure_layout_schema(profile["layout"]);
touch_profile(profile);
if (!save_profile_to_disk(app, profile))
@@ -1229,6 +1331,7 @@ int main(int argc, char** argv)
{
app.state["layout"] = profile["layout"];
app.state["lidars"] = profile["lidars"];
app.state["imus"] = profile["imus"];
}
save_state(app);
@@ -1236,6 +1339,122 @@ int main(int argc, char** argv)
res.body = profile.dump();
});
svr.Get("/api/imus", [&app](const httplib::Request&, httplib::Response& res) {
add_cors(res);
ensure_schema(app);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = (app.state.contains("imus") ? app.state["imus"] : json::array()).dump();
});
svr.Post("/api/imus", [&app](const httplib::Request& req, httplib::Response& res) {
add_cors(res);
json payload;
try
{
payload = json::parse(req.body);
}
catch (...)
{
return json_error(res, 400, "invalid JSON");
}
std::string err;
if (!validate_imu_payload(payload, err))
return json_error(res, 400, err);
const std::string name = trim_copy(payload["name"].get<std::string>());
const std::string frame_id = trim_copy(payload["frame_id"].get<std::string>());
const std::string topic = trim_copy(payload["topic"].get<std::string>());
if (imu_frame_exists(app.state, frame_id))
return json_error(res, 409, "imu with same frame_id already exists");
if (!app.state.contains("imus") || !app.state["imus"].is_array())
app.state["imus"] = json::array();
const std::string source =
payload.contains("source") && payload["source"].is_string() ? payload["source"].get<std::string>()
: "external";
const bool enabled = !payload.contains("enabled") || payload["enabled"].get<bool>();
const double rate_hz =
payload.contains("rate_hz") && payload["rate_hz"].is_number() ? payload["rate_hz"].get<double>() : 100.0;
json imu = {{"id", new_id()},
{"name", name},
{"frame_id", frame_id},
{"topic", topic},
{"source", source},
{"enabled", enabled},
{"rate_hz", rate_hz}};
app.state["imus"].push_back(imu);
if (!save_app_state(app))
return json_error(res, 500, "failed to save layout");
res.status = 201;
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = imu.dump();
});
svr.Put(R"(/api/imus/([0-9a-fA-F]+))", [&app](const httplib::Request& req, httplib::Response& res) {
add_cors(res);
const std::string id = req.matches[1].str();
json payload;
try
{
payload = json::parse(req.body);
}
catch (...)
{
return json_error(res, 400, "invalid JSON");
}
std::string err;
if (!validate_imu_payload(payload, err))
return json_error(res, 400, err);
auto idx = find_imu_index(app.state, id);
if (!idx)
return json_error(res, 404, "imu not found");
const std::string name = trim_copy(payload["name"].get<std::string>());
const std::string frame_id = trim_copy(payload["frame_id"].get<std::string>());
const std::string topic = trim_copy(payload["topic"].get<std::string>());
if (imu_frame_exists(app.state, frame_id, &id))
return json_error(res, 409, "imu with same frame_id already exists");
auto& imu = app.state["imus"][*idx];
imu["name"] = name;
imu["frame_id"] = frame_id;
imu["topic"] = topic;
if (payload.contains("source") && payload["source"].is_string())
imu["source"] = payload["source"];
if (payload.contains("enabled") && payload["enabled"].is_boolean())
imu["enabled"] = payload["enabled"];
if (payload.contains("rate_hz") && payload["rate_hz"].is_number())
imu["rate_hz"] = payload["rate_hz"];
if (!save_app_state(app))
return json_error(res, 500, "failed to save layout");
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = imu.dump();
});
svr.Delete(R"(/api/imus/([0-9a-fA-F]+))", [&app](const httplib::Request& req, httplib::Response& res) {
add_cors(res);
const std::string id = req.matches[1].str();
auto idx = find_imu_index(app.state, id);
if (!idx)
return json_error(res, 404, "imu not found");
app.state["imus"].erase(app.state["imus"].begin() + static_cast<json::difference_type>(*idx));
if (app.state.contains("layout") && app.state["layout"].is_object())
{
if (app.state["layout"].contains("imuPoses") && app.state["layout"]["imuPoses"].is_object())
app.state["layout"]["imuPoses"].erase(id);
}
if (!save_app_state(app))
return json_error(res, 500, "failed to save layout");
res.status = 204;
});
mount_static(svr, www_root);
// Console hint

View File

@@ -59,6 +59,10 @@ const layoutDeleteBtn = el("layoutDeleteBtn");
const layoutActiveHintEl = el("layoutActiveHint");
const lidarListCard = el("lidarListCard");
const lidarListCardToggle = el("lidarListCardToggle");
const imuListEl = el("imuList");
const imuListCard = el("imuListCard");
const imuListCardToggle = el("imuListCardToggle");
const imuFormHintEl = el("imuFormHint");
const robotModelCard = el("robotModelCard");
const robotModelCardToggle = el("robotModelCardToggle");
@@ -71,6 +75,7 @@ const selectedRelText = el("selectedRelText");
const state = {
lidars: [],
imus: [],
layout: {
map: { width: 800, height: 600 },
robot: { x: 400, y: 300, yaw_deg: 0 },
@@ -78,10 +83,13 @@ const state = {
// It is converted to canvas/world only for drawing and hit-testing.
lidarPoses: {}, // id -> {x,y,theta_deg}
lidarPosesFrame: "robot",
imuPoses: {}, // id -> {x,y,z,yaw_deg}
imuPosesFrame: "robot",
},
dragging: null, // {id, dx, dy} lidar
dragging: null, // {kind:'lidar'|'imu', id, dx, dy}
draggingFootprint: null, // {index, dx, dy}
selectedId: null,
selectedImuId: null,
selectedFootprintVertex: null,
editFootprint: false,
lidarListPanelCollapsed: false,
@@ -91,6 +99,8 @@ const state = {
layoutCatalog: [],
layoutDirty: false,
lidarItemCollapsed: {}, // id -> true if collapsed
imuItemCollapsed: {},
imuListPanelCollapsed: false,
viewInitialized: false,
view: { scale: 1, panX: 0, panY: 0 },
panning: null, // { startSx, startSy, startPanX, startPanY }
@@ -436,6 +446,72 @@ function ensureDefaultPose(id, idx) {
};
}
function ensureDefaultImuPose(id, idx) {
if (!state.layout.imuPoses) state.layout.imuPoses = {};
if (state.layout.imuPoses[id]) return;
const n = Math.max(1, state.imus.length);
const angle = ((idx + 0.5) / n) * Math.PI * 2;
const radius = 80;
state.layout.imuPoses[id] = {
x: Math.cos(angle) * radius * 0.5,
y: -Math.sin(angle) * radius * 0.5,
z: 0.1,
yaw_deg: 0,
};
}
function getImuPoseAbs(id) {
const pose = state.layout.imuPoses?.[id] || null;
if (!pose) return null;
const abs = robotToAbs(Number(pose.x || 0), Number(pose.y || 0));
return { ...pose, absX: abs.x, absY: abs.y };
}
function reconcileImuPoses() {
if (!state.layout.imuPoses) state.layout.imuPoses = {};
const valid = new Set(state.imus.map((im) => im.id));
Object.keys(state.layout.imuPoses).forEach((id) => {
if (!valid.has(id)) delete state.layout.imuPoses[id];
});
}
function findDuplicateImuFrame(frameId, excludeId = null) {
const f = String(frameId || "").trim();
if (!f) return null;
return (
state.imus.find(
(im) => im.id !== excludeId && String(im.frame_id || "").trim() === f,
) || null
);
}
function clearCanvasSelection() {
state.selectedId = null;
state.selectedImuId = null;
selectedText.textContent = "none";
setSelectedRelText();
}
function selectLidarOnCanvas(id) {
state.selectedImuId = null;
state.selectedId = id;
const l = state.lidars.find((x) => x.id === id);
selectedText.textContent = l ? `LiDAR: ${l.name}` : id;
setSelectedRelText();
refreshLidarSelectionUI();
refreshImuSelectionUI();
}
function selectImuOnCanvas(id) {
state.selectedId = null;
state.selectedImuId = id;
const im = state.imus.find((x) => x.id === id);
selectedText.textContent = im ? `IMU: ${im.name}` : id;
setSelectedRelText();
refreshLidarSelectionUI();
refreshImuSelectionUI();
}
function yawCanvasRad() {
// ROS yaw is CCW around +Z (up). Canvas has +Y down, so we flip sign.
return (-(state.layout.robot.yaw_deg || 0) * Math.PI) / 180;
@@ -1499,7 +1575,7 @@ async function persistLayoutNow() {
if (state.activeLayoutId) {
await api(`/api/layouts/${state.activeLayoutId}`, {
method: "PUT",
body: JSON.stringify({ layout: state.layout, lidars: state.lidars }),
body: JSON.stringify({ layout: state.layout, lidars: state.lidars, imus: state.imus }),
});
} else {
await api("/api/layout", { method: "PUT", body: JSON.stringify(state.layout) });
@@ -1667,10 +1743,7 @@ function initLidarListEvents() {
return;
}
if (action === "select") {
state.selectedId = id;
selectedText.textContent = id;
setSelectedRelText();
refreshLidarSelectionUI();
selectLidarOnCanvas(id);
renderCanvas();
return;
}
@@ -1681,10 +1754,7 @@ function initLidarListEvents() {
state.lidars = state.lidars.filter((l) => l.id !== id);
if (state.layout?.lidarPoses) delete state.layout.lidarPoses[id];
delete state.lidarItemCollapsed[id];
if (state.selectedId === id) {
state.selectedId = null;
selectedText.textContent = "none";
}
if (state.selectedId === id) clearCanvasSelection();
setSelectedRelText();
renderList();
renderCanvas();
@@ -1705,6 +1775,184 @@ function initLidarListEvents() {
});
}
function toggleImuItemCollapsed(id) {
state.imuItemCollapsed[id] = !state.imuItemCollapsed[id];
renderImuList();
}
function onImuPoseInputChange(id, action, value) {
ensureDefaultImuPose(id, 0);
const pose = state.layout.imuPoses[id];
if (!pose) return;
if (action === "yaw") pose.yaw_deg = clamp(Number(value), -180, 180);
else if (action === "x") pose.x = Number(value);
else if (action === "y") pose.y = Number(value);
else if (action === "z") pose.z = clamp(Number(value), -5, 5);
if (state.selectedImuId === id) setSelectedRelText();
updateImuItemPoseUI(id);
renderCanvas();
persistLayoutDebounced();
}
function initImuListEvents() {
if (!imuListEl || imuListEl.dataset.bound === "1") return;
imuListEl.dataset.bound = "1";
imuListEl.addEventListener("click", async (evt) => {
const btn = evt.target.closest("button[data-action][data-id]");
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (!action || !id) return;
if (action === "toggle-item") {
evt.stopPropagation();
toggleImuItemCollapsed(id);
return;
}
if (action === "select") {
selectImuOnCanvas(id);
renderCanvas();
return;
}
if (action === "delete") {
if (!confirm("Xóa IMU này?")) return;
try {
await api(`/api/imus/${id}`, { method: "DELETE" });
state.imus = state.imus.filter((im) => im.id !== id);
if (state.layout?.imuPoses) delete state.layout.imuPoses[id];
delete state.imuItemCollapsed[id];
if (state.selectedImuId === id) clearCanvasSelection();
setSelectedRelText();
renderImuList();
renderCanvas();
setStatus("Đã xóa IMU");
} catch (e) {
setStatus(`Lỗi: ${e.message}`);
}
}
});
imuListEl.addEventListener("change", (evt) => {
const input = evt.target.closest("input.poseInput[data-action][data-id]");
if (!input) return;
onImuPoseInputChange(input.dataset.id, input.dataset.action, input.value);
});
}
function setImuFormHint(msg) {
if (!imuFormHintEl) return;
if (!msg) {
imuFormHintEl.hidden = true;
imuFormHintEl.textContent = "";
return;
}
imuFormHintEl.hidden = false;
imuFormHintEl.textContent = msg;
}
let imuFormBusy = false;
async function submitAddImu() {
if (imuFormBusy) return;
imuFormBusy = true;
const addBtn = el("addImuBtn");
if (addBtn) addBtn.disabled = true;
const name = String(el("imuName")?.value || "").trim();
const frame_id = String(el("imuFrameId")?.value || "").trim();
const topic = String(el("imuTopic")?.value || "").trim();
const source = el("imuSource")?.value || "external";
const rate_hz = clamp(Number(el("imuRateHz")?.value), 1, 1000);
const enabled = !!el("imuEnabled")?.checked;
if (!name || !frame_id || !topic) {
setImuFormHint("Nhập đủ tên, frame_id và topic.");
setStatus("Thiếu thông tin IMU");
imuFormBusy = false;
if (addBtn) addBtn.disabled = false;
return;
}
const dup = findDuplicateImuFrame(frame_id);
if (dup) {
const msg = `IMU trùng frame_id «${frame_id}» (${dup.name}).`;
setImuFormHint(msg);
setStatus(msg);
imuFormBusy = false;
if (addBtn) addBtn.disabled = false;
return;
}
setImuFormHint("");
try {
const created = await api("/api/imus", {
method: "POST",
body: JSON.stringify({ name, frame_id, topic, source, rate_hz, enabled }),
});
state.imus.push(created);
ensureDefaultImuPose(created.id, state.imus.length - 1);
reconcileImuPoses();
await persistLayoutNow();
if (el("imuName")) el("imuName").value = "";
if (el("imuFrameId")) el("imuFrameId").value = "";
renderImuList();
renderCanvas();
setStatus("Đã thêm IMU");
} catch (e) {
setStatus(`Lỗi: ${e.message}`);
} finally {
imuFormBusy = false;
if (addBtn) addBtn.disabled = false;
}
}
function initImuForm() {
const form = el("imuForm");
if (!form || form.dataset.bound === "1") return;
form.dataset.bound = "1";
form.addEventListener("submit", (evt) => {
evt.preventDefault();
void submitAddImu();
});
el("addImuBtn")?.addEventListener("click", (evt) => {
evt.preventDefault();
void submitAddImu();
});
["imuName", "imuFrameId", "imuTopic"].forEach((id) => {
el(id)?.addEventListener("input", () => setImuFormHint(""));
});
}
function setImuListPanelCollapsed(collapsed) {
state.imuListPanelCollapsed = collapsed;
if (imuListCard) imuListCard.classList.toggle("collapsed", collapsed);
if (imuListCardToggle) imuListCardToggle.setAttribute("aria-expanded", String(!collapsed));
try {
localStorage.setItem("imuListPanelCollapsed", collapsed ? "1" : "0");
} catch {
/* ignore */
}
}
function initImuListPanelCollapse() {
if (!imuListCardToggle) return;
try {
const saved = localStorage.getItem("imuListPanelCollapsed");
if (saved === "1") setImuListPanelCollapsed(true);
} catch {
/* ignore */
}
const toggle = () => setImuListPanelCollapsed(!state.imuListPanelCollapsed);
imuListCardToggle.addEventListener("click", toggle);
imuListCardToggle.addEventListener("keydown", (evt) => {
if (evt.key === "Enter" || evt.key === " ") {
evt.preventDefault();
toggle();
}
});
}
function setEditFootprintMode(on) {
state.editFootprint = on;
editFootprintBtn.classList.toggle("active", on);
@@ -1723,6 +1971,17 @@ function setEditFootprintMode(on) {
}
function setSelectedRelText() {
if (state.selectedImuId) {
const pose = getImuPoseAbs(state.selectedImuId);
if (!pose) {
selectedRelText.textContent = "—";
return;
}
const yaw = Number(pose.yaw_deg || 0);
const z = Number(pose.z || 0);
selectedRelText.textContent = `(x=${Number(pose.x || 0).toFixed(0)}, y=${Number(pose.y || 0).toFixed(0)}, z=${z.toFixed(2)}, ψ=${yaw.toFixed(0)}°)`;
return;
}
if (!state.selectedId) {
selectedRelText.textContent = "—";
return;
@@ -1801,6 +2060,121 @@ function renderList() {
.join("");
}
function refreshImuSelectionUI() {
if (!imuListEl) return;
imuListEl.querySelectorAll(".item[data-imu-id]").forEach((item) => {
const id = item.dataset.imuId;
const im = state.imus.find((x) => x.id === id);
if (!im) return;
const nameEl = item.querySelector(".itemName");
if (!nameEl) return;
const selected = state.selectedImuId === id ? `<span class="pill">selected</span>` : "";
nameEl.innerHTML = `${escapeHtml(im.name)} ${selected}`;
});
}
function updateImuItemPoseUI(id) {
const item = imuListEl?.querySelector(`.item[data-imu-id="${id}"]`);
if (!item) return;
const im = state.imus.find((x) => x.id === id);
const pose = state.layout.imuPoses?.[id];
const meta = item.querySelector(".itemMeta");
if (!pose) {
if (meta && im) meta.textContent = `${im.frame_id} • chưa đặt pose`;
return;
}
const x = Number(pose.x || 0);
const y = Number(pose.y || 0);
const z = Number(pose.z || 0);
const yaw = Number(pose.yaw_deg || 0);
const posTxt = `x=${x.toFixed(0)}, y=${y.toFixed(0)}, z=${z.toFixed(2)}, ψ=${yaw.toFixed(0)}°`;
if (meta && im) meta.textContent = `${im.frame_id}${posTxt}`;
const active = document.activeElement;
const xIn = item.querySelector('input.poseInput[data-action="x"]');
const yIn = item.querySelector('input.poseInput[data-action="y"]');
const zIn = item.querySelector('input.poseInput[data-action="z"]');
const yawIn = item.querySelector('input.poseInput[data-action="yaw"]');
if (xIn && active !== xIn) xIn.value = x.toFixed(0);
if (yIn && active !== yIn) yIn.value = y.toFixed(0);
if (zIn && active !== zIn) zIn.value = z.toFixed(2);
if (yawIn && active !== yawIn) yawIn.value = String(Math.round(yaw));
}
function renderImuList() {
if (!imuListEl) return;
if (!state.imus.length) {
imuListEl.innerHTML = `<div class="item"><div class="itemName">Chưa có IMU</div><div class="itemMeta">Thêm IMU ở form phía trên.</div></div>`;
return;
}
imuListEl.innerHTML = state.imus
.map((im, idx) => {
ensureDefaultImuPose(im.id, idx);
const pose = getImuPoseAbs(im.id);
let posTxt = "chưa đặt pose";
let xRobot = 0;
let yRobot = 0;
let zM = 0.1;
let yawDeg = 0;
if (pose) {
xRobot = Number(pose.x || 0);
yRobot = Number(pose.y || 0);
zM = Number(pose.z ?? 0.1);
yawDeg = Number(pose.yaw_deg || 0);
posTxt = `x=${xRobot.toFixed(0)}, y=${yRobot.toFixed(0)}, z=${zM.toFixed(2)}, ψ=${yawDeg.toFixed(0)}°`;
}
const selected = state.selectedImuId === im.id ? `<span class="pill">selected</span>` : "";
const itemCollapsed = !!state.imuItemCollapsed[im.id];
const srcLabel =
im.source === "lidar_builtin" ? "LiDAR" : im.source === "onboard" ? "Onboard" : "ROS";
const enabledTxt = im.enabled === false ? " • tắt" : "";
return `
<div class="item imuItem ${itemCollapsed ? "collapsed" : ""}" data-imu-id="${im.id}">
<div class="itemTop">
<div class="itemTopRow">
<button
type="button"
class="itemToggle"
data-action="toggle-item"
data-id="${im.id}"
aria-expanded="${!itemCollapsed}"
aria-label="Đóng/mở chi tiết ${escapeHtml(im.name)}"
></button>
<div class="itemMain">
<div class="itemName">${escapeHtml(im.name)} ${selected}</div>
<div class="itemMeta">${escapeHtml(im.frame_id)}${escapeHtml(im.topic)}${srcLabel}${enabledTxt}${posTxt}</div>
</div>
</div>
<div class="itemBtns">
<button class="btn subtle" data-action="select" data-id="${im.id}">Chọn</button>
<button class="btn subtle danger" data-action="delete" data-id="${im.id}">Xóa</button>
</div>
</div>
<div class="itemBody">
<div class="poseRow">
<div class="poseField">
<span class="poseLabel">X</span>
<input class="poseInput" data-action="x" data-id="${im.id}" type="number" step="1" value="${xRobot.toFixed(0)}" />
</div>
<div class="poseField">
<span class="poseLabel">Y</span>
<input class="poseInput" data-action="y" data-id="${im.id}" type="number" step="1" value="${yRobot.toFixed(0)}" />
</div>
<div class="poseField">
<span class="poseLabel">Z</span>
<input class="poseInput" data-action="z" data-id="${im.id}" type="number" step="0.01" value="${zM.toFixed(2)}" />
</div>
<div class="poseField">
<span class="poseLabel">ψ</span>
<input class="poseInput" data-action="yaw" data-id="${im.id}" type="number" min="-180" max="180" step="1" value="${yawDeg}" />
</div>
</div>
</div>
</div>`;
})
.join("");
}
let lastCanvasW = 0;
let lastCanvasH = 0;
@@ -2251,19 +2625,72 @@ function renderCanvas() {
ctx.fillText(`(${Number(p.x || 0).toFixed(0)}, ${Number(p.y || 0).toFixed(0)}, θ=${Number(p.theta_deg || 0).toFixed(0)}°)`, absX + iconR + 8, absY + 18);
});
// IMU sensors (diamond icon, violet)
const imuR = 12;
state.imus.forEach((im, idx) => {
ensureDefaultImuPose(im.id, idx);
const p = getImuPoseAbs(im.id);
if (!p) return;
const isSelected = state.selectedImuId === im.id;
const absX = p.absX;
const absY = p.absY;
const yawImu = (-(Number(p.yaw_deg || 0) * Math.PI)) / 180;
const yawCanvasImu = yaw + yawImu;
ctx.save();
ctx.translate(absX, absY);
ctx.rotate(yawCanvasImu);
ctx.fillStyle = isSelected ? "rgba(192, 132, 252, 0.75)" : "rgba(168, 85, 247, 0.45)";
ctx.strokeStyle = isSelected ? "rgba(233, 213, 255, 0.95)" : "rgba(216, 180, 254, 0.85)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, -imuR);
ctx.lineTo(imuR, 0);
ctx.lineTo(0, imuR);
ctx.lineTo(-imuR, 0);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
ctx.fillStyle = "rgba(232,238,252,0.92)";
ctx.font = "12px ui-sans-serif, system-ui";
ctx.fillText(im.name, absX + imuR + 8, absY + 4);
ctx.fillStyle = isSelected ? "rgba(216, 180, 254, 0.95)" : "rgba(232,238,252,0.70)";
ctx.font = "11px ui-sans-serif, system-ui";
ctx.fillText(
`(${Number(p.x || 0).toFixed(0)}, ${Number(p.y || 0).toFixed(0)}, z=${Number(p.z || 0).toFixed(2)})`,
absX + imuR + 8,
absY + 18,
);
if (im.enabled === false) {
ctx.fillStyle = "rgba(248, 113, 113, 0.9)";
ctx.font = "10px ui-sans-serif, system-ui";
ctx.fillText("OFF", absX - 14, absY - imuR - 4);
}
});
ctx.restore();
}
function hitTest(x, y) {
function hitTestCanvasTarget(x, y) {
const iconR = 16 / Math.max(state.view.scale, 0.12);
// search top-most: reverse order
const r2 = iconR * iconR;
for (let i = state.imus.length - 1; i >= 0; i--) {
const im = state.imus[i];
const p = getImuPoseAbs(im.id);
if (!p) continue;
const dx = x - p.absX;
const dy = y - p.absY;
if (dx * dx + dy * dy <= r2) return { kind: "imu", id: im.id };
}
for (let i = state.lidars.length - 1; i >= 0; i--) {
const l = state.lidars[i];
const p = getLidarPoseAbs(l.id);
if (!p) continue;
const dx = x - p.absX;
const dy = y - p.absY;
if (dx * dx + dy * dy <= iconR * iconR) return l.id;
if (dx * dx + dy * dy <= r2) return { kind: "lidar", id: l.id };
}
return null;
}
@@ -2328,15 +2755,19 @@ canvas.addEventListener("mousedown", (evt) => {
return;
}
const id = hitTest(p.x, p.y);
if (!id) return;
state.selectedId = id;
selectedText.textContent = id;
setSelectedRelText();
refreshLidarSelectionUI();
const pose = getLidarPoseAbs(id);
state.dragging = { id, dx: p.x - pose.absX, dy: p.y - pose.absY };
updateLidarItemPoseUI(id);
const target = hitTestCanvasTarget(p.x, p.y);
if (!target) return;
if (target.kind === "imu") {
selectImuOnCanvas(target.id);
const pose = getImuPoseAbs(target.id);
state.dragging = { kind: "imu", id: target.id, dx: p.x - pose.absX, dy: p.y - pose.absY };
updateImuItemPoseUI(target.id);
} else {
selectLidarOnCanvas(target.id);
const pose = getLidarPoseAbs(target.id);
state.dragging = { kind: "lidar", id: target.id, dx: p.x - pose.absX, dy: p.y - pose.absY };
updateLidarItemPoseUI(target.id);
}
renderCanvas();
});
@@ -2375,17 +2806,26 @@ window.addEventListener("mousemove", (evt) => {
const id = state.dragging.id;
const nx = p.x - state.dragging.dx;
const ny = p.y - state.dragging.dy;
ensureDefaultPose(id, 0);
const rel = absToRobot(nx, ny);
state.layout.lidarPoses[id].x = rel.x;
state.layout.lidarPoses[id].y = rel.y;
if (state.selectedId === id) setSelectedRelText();
updateLidarItemPoseUI(id);
if (state.dragging.kind === "imu") {
ensureDefaultImuPose(id, 0);
state.layout.imuPoses[id].x = rel.x;
state.layout.imuPoses[id].y = rel.y;
if (state.selectedImuId === id) setSelectedRelText();
updateImuItemPoseUI(id);
} else {
ensureDefaultPose(id, 0);
state.layout.lidarPoses[id].x = rel.x;
state.layout.lidarPoses[id].y = rel.y;
if (state.selectedId === id) setSelectedRelText();
updateLidarItemPoseUI(id);
}
renderCanvas();
});
window.addEventListener("mouseup", (evt) => {
const draggedLidarId = state.dragging?.id ?? null;
const draggedLidarId = state.dragging?.kind === "lidar" ? state.dragging.id : null;
const draggedImuId = state.dragging?.kind === "imu" ? state.dragging.id : null;
if (state.panning) {
if (
@@ -2415,6 +2855,9 @@ window.addEventListener("mouseup", (evt) => {
if (draggedLidarId) {
updateLidarItemPoseUI(draggedLidarId);
persistLayoutDebounced();
} else if (draggedImuId) {
updateImuItemPoseUI(draggedImuId);
persistLayoutDebounced();
} else if (hadFootprintDrag) {
persistLayoutDebounced();
}
@@ -2467,6 +2910,7 @@ async function loadAll() {
state.activeLayoutName = st.active_layout_name || "";
state.layoutCatalog = st.layouts || [];
state.lidars = st.lidars || [];
state.imus = st.imus || [];
state.layout = st.layout || state.layout;
clearLayoutDirty();
renderLayoutSelect();
@@ -2501,6 +2945,10 @@ async function loadAll() {
reconcileLidarPoses();
if (!state.layout.imuPoses) state.layout.imuPoses = {};
if (!state.layout.imuPosesFrame) state.layout.imuPosesFrame = "robot";
reconcileImuPoses();
let addedDefaultPoses = false;
state.lidars.forEach((l, idx) => {
if (!state.layout.lidarPoses[l.id]) {
@@ -2508,17 +2956,30 @@ async function loadAll() {
addedDefaultPoses = true;
}
});
if (addedDefaultPoses) await persistLayoutNow();
let addedDefaultImuPoses = false;
state.imus.forEach((im, idx) => {
if (!state.layout.imuPoses[im.id]) {
ensureDefaultImuPose(im.id, idx);
addedDefaultImuPoses = true;
}
});
if (addedDefaultPoses || addedDefaultImuPoses) await persistLayoutNow();
syncDiffFormFromState();
syncFootprintUIFromState();
if (state.selectedId && !state.lidars.find((l) => l.id === state.selectedId)) {
state.selectedId = null;
}
if (state.selectedImuId && !state.imus.find((im) => im.id === state.selectedImuId)) {
state.selectedImuId = null;
}
if (!state.selectedId && !state.selectedImuId) {
selectedText.textContent = "none";
}
setSelectedRelText();
renderList();
renderImuList();
if (!state.viewInitialized) {
fitViewToWorld();
state.viewInitialized = true;
@@ -2747,7 +3208,10 @@ initMotorWheelsEvents();
initBicycleMotorWheelsEvents();
initFootprintEvents();
initLidarListEvents();
initImuListEvents();
initImuForm();
initLidarListPanelCollapse();
initImuListPanelCollapse();
initRobotModelPanelCollapse();
if (typeof ResizeObserver !== "undefined") {

View File

@@ -121,6 +121,64 @@
</div>
</section>
<section class="card collapsible" id="imuListCard">
<div
class="cardHeader cardHeaderToggle"
id="imuListCardToggle"
role="button"
tabindex="0"
aria-expanded="true"
aria-controls="imuListCardBody"
>
<div>
<div class="cardTitle">IMU</div>
<div class="cardSub">Cảm biến quán tính — frame, topic và pose trên robot.</div>
</div>
<span class="cardChevron" aria-hidden="true"></span>
</div>
<div class="cardBody" id="imuListCardBody">
<form id="imuForm" class="form">
<div class="row">
<label>Tên</label>
<input id="imuName" placeholder="IMU chính" required />
</div>
<div class="row">
<label>Frame ID</label>
<input id="imuFrameId" placeholder="imu_link" required />
</div>
<div class="row">
<label>Topic</label>
<input id="imuTopic" placeholder="imu/data" value="imu/data" required />
</div>
<div class="row rowWide">
<label>Nguồn</label>
<select id="imuSource">
<option value="external">Ngoài (ROS topic)</option>
<option value="lidar_builtin">Tích hợp LiDAR</option>
<option value="onboard">Onboard robot</option>
</select>
</div>
<div class="row">
<label>Tần số (Hz)</label>
<input id="imuRateHz" type="number" min="1" max="1000" step="1" value="100" />
</div>
<div class="checkRow">
<label>
<input id="imuEnabled" type="checkbox" checked />
Bật IMU
</label>
</div>
<div class="actions">
<button id="addImuBtn" class="btn primary" type="button">Thêm IMU</button>
</div>
<p id="imuFormHint" class="formHint" hidden></p>
</form>
<div id="imuList" class="list"></div>
</div>
</section>
<section class="card collapsible" id="robotModelCard">
<div
class="cardHeader cardHeaderToggle"