diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..f29f11c --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,43 @@ +cmake_minimum_required(VERSION 3.16) +project(lidar_manager_web LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +include(FetchContent) +find_package(Threads REQUIRED) + +FetchContent_Declare( + cpp_httplib + GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git + GIT_TAG v0.44.0 +) +FetchContent_GetProperties(cpp_httplib) +if(NOT cpp_httplib_POPULATED) + FetchContent_Populate(cpp_httplib) +endif() + +FetchContent_Declare( + nlohmann_json + URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz +) +FetchContent_GetProperties(nlohmann_json) +if(NOT nlohmann_json_POPULATED) + FetchContent_Populate(nlohmann_json) +endif() + +add_executable(lidar_manager_web + src/main.cpp +) + +target_link_libraries(lidar_manager_web PRIVATE Threads::Threads) + +target_include_directories(lidar_manager_web SYSTEM PRIVATE + "${cpp_httplib_SOURCE_DIR}" + "${nlohmann_json_SOURCE_DIR}/single_include" +) + +target_compile_definitions(lidar_manager_web PRIVATE + _DEFAULT_SOURCE +) diff --git a/README.md b/README.md index 5d5681f..e844db2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,31 @@ -# App +# LiDAR Manager Web (Test3) + +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 + +## Build + +```bash +cd /home/robotics/RD/Test3 +cmake -S . -B build +cmake --build build -j +``` + +## Run + +Chạy mặc định port 8080, phục vụ static từ `www/`, dữ liệu ở `data/state.json`: + +```bash +./build/lidar_manager_web +``` + +Hoặc chỉ định: + +```bash +./build/lidar_manager_web 8080 ./www ./data/state.json +``` + +Mở trình duyệt: `http://localhost:8080/` diff --git a/data/models/a07ab938d9029ef1.json b/data/models/a07ab938d9029ef1.json new file mode 100644 index 0000000..de1edf0 --- /dev/null +++ b/data/models/a07ab938d9029ef1.json @@ -0,0 +1,204 @@ +{ + "created_at": "2026-05-29T08:27:25Z", + "id": "a07ab938d9029ef1", + "layout": { + "lidarPoses": { + "02c4b7f4de7bd639": { + "theta_deg": 45, + "x": 215, + "y": 40 + }, + "1e591c93c581f705": { + "theta_deg": -45, + "x": 215.39984362180326, + "y": -40 + }, + "242be6d6e782ecdf": { + "theta_deg": 180, + "x": 145, + "y": -0.3738614899159438 + } + }, + "lidarPosesFrame": "robot", + "lidarPositions": {}, + "map": { + "height": 600, + "width": 800 + }, + "robot": { + "bicycle": { + "display": { + "L_px": 240.0, + "r_px": 60.0, + "scale_m_per_px": 0.005 + }, + "drive": { + "joint_name": "rear_wheel_joint" + }, + "limits": { + "cmd_vel_timeout_s": 0.25, + "linear": { + "max_acceleration": 0.8, + "max_velocity": 1 + } + }, + "steer": { + "joint_name": "front_steer_joint", + "max_angle_deg": 60, + "preview_deg": 15 + }, + "wheel_radius_m": 0.15, + "wheelbase_m": 1.2, + "wheels": [ + { + "id": "rear", + "joint_name": "rear_wheel_joint", + "motor": { + "gear_ratio": 20, + "invert": false, + "model": "m2dc10a", + "vendor": "moons" + }, + "role": "drive", + "x_m": 0, + "y_m": 0 + }, + { + "id": "front", + "joint_name": "front_steer_joint", + "motor": { + "gear_ratio": 20, + "invert": false, + "model": "m2dc10a", + "vendor": "moons" + }, + "role": "steer", + "x_m": 1.2, + "y_m": 0 + } + ] + }, + "diff": { + "b": 200.0, + "d": 120.0, + "display": { + "b_px": 200.0, + "d_px": 120.0, + "scale_m_per_px": 0.005 + }, + "limits": { + "angular": { + "max_acceleration": 1.5, + "max_velocity": 1.7 + }, + "cmd_vel_timeout_s": 0.25, + "linear": { + "max_acceleration": 0.8, + "max_velocity": 1, + "min_acceleration": -0.4, + "min_velocity": -0.5 + } + }, + "wheel_radius_m": 0.3, + "wheel_radius_multiplier": 1, + "wheel_separation_m": 1, + "wheel_separation_multiplier": 1, + "wheels": [ + { + "id": "left", + "joint_name": "wheel_left_joint", + "motor": { + "gear_ratio": 20, + "invert": false, + "model": "m2dc10a", + "vendor": "moons" + }, + "side": "left", + "y_m": 0.5 + }, + { + "id": "right", + "joint_name": "wheel_right_joint", + "motor": { + "gear_ratio": 20, + "invert": false, + "model": "m2dc10a", + "vendor": "moons" + }, + "side": "right", + "y_m": -0.5 + } + ] + }, + "footprint": [ + { + "x": 249.49596246923238, + "y": 76.53128468019501 + }, + { + "x": 252.05138984920825, + "y": -73.40426803273583 + }, + { + "x": 146.0988213814129, + "y": -73.14624094113161 + }, + { + "x": 146.4579317148541, + "y": -36.76005121552378 + }, + { + "x": -24.190052366845578, + "y": -36.232153738354725 + }, + { + "x": -23.18092513013994, + "y": 31.895774646867324 + }, + { + "x": 149.1507088069675, + "y": 31.363038836025066 + }, + { + "x": 148.2973527630072, + "y": 77.68471811183447 + } + ], + "footprint_params": { + "length_m": 1.69, + "radius_m": 0.8432486399759678, + "segments": 32, + "sides": 6, + "width_m": 1.28 + }, + "footprint_shape": "custom", + "frame_id": "base_footprint", + "model": "bicycle", + "x": 400, + "y": 300, + "yaw_deg": 0 + } + }, + "lidars": [ + { + "id": "02c4b7f4de7bd639", + "ip": "192.168.1.11", + "name": "Front", + "port": 2112 + }, + { + "id": "1e591c93c581f705", + "ip": "192.168.1.12", + "name": "Back", + "port": 2112 + }, + { + "id": "242be6d6e782ecdf", + "ip": "192.168.1.15", + "name": "Oile", + "port": 2112 + } + ], + "name": "Mặc định", + "updated_at": "2026-05-29T08:39:03Z" +} diff --git a/data/models/ea89e39c835c0557.json b/data/models/ea89e39c835c0557.json new file mode 100644 index 0000000..8e0fe63 --- /dev/null +++ b/data/models/ea89e39c835c0557.json @@ -0,0 +1,177 @@ +{ + "created_at": "2026-05-29T08:40:51Z", + "id": "ea89e39c835c0557", + "layout": { + "lidarPoses": { + "40235913b52d8101": { + "theta_deg": -135, + "x": -120, + "y": -90 + }, + "f4504deeb605e6ed": { + "theta_deg": 45, + "x": 120, + "y": 90 + } + }, + "lidarPosesFrame": "robot", + "lidarPositions": {}, + "map": { + "height": 600, + "width": 800 + }, + "robot": { + "bicycle": { + "display": { + "L_px": 240.0, + "r_px": 60.0, + "scale_m_per_px": 0.005 + }, + "drive": { + "joint_name": "rear_wheel_joint" + }, + "limits": { + "cmd_vel_timeout_s": 0.25, + "linear": { + "max_acceleration": 0.8, + "max_velocity": 1 + } + }, + "steer": { + "joint_name": "front_steer_joint", + "max_angle_deg": 60, + "preview_deg": 15 + }, + "wheel_radius_m": 0.15, + "wheelbase_m": 1.2, + "wheels": [ + { + "id": "rear", + "joint_name": "rear_wheel_joint", + "motor": { + "gear_ratio": 20, + "invert": false, + "model": "m2dc10a", + "vendor": "moons" + }, + "role": "drive", + "x_m": 0, + "y_m": 0 + }, + { + "id": "front", + "joint_name": "front_steer_joint", + "motor": { + "gear_ratio": 20, + "invert": false, + "model": "m2dc10a", + "vendor": "moons" + }, + "role": "steer", + "x_m": 1.2, + "y_m": 0 + } + ] + }, + "diff": { + "b": 220.0, + "d": 120.0, + "display": { + "b_px": 220.0, + "d_px": 120.0, + "scale_m_per_px": 0.005 + }, + "limits": { + "angular": { + "max_acceleration": 1.5, + "max_velocity": 1.7 + }, + "cmd_vel_timeout_s": 0.25, + "linear": { + "max_acceleration": 0.8, + "max_velocity": 1, + "min_acceleration": -0.8, + "min_velocity": -0.5 + } + }, + "wheel_radius_m": 0.3, + "wheel_radius_multiplier": 1, + "wheel_separation_m": 1.1, + "wheel_separation_multiplier": 1, + "wheels": [ + { + "id": "left", + "joint_name": "wheel_left_joint", + "motor": { + "gear_ratio": 10, + "invert": true, + "model": "m2dc10a", + "vendor": "moons" + }, + "side": "left", + "y_m": 0.55 + }, + { + "id": "right", + "joint_name": "wheel_right_joint", + "motor": { + "gear_ratio": 10, + "invert": false, + "model": "m2dc10a", + "vendor": "moons" + }, + "side": "right", + "y_m": -0.55 + } + ] + }, + "footprint": [ + { + "x": 150, + "y": 120 + }, + { + "x": 150, + "y": -120 + }, + { + "x": -150, + "y": -120 + }, + { + "x": -150, + "y": 120 + } + ], + "footprint_params": { + "length_m": 1.5, + "radius_m": 1, + "segments": 32, + "sides": 6, + "width_m": 1.2 + }, + "footprint_shape": "rectangle", + "frame_id": "base_footprint", + "model": "diff", + "x": 400, + "y": 300, + "yaw_deg": 0 + } + }, + "lidars": [ + { + "id": "f4504deeb605e6ed", + "ip": "192.168.1.11", + "name": "Front", + "port": 2112 + }, + { + "id": "40235913b52d8101", + "ip": "192.168.1.11", + "name": "Back", + "port": 2112 + } + ], + "name": "T800", + "updated_at": "2026-05-29T08:44:03Z" +} diff --git a/data/state.json b/data/state.json new file mode 100644 index 0000000..df4d6b0 --- /dev/null +++ b/data/state.json @@ -0,0 +1,22 @@ +{ + "active_layout_id": "a07ab938d9029ef1", + "layouts": [ + { + "created_at": "2026-05-29T08:27:25Z", + "id": "a07ab938d9029ef1", + "lidar_count": 3, + "model": "bicycle", + "name": "Mặc định", + "updated_at": "2026-05-29T08:39:03Z" + }, + { + "created_at": "2026-05-29T08:40:51Z", + "id": "ea89e39c835c0557", + "lidar_count": 2, + "model": "diff", + "name": "T800", + "updated_at": "2026-05-29T08:44:03Z" + } + ], + "version": 3 +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..e37b2a8 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,1252 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using json = nlohmann::json; + +static std::string read_file_binary(const fs::path& path) +{ + std::ifstream in(path, std::ios::binary); + if (!in) + return {}; + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +static bool write_file_binary_atomic(const fs::path& path, const std::string& contents) +{ + fs::create_directories(path.parent_path()); + auto tmp = path; + tmp += ".tmp"; + + { + std::ofstream out(tmp, std::ios::binary | std::ios::trunc); + if (!out) + return false; + out.write(contents.data(), static_cast(contents.size())); + out.flush(); + if (!out) + return false; + } + + std::error_code ec; + fs::rename(tmp, path, ec); + if (!ec) + return true; + + // Fallback: some filesystems disallow rename-overwrite + fs::remove(path, ec); + ec.clear(); + fs::rename(tmp, path, ec); + return !ec; +} + +static std::string to_lower(std::string s) +{ + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return s; +} + +static std::string content_type_for_path(const fs::path& p) +{ + const auto ext = to_lower(p.extension().string()); + if (ext == ".html") + return "text/html; charset=utf-8"; + if (ext == ".css") + return "text/css; charset=utf-8"; + if (ext == ".js") + return "application/javascript; charset=utf-8"; + if (ext == ".json") + return "application/json; charset=utf-8"; + if (ext == ".png") + return "image/png"; + if (ext == ".svg") + return "image/svg+xml"; + if (ext == ".ico") + return "image/x-icon"; + return "application/octet-stream"; +} + +static std::string new_id() +{ + static thread_local std::mt19937_64 rng{std::random_device{}()}; + static constexpr char kHex[] = "0123456789abcdef"; + std::string out; + out.resize(16); + for (int i = 0; i < 16; i++) + out[i] = kHex[static_cast(rng() & 0xF)]; + return out; +} + +static std::string now_iso8601() +{ + using clock = std::chrono::system_clock; + const std::time_t t = clock::to_time_t(clock::now()); + std::tm tm{}; +#if defined(_WIN32) + gmtime_s(&tm, &t); +#else + gmtime_r(&t, &tm); +#endif + char buf[32]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm); + return buf; +} + +struct AppState +{ + fs::path data_path; + json state; +}; + +static json default_bicycle_wheels() +{ + return json::array( + {json{{"id", "rear"}, + {"role", "drive"}, + {"x_m", 0}, + {"y_m", 0}, + {"joint_name", "rear_wheel_joint"}, + {"motor", + {{"vendor", "moons"}, + {"model", "m2dc10a"}, + {"gear_ratio", 20}, + {"invert", false}}}}, + json{{"id", "front"}, + {"role", "steer"}, + {"x_m", 1.2}, + {"y_m", 0}, + {"joint_name", "front_steer_joint"}, + {"motor", + {{"vendor", "moons"}, + {"model", "m2dc10a"}, + {"gear_ratio", 20}, + {"invert", false}}}}}); +} + +static json default_diff_wheels() +{ + return json::array( + {json{{"id", "left"}, + {"side", "left"}, + {"joint_name", "wheel_left_joint"}, + {"y_m", 0.5}, + {"motor", + {{"vendor", "moons"}, + {"model", "m2dc10a"}, + {"gear_ratio", 20}, + {"invert", false}}}}, + json{{"id", "right"}, + {"side", "right"}, + {"joint_name", "wheel_right_joint"}, + {"y_m", -0.5}, + {"motor", + {{"vendor", "moons"}, + {"model", "m2dc10a"}, + {"gear_ratio", 20}, + {"invert", false}}}}}); +} + +static void ensure_layout_schema(json& layout); + +static json default_layout_object() +{ + return json{ + {"robot", + {{"x", 400}, + {"y", 300}, + {"yaw_deg", 0}, + {"frame_id", "base_footprint"}, + {"model", "diff"}, + {"diff", + {{"wheel_separation_m", 1.0}, + {"wheel_radius_m", 0.3}, + {"wheel_separation_multiplier", 1.0}, + {"wheel_radius_multiplier", 1.0}, + {"display", {{"scale_m_per_px", 0.005}, {"b_px", 200}, {"d_px", 120}}}, + {"limits", + {{"cmd_vel_timeout_s", 0.25}, + {"linear", + {{"max_velocity", 1.0}, + {"min_velocity", -0.5}, + {"max_acceleration", 0.8}, + {"min_acceleration", -0.4}}}, + {"angular", + {{"max_velocity", 1.7}, {"max_acceleration", 1.5}}}}, + {"wheels", default_diff_wheels()}}}}, + {"footprint", + json::array({json{{"x", 120}, {"y", 80}}, + json{{"x", 120}, {"y", -80}}, + json{{"x", -90}, {"y", -80}}, + json{{"x", -90}, {"y", 80}}})}}}, + {"map", {{"width", 800}, {"height", 600}}}, + {"lidarPositions", json::object()}, + {"lidarPoses", json::object()}, + {"lidarPosesFrame", "robot"}}; +} + +static json make_layout_profile(const std::string& name, const json& layout, const json& lidars) +{ + const std::string ts = now_iso8601(); + return json{{"id", new_id()}, + {"name", name}, + {"created_at", ts}, + {"updated_at", ts}, + {"layout", layout}, + {"lidars", lidars}}; +} + +static std::string trim_copy(const std::string& s) +{ + size_t a = 0; + while (a < s.size() && std::isspace(static_cast(s[a]))) + a++; + size_t b = s.size(); + while (b > a && std::isspace(static_cast(s[b - 1]))) + b--; + return s.substr(a, b - a); +} + +static std::optional find_profile_index(const json& state, const std::string& id) +{ + if (!state.contains("layouts") || !state["layouts"].is_array()) + return std::nullopt; + const auto& layouts = state["layouts"]; + for (size_t i = 0; i < layouts.size(); i++) + { + const auto& p = layouts[i]; + if (p.is_object() && p.contains("id") && p["id"].is_string() && p["id"].get() == id) + return i; + } + return std::nullopt; +} + +static std::optional find_active_profile_index(json& state) +{ + if (!state.contains("active_layout_id") || !state["active_layout_id"].is_string()) + return std::nullopt; + return find_profile_index(state, state["active_layout_id"].get()); +} + +static void touch_profile(json& profile) +{ + profile["updated_at"] = now_iso8601(); +} + +static fs::path models_dir(const AppState& app) +{ + return app.data_path.parent_path() / "models"; +} + +static fs::path profile_file_path(const AppState& app, const std::string& id) +{ + return models_dir(app) / (id + ".json"); +} + +static std::string profile_model_from_layout(const json& layout) +{ + if (layout.is_object() && layout.contains("robot") && layout["robot"].is_object() && + layout["robot"].contains("model") && layout["robot"]["model"].is_string()) + return layout["robot"]["model"].get(); + return "diff"; +} + +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; + 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}}; +} + +static std::optional load_profile_from_disk(const AppState& app, const std::string& id) +{ + const auto raw = read_file_binary(profile_file_path(app, id)); + if (raw.empty()) + return std::nullopt; + try + { + return json::parse(raw); + } + catch (...) + { + return std::nullopt; + } +} + +static bool save_profile_to_disk(const AppState& app, const json& profile) +{ + if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string()) + return false; + std::error_code ec; + fs::create_directories(models_dir(app), ec); + auto body = profile.dump(2); + body.push_back('\n'); + return write_file_binary_atomic(profile_file_path(app, profile["id"].get()), body); +} + +static bool delete_profile_file(const AppState& app, const std::string& id) +{ + std::error_code ec; + fs::remove(profile_file_path(app, id), ec); + return true; +} + +static void load_active_cache(AppState& app) +{ + json& state = app.state; + const auto idx = find_active_profile_index(state); + if (!idx) + return; + + const std::string id = state["layouts"][*idx]["id"].get(); + json profile; + if (auto loaded = load_profile_from_disk(app, id)) + profile = *loaded; + else + { + profile = make_layout_profile(state["layouts"][*idx]["name"].get(), + default_layout_object(), + json::array()); + profile["id"] = id; + if (state["layouts"][*idx].contains("created_at")) + profile["created_at"] = state["layouts"][*idx]["created_at"]; + } + + if (!profile.contains("layout") || !profile["layout"].is_object()) + profile["layout"] = default_layout_object(); + if (!profile.contains("lidars") || !profile["lidars"].is_array()) + profile["lidars"] = json::array(); + + ensure_layout_schema(profile["layout"]); + state["layout"] = profile["layout"]; + state["lidars"] = profile["lidars"]; +} + +static bool persist_active_profile(AppState& app) +{ + json& state = app.state; + const auto idx = find_active_profile_index(state); + if (!idx) + return false; + + auto& entry = state["layouts"][*idx]; + json profile; + profile["id"] = entry["id"]; + profile["name"] = entry.contains("name") ? entry["name"] : json("Layout"); + profile["created_at"] = entry.value("created_at", now_iso8601()); + profile["updated_at"] = now_iso8601(); + profile["layout"] = state["layout"]; + profile["lidars"] = state["lidars"]; + ensure_layout_schema(profile["layout"]); + if (!save_profile_to_disk(app, profile)) + return false; + entry = catalog_entry_from_profile(profile); + return true; +} + +static json global_state_for_disk(const json& state) +{ + json out = json::object(); + out["version"] = 3; + if (state.contains("active_layout_id")) + out["active_layout_id"] = state["active_layout_id"]; + out["layouts"] = json::array(); + if (state.contains("layouts") && state["layouts"].is_array()) + { + for (const auto& entry : state["layouts"]) + { + if (!entry.is_object() || !entry.contains("id") || !entry.contains("name")) + continue; + if (entry.contains("layout")) + out["layouts"].push_back(catalog_entry_from_profile(entry)); + else + out["layouts"].push_back(entry); + } + } + return out; +} + +static void strip_inline_profiles(json& state) +{ + if (!state.contains("layouts") || !state["layouts"].is_array()) + return; + json catalog = json::array(); + for (const auto& entry : state["layouts"]) + { + if (!entry.is_object() || !entry.contains("id")) + continue; + if (entry.contains("layout")) + catalog.push_back(catalog_entry_from_profile(entry)); + else + catalog.push_back(entry); + } + state["layouts"] = catalog; +} + +static void migrate_storage(AppState& app) +{ + json& s = app.state; + if (!s.is_object()) + s = json::object(); + + const int version = s.contains("version") && s["version"].is_number_integer() ? s["version"].get() : 1; + + if (!s.contains("layouts") || !s["layouts"].is_array() || s["layouts"].empty()) + { + json layout = s.contains("layout") && s["layout"].is_object() ? s["layout"] : default_layout_object(); + json lidars = s.contains("lidars") && s["lidars"].is_array() ? s["lidars"] : json::array(); + json profile = make_layout_profile("Mặc định", layout, lidars); + ensure_layout_schema(profile["layout"]); + save_profile_to_disk(app, profile); + s["layouts"] = json::array({catalog_entry_from_profile(profile)}); + s["active_layout_id"] = profile["id"].get(); + } + else if (version < 3) + { + if (!s.contains("active_layout_id") || !s["active_layout_id"].is_string() || + !find_profile_index(s, s["active_layout_id"].get())) + { + s["active_layout_id"] = s["layouts"][0]["id"].get(); + } + + json catalog = json::array(); + for (auto& entry : s["layouts"]) + { + if (!entry.is_object() || !entry.contains("id")) + continue; + json profile; + if (entry.contains("layout")) + { + profile = entry; + if (!profile.contains("name")) + profile["name"] = "Layout"; + if (!profile.contains("lidars") || !profile["lidars"].is_array()) + profile["lidars"] = json::array(); + if (!profile.contains("created_at")) + profile["created_at"] = now_iso8601(); + touch_profile(profile); + ensure_layout_schema(profile["layout"]); + save_profile_to_disk(app, profile); + catalog.push_back(catalog_entry_from_profile(profile)); + } + else + { + const std::string id = entry["id"].get(); + if (auto loaded = load_profile_from_disk(app, id)) + { + catalog.push_back(catalog_entry_from_profile(*loaded)); + } + else + { + profile = make_layout_profile(entry["name"].get(), default_layout_object(), json::array()); + profile["id"] = id; + profile["created_at"] = entry.value("created_at", now_iso8601()); + touch_profile(profile); + save_profile_to_disk(app, profile); + catalog.push_back(catalog_entry_from_profile(profile)); + } + } + } + s["layouts"] = catalog; + } + else + { + strip_inline_profiles(s); + if (!s.contains("active_layout_id") || !s["active_layout_id"].is_string() || + !find_profile_index(s, s["active_layout_id"].get())) + { + s["active_layout_id"] = s["layouts"][0]["id"].get(); + } + } + + s["version"] = 3; + s.erase("layout"); + s.erase("lidars"); + load_active_cache(app); +} + +static bool layout_name_exists(const json& state, + const std::string& name, + const std::string* exclude_id = nullptr) +{ + if (!state.contains("layouts") || !state["layouts"].is_array()) + return false; + const std::string n = trim_copy(name); + for (const auto& p : state["layouts"]) + { + if (!p.is_object() || !p.contains("name")) + continue; + if (exclude_id && p.contains("id") && p["id"].get() == *exclude_id) + continue; + if (trim_copy(p["name"].get()) == n) + return true; + } + return false; +} + +static json build_layouts_catalog(const json& state) +{ + json catalog = json::array(); + if (!state.contains("layouts") || !state["layouts"].is_array()) + return catalog; + for (const auto& p : state["layouts"]) + { + if (!p.is_object() || !p.contains("id") || !p.contains("name")) + continue; + catalog.push_back(p); + } + return catalog; +} + +static void bootstrap_default_state(AppState& app) +{ + const json layout = default_layout_object(); + json profile = make_layout_profile("Mặc định", layout, json::array()); + ensure_layout_schema(profile["layout"]); + save_profile_to_disk(app, profile); + app.state = json{{"version", 3}, + {"active_layout_id", profile["id"]}, + {"layouts", json::array({catalog_entry_from_profile(profile)})}}; + app.state["layout"] = profile["layout"]; + app.state["lidars"] = profile["lidars"]; +} + +static void ensure_layout_schema(json& layout) +{ + if (!layout.is_object()) + layout = json::object(); + if (!layout.contains("robot") || !layout["robot"].is_object()) + layout["robot"] = json::object(); + if (!layout.contains("map") || !layout["map"].is_object()) + layout["map"] = json::object(); + if (!layout.contains("lidarPositions") || !layout["lidarPositions"].is_object()) + layout["lidarPositions"] = json::object(); + if (!layout.contains("lidarPoses") || !layout["lidarPoses"].is_object()) + layout["lidarPoses"] = json::object(); + if (!layout.contains("lidarPosesFrame")) + layout["lidarPosesFrame"] = "robot"; + + auto& robot = layout["robot"]; + if (!robot.contains("x")) + robot["x"] = 400; + if (!robot.contains("y")) + robot["y"] = 300; + if (!robot.contains("yaw_deg")) + robot["yaw_deg"] = 0; + if (!robot.contains("frame_id")) + robot["frame_id"] = "base_footprint"; + if (!robot.contains("model")) + robot["model"] = "diff"; + if (!robot.contains("diff") || !robot["diff"].is_object()) + robot["diff"] = json::object(); + auto& diff = robot["diff"]; + + const double default_scale = 0.005; + if (!diff.contains("display") || !diff["display"].is_object()) + diff["display"] = json::object(); + auto& display = diff["display"]; + if (!display.contains("scale_m_per_px")) + display["scale_m_per_px"] = default_scale; + const double scale = display["scale_m_per_px"].get(); + + if (!diff.contains("wheel_separation_m")) + { + if (diff.contains("b")) + diff["wheel_separation_m"] = diff["b"].get() * scale; + else + diff["wheel_separation_m"] = 1.0; + } + if (!diff.contains("wheel_radius_m")) + { + if (diff.contains("d")) + diff["wheel_radius_m"] = diff["d"].get() * scale * 0.5; + else + diff["wheel_radius_m"] = 0.3; + } + if (!diff.contains("wheel_separation_multiplier")) + diff["wheel_separation_multiplier"] = 1.0; + if (!diff.contains("wheel_radius_multiplier")) + diff["wheel_radius_multiplier"] = 1.0; + + const double b_mult = diff["wheel_separation_multiplier"].get(); + const double r_mult = diff["wheel_radius_multiplier"].get(); + const double sep_m = diff["wheel_separation_m"].get(); + const double rad_m = diff["wheel_radius_m"].get(); + display["b_px"] = sep_m * b_mult / scale; + display["d_px"] = 2.0 * rad_m * r_mult / scale; + diff["b"] = display["b_px"]; + diff["d"] = display["d_px"]; + + if (!diff.contains("limits") || !diff["limits"].is_object()) + diff["limits"] = json::object(); + auto& limits = diff["limits"]; + if (!limits.contains("cmd_vel_timeout_s")) + limits["cmd_vel_timeout_s"] = 0.25; + if (!limits.contains("linear") || !limits["linear"].is_object()) + limits["linear"] = json::object(); + auto& linear = limits["linear"]; + if (!linear.contains("max_velocity")) + linear["max_velocity"] = 1.0; + if (!linear.contains("min_velocity")) + linear["min_velocity"] = -0.5; + if (!linear.contains("max_acceleration")) + linear["max_acceleration"] = 0.8; + if (!linear.contains("min_acceleration")) + linear["min_acceleration"] = -0.4; + if (!limits.contains("angular") || !limits["angular"].is_object()) + limits["angular"] = json::object(); + auto& angular = limits["angular"]; + if (!angular.contains("max_velocity")) + angular["max_velocity"] = 1.7; + if (!angular.contains("max_acceleration")) + angular["max_acceleration"] = 1.5; + + const double half_sep = sep_m / 2.0; + if (!diff.contains("wheels") || !diff["wheels"].is_array() || diff["wheels"].empty()) + { + diff["wheels"] = json::array( + {json{{"id", "left"}, + {"side", "left"}, + {"joint_name", "wheel_left_joint"}, + {"y_m", half_sep}, + {"motor", + json{{"vendor", "moons"}, + {"model", "m2dc10a"}, + {"gear_ratio", 20}, + {"invert", false}}}}, + json{{"id", "right"}, + {"side", "right"}, + {"joint_name", "wheel_right_joint"}, + {"y_m", -half_sep}, + {"motor", + json{{"vendor", "moons"}, + {"model", "m2dc10a"}, + {"gear_ratio", 20}, + {"invert", false}}}}}); + } + else + { + for (auto& w : diff["wheels"]) + { + if (!w.is_object()) + continue; + if (!w.contains("id")) + w["id"] = "left"; + if (!w.contains("side")) + w["side"] = (w["id"].get() == "right") ? "right" : "left"; + if (!w.contains("joint_name")) + { + w["joint_name"] = (w["side"].get() == "right") ? "wheel_right_joint" + : "wheel_left_joint"; + } + if (!w.contains("y_m")) + { + w["y_m"] = (w["side"].get() == "right") ? -half_sep : half_sep; + } + if (!w.contains("motor") || !w["motor"].is_object()) + w["motor"] = json::object(); + auto& motor = w["motor"]; + if (!motor.contains("vendor")) + motor["vendor"] = "custom"; + if (!motor.contains("model")) + motor["model"] = "custom"; + if (!motor.contains("gear_ratio")) + motor["gear_ratio"] = 20; + if (!motor.contains("invert")) + motor["invert"] = false; + } + } + + if (!robot.contains("bicycle") || !robot["bicycle"].is_object()) + robot["bicycle"] = json::object(); + auto& bicycle = robot["bicycle"]; + if (!bicycle.contains("display") || !bicycle["display"].is_object()) + bicycle["display"] = json::object(); + auto& bdisplay = bicycle["display"]; + if (!bdisplay.contains("scale_m_per_px")) + bdisplay["scale_m_per_px"] = default_scale; + const double bscale = bdisplay["scale_m_per_px"].get(); + if (!bicycle.contains("wheelbase_m")) + bicycle["wheelbase_m"] = 1.2; + if (!bicycle.contains("wheel_radius_m")) + bicycle["wheel_radius_m"] = 0.15; + const double L_m = bicycle["wheelbase_m"].get(); + const double b_rad = bicycle["wheel_radius_m"].get(); + bdisplay["L_px"] = L_m / bscale; + bdisplay["r_px"] = 2.0 * b_rad / bscale; + if (!bicycle.contains("steer") || !bicycle["steer"].is_object()) + bicycle["steer"] = json::object(); + auto& steer = bicycle["steer"]; + if (!steer.contains("max_angle_deg")) + steer["max_angle_deg"] = 35; + if (!steer.contains("preview_deg")) + steer["preview_deg"] = 15; + if (!steer.contains("joint_name")) + steer["joint_name"] = "front_steer_joint"; + if (!bicycle.contains("drive") || !bicycle["drive"].is_object()) + bicycle["drive"] = json::object(); + if (!bicycle["drive"].contains("joint_name")) + bicycle["drive"]["joint_name"] = "rear_wheel_joint"; + if (!bicycle.contains("limits") || !bicycle["limits"].is_object()) + bicycle["limits"] = json::object(); + auto& blimits = bicycle["limits"]; + if (!blimits.contains("cmd_vel_timeout_s")) + blimits["cmd_vel_timeout_s"] = 0.25; + if (!blimits.contains("linear") || !blimits["linear"].is_object()) + blimits["linear"] = json::object(); + auto& blinear = blimits["linear"]; + if (!blinear.contains("max_velocity")) + blinear["max_velocity"] = 1.0; + if (!blinear.contains("max_acceleration")) + blinear["max_acceleration"] = 0.8; + if (!bicycle.contains("wheels") || !bicycle["wheels"].is_array() || bicycle["wheels"].empty()) + bicycle["wheels"] = default_bicycle_wheels(); + + if (!robot.contains("footprint") || !robot["footprint"].is_array()) + { + robot["footprint"] = + json::array({json{{"x", 120}, {"y", 80}}, + json{{"x", 120}, {"y", -80}}, + json{{"x", -90}, {"y", -80}}, + json{{"x", -90}, {"y", 80}}}); + } + if (!robot.contains("footprint_shape")) + robot["footprint_shape"] = "custom"; + if (!robot.contains("footprint_params") || !robot["footprint_params"].is_object()) + { + robot["footprint_params"] = json{{"length_m", 1.4}, + {"width_m", 1.1}, + {"radius_m", 0.55}, + {"sides", 6}, + {"segments", 32}}; + } + auto& map = layout["map"]; + if (!map.contains("width")) + map["width"] = 800; + if (!map.contains("height")) + map["height"] = 600; +} + +static void ensure_schema(AppState& app) +{ + migrate_storage(app); +} + +static bool save_state(const AppState& app); + +static bool load_state(AppState& app) +{ + const auto raw = read_file_binary(app.data_path); + if (raw.empty()) + { + bootstrap_default_state(app); + save_state(app); + return true; + } + try + { + app.state = json::parse(raw); + ensure_schema(app); + save_state(app); + return true; + } + catch (...) + { + bootstrap_default_state(app); + save_state(app); + return false; + } +} + +static bool save_state(const AppState& app) +{ + try + { + const json disk = global_state_for_disk(app.state); + auto raw = disk.dump(2); + raw.push_back('\n'); + return write_file_binary_atomic(app.data_path, raw); + } + catch (...) + { + return false; + } +} + +static bool save_app_state(AppState& app) +{ + if (!persist_active_profile(app)) + return false; + return save_state(app); +} + +static std::optional find_lidar_index(const json& state, const std::string& id) +{ + const auto& lidars = state.at("lidars"); + for (size_t i = 0; i < lidars.size(); i++) + { + const auto& l = lidars[i]; + if (l.is_object() && l.contains("id") && l["id"].is_string() && l["id"].get() == id) + return i; + } + return std::nullopt; +} + +static void json_error(httplib::Response& res, int status, const std::string& msg) +{ + res.status = status; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = json({{"error", msg}}).dump(); +} + +static void add_cors(httplib::Response& res) +{ + // Same-origin by default. This is helpful if you later host the frontend elsewhere. + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + res.set_header("Access-Control-Allow-Headers", "Content-Type"); +} + +static bool validate_lidar_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().empty()) + { + err = "name is required"; + return false; + } + if (!payload.contains("ip") || !payload["ip"].is_string() || payload["ip"].get().empty()) + { + err = "ip is required"; + return false; + } + if (!payload.contains("port") || !payload["port"].is_number_integer()) + { + err = "port must be an integer"; + return false; + } + const int port = payload["port"].get(); + if (port < 1 || port > 65535) + { + err = "port must be in range 1..65535"; + return false; + } + return true; +} + +static bool lidar_triplet_exists(const json& state, + const std::string& name, + const std::string& ip, + int port, + const std::string* exclude_id = nullptr) +{ + if (!state.contains("lidars") || !state["lidars"].is_array()) + return false; + const std::string n = trim_copy(name); + const std::string i = trim_copy(ip); + for (const auto& l : state["lidars"]) + { + if (!l.is_object()) + continue; + if (exclude_id && l.contains("id") && l["id"].get() == *exclude_id) + continue; + if (!l.contains("name") || !l.contains("ip") || !l.contains("port")) + continue; + if (trim_copy(l["name"].get()) == n && trim_copy(l["ip"].get()) == i && + l["port"].get() == port) + 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) { + std::string rel = req.matches.size() >= 2 ? req.matches[1].str() : ""; + if (rel.empty()) + rel = "index.html"; + + // Basic traversal protection + if (rel.find("..") != std::string::npos) + { + res.status = 400; + res.set_content("Bad path", "text/plain; charset=utf-8"); + return; + } + + fs::path file_path = www_root / rel; + if (fs::is_directory(file_path)) + file_path /= "index.html"; + + std::error_code ec; + if (!fs::exists(file_path, ec) || ec) + { + res.status = 404; + res.set_content("Not Found", "text/plain; charset=utf-8"); + return; + } + + const auto body = read_file_binary(file_path); + if (body.empty()) + { + res.status = 500; + res.set_content("Failed to read file", "text/plain; charset=utf-8"); + return; + } + + res.set_header("Cache-Control", "no-store"); + res.set_content(body, content_type_for_path(file_path)); + }); +} + +int main(int argc, char** argv) +{ + const int port = (argc >= 2) ? std::atoi(argv[1]) : 8080; + const fs::path www_root = (argc >= 3) ? fs::path(argv[2]) : fs::path("www"); + const fs::path data_path = (argc >= 4) ? fs::path(argv[3]) : fs::path("data/state.json"); + + AppState app; + app.data_path = data_path; + load_state(app); + + httplib::Server svr; + + svr.Options(R"(/api/(.*))", [](const httplib::Request&, httplib::Response& res) { + add_cors(res); + res.status = 204; + }); + + svr.Get("/api/health", [](const httplib::Request&, httplib::Response& res) { + add_cors(res); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = json({{"ok", true}}).dump(); + }); + + svr.Get("/api/state", [&app](const httplib::Request&, httplib::Response& res) { + add_cors(res); + ensure_schema(app); + std::string active_name; + const auto idx = find_active_profile_index(app.state); + if (idx) + active_name = app.state["layouts"][*idx]["name"].get(); + const json response = {{"version", app.state.value("version", 3)}, + {"active_layout_id", app.state["active_layout_id"]}, + {"active_layout_name", active_name}, + {"layouts", build_layouts_catalog(app.state)}, + {"layout", app.state["layout"]}, + {"lidars", app.state["lidars"]}}; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = response.dump(); + }); + + svr.Get("/api/layouts", [&app](const httplib::Request&, httplib::Response& res) { + add_cors(res); + ensure_schema(app); + const json response = {{"active_layout_id", app.state["active_layout_id"]}, + {"layouts", build_layouts_catalog(app.state)}}; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = response.dump(); + }); + + svr.Post("/api/layouts", [&app](const httplib::Request& req, httplib::Response& res) { + add_cors(res); + ensure_schema(app); + json payload; + try + { + payload = json::parse(req.body); + } + catch (...) + { + return json_error(res, 400, "invalid JSON"); + } + if (!payload.is_object() || !payload.contains("name") || !payload["name"].is_string()) + return json_error(res, 400, "name is required"); + const std::string name = trim_copy(payload["name"].get()); + if (name.empty()) + return json_error(res, 400, "name is required"); + if (layout_name_exists(app.state, name)) + return json_error(res, 409, "layout name already exists"); + + const bool clone = payload.contains("clone") && payload["clone"].is_boolean() && payload["clone"].get(); + json layout = default_layout_object(); + json lidars = json::array(); + if (clone) + { + layout = app.state["layout"]; + lidars = app.state["lidars"]; + } + json profile = make_layout_profile(name, layout, lidars); + ensure_layout_schema(profile["layout"]); + if (!save_profile_to_disk(app, profile)) + return json_error(res, 500, "failed to save layout file"); + app.state["layouts"].push_back(catalog_entry_from_profile(profile)); + app.state["active_layout_id"] = profile["id"].get(); + load_active_cache(app); + save_state(app); + + res.status = 201; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = profile.dump(); + }); + + svr.Post(R"(/api/layouts/([0-9a-fA-F]+)/activate)", [&app](const httplib::Request& req, httplib::Response& res) { + add_cors(res); + const std::string id = req.matches[1].str(); + ensure_schema(app); + if (!find_profile_index(app.state, id)) + return json_error(res, 404, "layout not found"); + app.state["active_layout_id"] = id; + load_active_cache(app); + save_state(app); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = json({{"ok", true}, {"active_layout_id", id}}).dump(); + }); + + svr.Delete(R"(/api/layouts/([0-9a-fA-F]+))", [&app](const httplib::Request& req, httplib::Response& res) { + add_cors(res); + const std::string id = req.matches[1].str(); + ensure_schema(app); + if (!app.state.contains("layouts") || !app.state["layouts"].is_array()) + return json_error(res, 404, "layout not found"); + if (app.state["layouts"].size() <= 1) + return json_error(res, 400, "cannot delete the last layout"); + const auto idx = find_profile_index(app.state, id); + if (!idx) + return json_error(res, 404, "layout not found"); + + const bool was_active = + app.state.contains("active_layout_id") && app.state["active_layout_id"].get() == id; + delete_profile_file(app, id); + app.state["layouts"].erase(app.state["layouts"].begin() + static_cast(*idx)); + if (was_active) + app.state["active_layout_id"] = app.state["layouts"][0]["id"].get(); + load_active_cache(app); + save_state(app); + res.status = 204; + }); + + svr.Get("/api/lidars", [&app](const httplib::Request&, httplib::Response& res) { + add_cors(res); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = app.state["lidars"].dump(); + }); + + svr.Post("/api/lidars", [&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_lidar_payload(payload, err)) + return json_error(res, 400, err); + + const std::string name = trim_copy(payload["name"].get()); + const std::string ip = trim_copy(payload["ip"].get()); + const int port = payload["port"].get(); + if (lidar_triplet_exists(app.state, name, ip, port)) + return json_error(res, 409, "lidar with same name, ip and port already exists"); + + json lidar = { + {"id", new_id()}, + {"name", name}, + {"ip", ip}, + {"port", port}, + }; + app.state["lidars"].push_back(lidar); + 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 = lidar.dump(); + }); + + svr.Put(R"(/api/lidars/([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_lidar_payload(payload, err)) + return json_error(res, 400, err); + + auto idx = find_lidar_index(app.state, id); + if (!idx) + return json_error(res, 404, "lidar not found"); + + const std::string name = trim_copy(payload["name"].get()); + const std::string ip = trim_copy(payload["ip"].get()); + const int port = payload["port"].get(); + if (lidar_triplet_exists(app.state, name, ip, port, &id)) + return json_error(res, 409, "lidar with same name, ip and port already exists"); + + auto& lidar = app.state["lidars"][*idx]; + lidar["name"] = name; + lidar["ip"] = ip; + lidar["port"] = port; + 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 = lidar.dump(); + }); + + svr.Delete(R"(/api/lidars/([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_lidar_index(app.state, id); + if (!idx) + return json_error(res, 404, "lidar not found"); + + app.state["lidars"].erase(app.state["lidars"].begin() + static_cast(*idx)); + // Also remove pose entry if present + if (app.state.contains("layout") && app.state["layout"].is_object()) + { + if (app.state["layout"].contains("lidarPoses") && app.state["layout"]["lidarPoses"].is_object()) + app.state["layout"]["lidarPoses"].erase(id); + if (app.state["layout"].contains("lidarPositions") && app.state["layout"]["lidarPositions"].is_object()) + app.state["layout"]["lidarPositions"].erase(id); + } + if (!save_app_state(app)) + return json_error(res, 500, "failed to save layout"); + res.status = 204; + }); + + svr.Get("/api/layout", [&app](const httplib::Request&, httplib::Response& res) { + add_cors(res); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = app.state["layout"].dump(); + }); + + svr.Put("/api/layout", [&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"); + } + if (!payload.is_object()) + return json_error(res, 400, "layout must be an object"); + + app.state["layout"] = payload; + 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 = app.state["layout"].dump(); + }); + + svr.Put(R"(/api/layouts/([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"); + } + if (!payload.is_object()) + return json_error(res, 400, "payload must be an object"); + + ensure_schema(app); + const auto idx = find_profile_index(app.state, id); + if (!idx) + return json_error(res, 404, "layout not found"); + + auto loaded = load_profile_from_disk(app, id); + if (!loaded) + return json_error(res, 404, "layout file not found"); + json profile = *loaded; + + if (payload.contains("name") && payload["name"].is_string()) + { + const std::string name = trim_copy(payload["name"].get()); + if (name.empty()) + return json_error(res, 400, "name is required"); + if (layout_name_exists(app.state, name, &id)) + return json_error(res, 409, "layout name already exists"); + profile["name"] = name; + } + if (payload.contains("layout") && payload["layout"].is_object()) + profile["layout"] = payload["layout"]; + if (payload.contains("lidars") && payload["lidars"].is_array()) + profile["lidars"] = payload["lidars"]; + ensure_layout_schema(profile["layout"]); + touch_profile(profile); + if (!save_profile_to_disk(app, profile)) + return json_error(res, 500, "failed to save layout file"); + + app.state["layouts"][*idx] = catalog_entry_from_profile(profile); + const bool is_active = + app.state.contains("active_layout_id") && app.state["active_layout_id"].get() == id; + if (is_active) + { + app.state["layout"] = profile["layout"]; + app.state["lidars"] = profile["lidars"]; + } + save_state(app); + + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = profile.dump(); + }); + + mount_static(svr, www_root); + + // Console hint + std::fprintf(stderr, + "lidar_manager_web listening on http://0.0.0.0:%d (www=%s, state=%s, models=%s)\n", + port, + www_root.string().c_str(), + data_path.string().c_str(), + (data_path.parent_path() / "models").string().c_str()); + + svr.listen("0.0.0.0", port); + return 0; +} + diff --git a/www/app.js b/www/app.js new file mode 100644 index 0000000..0afc8ba --- /dev/null +++ b/www/app.js @@ -0,0 +1,2799 @@ +const el = (id) => document.getElementById(id); + +const statusEl = el("status"); +const listEl = el("lidarList"); +const lidarFormHintEl = el("lidarFormHint"); + +const canvasWrap = el("canvasWrap"); +const robotModelEl = el("robotModel"); +const diffParamsEl = el("diffParams"); +const bicycleParamsEl = el("bicycleParams"); +const bicycleWheelbaseMEl = el("bicycleWheelbaseM"); +const bicycleWheelRadiusMEl = el("bicycleWheelRadiusM"); +const bicycleScaleMPerPxEl = el("bicycleScaleMPerPx"); +const bicycleSteerPreviewDegEl = el("bicycleSteerPreviewDeg"); +const bicycleSteerMaxDegEl = el("bicycleSteerMaxDeg"); +const bicycleCmdVelTimeoutEl = el("bicycleCmdVelTimeout"); +const bicycleLinearMaxVelEl = el("bicycleLinearMaxVel"); +const bicycleLinearMaxAccelEl = el("bicycleLinearMaxAccel"); +const bicycleValidationEl = el("bicycleValidation"); +const bicycleMotorWheelsEl = el("bicycleMotorWheels"); +const wheelSeparationMEl = el("wheelSeparationM"); +const wheelRadiusMEl = el("wheelRadiusM"); +const scaleMPerPxEl = el("scaleMPerPx"); +const wheelSeparationMultEl = el("wheelSeparationMult"); +const wheelRadiusMultEl = el("wheelRadiusMult"); +const cmdVelTimeoutEl = el("cmdVelTimeout"); +const linearMaxVelEl = el("linearMaxVel"); +const linearMinVelEl = el("linearMinVel"); +const linearMaxAccelEl = el("linearMaxAccel"); +const angularMaxVelEl = el("angularMaxVel"); +const angularMaxAccelEl = el("angularMaxAccel"); +const diffValidationEl = el("diffValidation"); +const robotDiffSummaryEl = el("robotDiffSummary"); +const editFootprintBtn = el("editFootprintBtn"); +const footprintEditHint = el("footprintEditHint"); +const footprintShapeEl = el("footprintShape"); +const footprintPresetPanelEl = el("footprintPresetPanel"); +const fpRectParamsEl = el("fpRectParams"); +const fpCircleParamsEl = el("fpCircleParams"); +const fpPolyParamsEl = el("fpPolyParams"); +const fpLengthMEl = el("fpLengthM"); +const fpWidthMEl = el("fpWidthM"); +const fpRadiusMEl = el("fpRadiusM"); +const fpCircleSegmentsEl = el("fpCircleSegments"); +const fpPolyRadiusMEl = el("fpPolyRadiusM"); +const fpPolySidesEl = el("fpPolySides"); +const applyFootprintPresetBtn = el("applyFootprintPresetBtn"); +const footprintCustomPanelEl = el("footprintCustomPanel"); +const fpVertexCountEl = el("fpVertexCount"); +const fpSelectedVertexTextEl = el("fpSelectedVertexText"); +const fpAddVertexBtn = el("fpAddVertexBtn"); +const fpRemoveVertexBtn = el("fpRemoveVertexBtn"); +const saveLayoutBtn = el("saveLayoutBtn"); +const layoutSelectEl = el("layoutSelect"); +const layoutNewNameEl = el("layoutNewName"); +const layoutCloneCurrentEl = el("layoutCloneCurrent"); +const layoutCreateBtn = el("layoutCreateBtn"); +const layoutDeleteBtn = el("layoutDeleteBtn"); +const layoutActiveHintEl = el("layoutActiveHint"); +const lidarListCard = el("lidarListCard"); +const lidarListCardToggle = el("lidarListCardToggle"); +const robotModelCard = el("robotModelCard"); +const robotModelCardToggle = el("robotModelCardToggle"); + +const canvas = el("canvas"); +const ctx = canvas.getContext("2d"); + +const robotCenterText = el("robotCenterText"); +const selectedText = el("selectedText"); +const selectedRelText = el("selectedRelText"); + +const state = { + lidars: [], + layout: { + map: { width: 800, height: 600 }, + robot: { x: 400, y: 300, yaw_deg: 0 }, + // LiDAR pose is stored in ROBOT FRAME (ROS REP-103): x forward, y left, theta CCW around +Z. + // It is converted to canvas/world only for drawing and hit-testing. + lidarPoses: {}, // id -> {x,y,theta_deg} + lidarPosesFrame: "robot", + }, + dragging: null, // {id, dx, dy} lidar + draggingFootprint: null, // {index, dx, dy} + selectedId: null, + selectedFootprintVertex: null, + editFootprint: false, + lidarListPanelCollapsed: false, + robotModelPanelCollapsed: false, + activeLayoutId: null, + activeLayoutName: "", + layoutCatalog: [], + layoutDirty: false, + lidarItemCollapsed: {}, // id -> true if collapsed + viewInitialized: false, + view: { scale: 1, panX: 0, panY: 0 }, + panning: null, // { startSx, startSy, startPanX, startPanY } + pendingFootprintClick: null, // { sx, sy } when Shift+click may add a vertex +}; + +const DIFF_DEFAULTS = { + frame_id: "base_footprint", + wheel_separation_m: 1.0, + wheel_radius_m: 0.3, + wheel_separation_multiplier: 1.0, + wheel_radius_multiplier: 1.0, + scale_m_per_px: 0.005, + cmd_vel_timeout_s: 0.25, + linear: { + max_velocity: 1.0, + min_velocity: -0.5, + max_acceleration: 0.8, + min_acceleration: -0.4, + }, + angular: { + max_velocity: 1.7, + max_acceleration: 1.5, + }, +}; + +const BICYCLE_DEFAULTS = { + frame_id: "base_footprint", + wheelbase_m: 1.2, + wheel_radius_m: 0.15, + scale_m_per_px: 0.005, + steer_max_deg: 35, + steer_preview_deg: 15, + cmd_vel_timeout_s: 0.25, + linear_max_velocity: 1.0, + linear_max_acceleration: 0.8, +}; + +const DEFAULT_BICYCLE_WHEELS = { + rear: { + id: "rear", + role: "drive", + x_m: 0, + y_m: 0, + joint_name: "rear_wheel_joint", + motor: { vendor: "moons", model: "m2dc10a", gear_ratio: 20, invert: false }, + }, + front: { + id: "front", + role: "steer", + x_m: 1.2, + y_m: 0, + joint_name: "front_steer_joint", + motor: { vendor: "moons", model: "m2dc10a", gear_ratio: 20, invert: false }, + }, +}; + +const DEFAULT_WHEEL_MOTORS = { + left: { + id: "left", + side: "left", + joint_name: "wheel_left_joint", + motor: { vendor: "moons", model: "m2dc10a", gear_ratio: 20, invert: false }, + }, + right: { + id: "right", + side: "right", + joint_name: "wheel_right_joint", + motor: { vendor: "moons", model: "m2dc10a", gear_ratio: 20, invert: false }, + }, +}; + +let motorCatalog = null; +const motorWheelsEl = el("motorWheels"); + +async function loadMotorCatalog() { + if (motorCatalog) return motorCatalog; + try { + const res = await fetch("/data/motor_catalog.json", { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + motorCatalog = await res.json(); + } catch (e) { + motorCatalog = { + vendors: { + custom: { + label: "Tùy chỉnh", + models: { + custom: { + label: "Motor tùy chỉnh", + interface: "other", + max_rpm: 3000, + gear_ratio_default: 1, + }, + }, + }, + }, + }; + setStatus(`Không tải được catalog động cơ: ${e.message}`); + } + return motorCatalog; +} + +function getVendorOptions() { + if (!motorCatalog?.vendors) return []; + return Object.entries(motorCatalog.vendors).map(([id, v]) => ({ + id, + label: v.label || id, + })); +} + +function getModelOptions(vendorId) { + const v = motorCatalog?.vendors?.[vendorId]; + if (!v?.models) return []; + return Object.entries(v.models).map(([id, m]) => ({ + id, + label: m.label || id, + })); +} + +function getMotorModelSpec(vendorId, modelId) { + return motorCatalog?.vendors?.[vendorId]?.models?.[modelId] || null; +} + +function getMotorDisplayLabel(vendorId, modelId) { + const v = motorCatalog?.vendors?.[vendorId]; + const m = v?.models?.[modelId]; + if (!v && !m) return "—"; + if (v && m) return `${v.label} — ${m.label}`; + return v?.label || modelId || "—"; +} + +function syncWheelPositionsFromSeparation() { + const diff = state.layout.robot.diff; + if (!Array.isArray(diff.wheels)) return; + const half = Number(diff.wheel_separation_m) / 2; + diff.wheels.forEach((w) => { + if (w.side === "left" || w.id === "left") w.y_m = half; + if (w.side === "right" || w.id === "right") w.y_m = -half; + }); +} + +function ensureDiffWheels() { + const diff = state.layout.robot.diff; + if (!Array.isArray(diff.wheels) || diff.wheels.length < 2) { + const half = Number(diff.wheel_separation_m || 1) / 2; + diff.wheels = [ + { ...DEFAULT_WHEEL_MOTORS.left, motor: { ...DEFAULT_WHEEL_MOTORS.left.motor }, y_m: half }, + { ...DEFAULT_WHEEL_MOTORS.right, motor: { ...DEFAULT_WHEEL_MOTORS.right.motor }, y_m: -half }, + ]; + } + diff.wheels.forEach((w, idx) => { + if (!w.id) w.id = idx === 0 ? "left" : "right"; + if (!w.side) w.side = w.id === "right" ? "right" : "left"; + if (!w.motor || typeof w.motor !== "object") { + const def = w.side === "right" ? DEFAULT_WHEEL_MOTORS.right : DEFAULT_WHEEL_MOTORS.left; + w.motor = { ...def.motor }; + } + if (!w.motor.vendor) w.motor.vendor = "custom"; + if (!w.motor.model) w.motor.model = "custom"; + if (w.motor.gear_ratio === undefined) { + const spec = getMotorModelSpec(w.motor.vendor, w.motor.model); + w.motor.gear_ratio = spec?.gear_ratio_default ?? 1; + } + if (w.motor.invert === undefined) w.motor.invert = false; + if (!w.joint_name) { + w.joint_name = w.side === "right" ? "wheel_right_joint" : "wheel_left_joint"; + } + if (w.y_m === undefined || w.y_m === null) { + w.y_m = w.side === "right" ? -halfFromSep(diff) : halfFromSep(diff); + } + }); + syncWheelPositionsFromSeparation(); +} + +function halfFromSep(diff) { + return Number(diff?.wheel_separation_m ?? 1) / 2; +} + +function getDiffWheelsForDraw() { + ensureDiffSchema(); + return state.layout.robot.diff.wheels || []; +} + +function renderMotorWheels() { + if (!motorWheelsEl) return; + ensureDiffSchema(); + const wheels = state.layout.robot.diff.wheels; + const vendors = getVendorOptions(); + + motorWheelsEl.innerHTML = wheels + .map((w) => { + const sideLabel = w.side === "right" ? "Bánh phải" : "Bánh trái"; + const vendor = w.motor?.vendor || "custom"; + const model = w.motor?.model || "custom"; + const vendorOpts = vendors + .map( + (v) => + ``, + ) + .join(""); + const models = getModelOptions(vendor); + const modelOpts = models + .map( + (m) => + ``, + ) + .join(""); + const spec = getMotorModelSpec(vendor, model); + const specTxt = spec + ? `${spec.interface || "—"} • ${spec.max_rpm ?? "—"} rpm • τ≈${spec.rated_torque_nm ?? "—"} Nm` + : "—"; + return ` +
+
${escapeHtml(sideLabel)} (${escapeHtml(w.joint_name || "")})
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
${escapeHtml(specTxt)}
+
`; + }) + .join(""); +} + +function findWheelById(wheelId) { + return state.layout.robot.diff.wheels?.find((w) => w.id === wheelId) || null; +} + +function applyMotorWheelsFromDOM() { + if (!motorWheelsEl) return; + ensureDiffSchema(); + motorWheelsEl.querySelectorAll(".wheelMotorBlock").forEach((block) => { + const wheelId = block.dataset.wheelId; + const w = findWheelById(wheelId); + if (!w) return; + const vendor = block.querySelector(".motorVendor")?.value || "custom"; + const model = block.querySelector(".motorModel")?.value || "custom"; + const joint = block.querySelector(".motorJoint")?.value?.trim() || ""; + const gear = Number(block.querySelector(".motorGear")?.value); + const invert = !!block.querySelector(".motorInvert")?.checked; + w.motor.vendor = vendor; + w.motor.model = model; + w.joint_name = joint || (w.side === "right" ? "wheel_right_joint" : "wheel_left_joint"); + w.motor.gear_ratio = clamp(Number.isFinite(gear) ? gear : 1, 0.1, 200); + w.motor.invert = invert; + const meta = block.querySelector(".wheelMotorMeta"); + if (meta) { + const spec = getMotorModelSpec(vendor, model); + meta.textContent = spec + ? `${spec.interface || "—"} • ${spec.max_rpm ?? "—"} rpm • τ≈${spec.rated_torque_nm ?? "—"} Nm` + : "—"; + } + }); +} + +function initMotorWheelsEvents() { + if (!motorWheelsEl || motorWheelsEl.dataset.bound === "1") return; + motorWheelsEl.dataset.bound = "1"; + + motorWheelsEl.addEventListener("change", (evt) => { + const vendorSel = evt.target.closest(".motorVendor"); + if (vendorSel) { + const w = findWheelById(vendorSel.dataset.wheelId); + if (w) { + const models = getModelOptions(vendorSel.value); + const first = models[0]?.id || "custom"; + w.motor.vendor = vendorSel.value; + w.motor.model = first; + const spec = getMotorModelSpec(w.motor.vendor, w.motor.model); + if (spec?.gear_ratio_default !== undefined) { + w.motor.gear_ratio = spec.gear_ratio_default; + } + renderMotorWheels(); + } + applyMotorWheelsFromDOM(); + updateRobotDiffSummary(); + renderCanvas(); + return; + } + applyMotorWheelsFromDOM(); + updateRobotDiffSummary(); + renderCanvas(); + }); + + motorWheelsEl.addEventListener("input", (evt) => { + if (evt.target.closest(".motorJoint") || evt.target.closest(".motorGear")) { + applyMotorWheelsFromDOM(); + updateRobotDiffSummary(); + } + }); +} + +function setStatus(msg) { + statusEl.textContent = msg; +} + +async function api(path, opts = {}) { + const res = await fetch(path, { + headers: { "Content-Type": "application/json" }, + ...opts, + }); + if (res.status === 204) return null; + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = text; + } + if (!res.ok) { + const err = data && data.error ? data.error : `HTTP ${res.status}`; + throw new Error(err); + } + return data; +} + +function ensureDefaultPose(id, idx) { + if (state.layout.lidarPoses[id]) return; + const angle = (idx / Math.max(1, state.lidars.length)) * Math.PI * 2; + const radius = 120; + state.layout.lidarPoses[id] = { + x: Math.cos(angle) * radius, + y: -Math.sin(angle) * radius, + theta_deg: 0, + }; +} + +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; +} + +function ensureDiffSchema() { + const robot = state.layout.robot; + if (!robot.diff || typeof robot.diff !== "object") robot.diff = {}; + const diff = robot.diff; + if (!diff.display || typeof diff.display !== "object") diff.display = {}; + const disp = diff.display; + const scale = + Number(disp.scale_m_per_px) > 0 ? Number(disp.scale_m_per_px) : DIFF_DEFAULTS.scale_m_per_px; + disp.scale_m_per_px = scale; + + if (diff.wheel_separation_m === undefined) { + diff.wheel_separation_m = + diff.b !== undefined ? Number(diff.b) * scale : DIFF_DEFAULTS.wheel_separation_m; + } + if (diff.wheel_radius_m === undefined) { + diff.wheel_radius_m = + diff.d !== undefined ? (Number(diff.d) / 2) * scale : DIFF_DEFAULTS.wheel_radius_m; + } + if (diff.wheel_separation_multiplier === undefined) { + diff.wheel_separation_multiplier = DIFF_DEFAULTS.wheel_separation_multiplier; + } + if (diff.wheel_radius_multiplier === undefined) { + diff.wheel_radius_multiplier = DIFF_DEFAULTS.wheel_radius_multiplier; + } + + if (!diff.limits || typeof diff.limits !== "object") diff.limits = {}; + const lim = diff.limits; + if (lim.cmd_vel_timeout_s === undefined) lim.cmd_vel_timeout_s = DIFF_DEFAULTS.cmd_vel_timeout_s; + if (!lim.linear || typeof lim.linear !== "object") lim.linear = {}; + if (lim.linear.max_velocity === undefined) lim.linear.max_velocity = DIFF_DEFAULTS.linear.max_velocity; + if (lim.linear.min_velocity === undefined) lim.linear.min_velocity = DIFF_DEFAULTS.linear.min_velocity; + if (lim.linear.max_acceleration === undefined) { + lim.linear.max_acceleration = DIFF_DEFAULTS.linear.max_acceleration; + } + if (lim.linear.min_acceleration === undefined) { + lim.linear.min_acceleration = DIFF_DEFAULTS.linear.min_acceleration; + } + if (!lim.angular || typeof lim.angular !== "object") lim.angular = {}; + if (lim.angular.max_velocity === undefined) { + lim.angular.max_velocity = DIFF_DEFAULTS.angular.max_velocity; + } + if (lim.angular.max_acceleration === undefined) { + lim.angular.max_acceleration = DIFF_DEFAULTS.angular.max_acceleration; + } + + if (!robot.frame_id) robot.frame_id = DIFF_DEFAULTS.frame_id; + applyDiffDisplayPx(); + ensureDiffWheels(); +} + +function applyDiffDisplayPx() { + const diff = state.layout.robot.diff; + const s = diff.display.scale_m_per_px; + const bMult = Number(diff.wheel_separation_multiplier) || 1; + const rMult = Number(diff.wheel_radius_multiplier) || 1; + diff.display.b_px = (Number(diff.wheel_separation_m) * bMult) / s; + diff.display.d_px = (2 * Number(diff.wheel_radius_m) * rMult) / s; + diff.b = diff.display.b_px; + diff.d = diff.display.d_px; +} + +function syncDiffDisplayPx() { + ensureDiffSchema(); +} + +function getWheelSeparationPx() { + syncDiffDisplayPx(); + return state.layout.robot.diff.display.b_px; +} + +function getWheelDiameterPx() { + syncDiffDisplayPx(); + return state.layout.robot.diff.display.d_px; +} + +function validateDiff() { + ensureDiffSchema(); + const diff = state.layout.robot.diff; + const b = Number(diff.wheel_separation_m); + const r = Number(diff.wheel_radius_m); + const msgs = []; + if (!(b > 2 * r)) msgs.push("Khoảng cách 2 bánh nên lớn hơn đường kính bánh (b > 2r)."); + const lim = diff.limits.linear; + if (lim.min_velocity > 0) msgs.push("Linear min_velocity thường ≤ 0."); + if (lim.max_velocity < lim.min_velocity) msgs.push("Linear max_velocity phải ≥ min_velocity."); + + ensureFootprint(); + const fp = state.layout.robot.footprint; + let minY = Infinity; + let maxY = -Infinity; + fp.forEach((p) => { + const y = Number(p.y); + if (Number.isFinite(y)) { + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + }); + const trackPx = getWheelSeparationPx(); + if (Number.isFinite(minY) && maxY - minY < trackPx * 0.9) { + msgs.push("Footprint có vẻ hẹp hơn khoảng cách 2 bánh — kiểm tra lại."); + } + + if (msgs.length === 0) { + diffValidationEl.hidden = true; + diffValidationEl.textContent = ""; + diffValidationEl.classList.remove("error"); + return true; + } + diffValidationEl.hidden = false; + diffValidationEl.textContent = msgs.join(" "); + diffValidationEl.classList.toggle("error", msgs.some((m) => m.includes("phải"))); + return false; +} + +function updateRobotDiffSummary() { + const model = state.layout.robot.model || "diff"; + if (model === "bicycle") { + ensureBicycleSchema(); + const b = state.layout.robot.bicycle; + const lim = b.limits.linear; + const rear = b.wheels?.find((w) => w.id === "rear" || w.role === "drive"); + const front = b.wheels?.find((w) => w.id === "front" || w.role === "steer"); + const rMot = rear?.motor ? getMotorDisplayLabel(rear.motor.vendor, rear.motor.model) : "—"; + const fMot = front?.motor ? getMotorDisplayLabel(front.motor.vendor, front.motor.model) : "—"; + const delta = Number(b.steer?.preview_deg ?? 0); + const R = Math.abs(delta) > 0.5 ? Number(b.wheelbase_m) / Math.tan((delta * Math.PI) / 180) : Infinity; + const rTxt = Number.isFinite(R) && R < 1e4 ? `R≈${R.toFixed(2)}m` : "R=∞"; + robotDiffSummaryEl.textContent = + `Bicycle: L=${Number(b.wheelbase_m).toFixed(2)} m, δ=${delta.toFixed(0)}° (${rTxt}) | ` + + `v≤${Number(lim.max_velocity).toFixed(1)} m/s | rear: ${rMot} | steer: ${fMot}`; + return; + } + if (model !== "diff") { + robotDiffSummaryEl.textContent = `Model: ${model}`; + return; + } + ensureDiffSchema(); + const d = state.layout.robot.diff; + const lim = d.limits.linear; + const wheels = d.wheels || []; + const left = wheels.find((w) => w.side === "left" || w.id === "left"); + const right = wheels.find((w) => w.side === "right" || w.id === "right"); + const lMot = left?.motor ? getMotorDisplayLabel(left.motor.vendor, left.motor.model) : "—"; + const rMot = right?.motor ? getMotorDisplayLabel(right.motor.vendor, right.motor.model) : "—"; + robotDiffSummaryEl.textContent = + `Diff: b=${Number(d.wheel_separation_m).toFixed(2)} m, r=${Number(d.wheel_radius_m).toFixed(2)} m | ` + + `v≤${Number(lim.max_velocity).toFixed(1)} m/s | L: ${lMot} | R: ${rMot}`; +} + +function ensureBicycleSchema() { + const robot = state.layout.robot; + if (!robot.bicycle || typeof robot.bicycle !== "object") robot.bicycle = {}; + const b = robot.bicycle; + if (!b.display || typeof b.display !== "object") b.display = {}; + const scale = + Number(b.display.scale_m_per_px) > 0 ? Number(b.display.scale_m_per_px) : BICYCLE_DEFAULTS.scale_m_per_px; + b.display.scale_m_per_px = scale; + + if (b.wheelbase_m === undefined) b.wheelbase_m = BICYCLE_DEFAULTS.wheelbase_m; + if (b.wheel_radius_m === undefined) b.wheel_radius_m = BICYCLE_DEFAULTS.wheel_radius_m; + + if (!b.steer || typeof b.steer !== "object") b.steer = {}; + if (b.steer.max_angle_deg === undefined) b.steer.max_angle_deg = BICYCLE_DEFAULTS.steer_max_deg; + if (b.steer.preview_deg === undefined) b.steer.preview_deg = BICYCLE_DEFAULTS.steer_preview_deg; + if (!b.steer.joint_name) b.steer.joint_name = "front_steer_joint"; + + if (!b.drive || typeof b.drive !== "object") b.drive = {}; + if (!b.drive.joint_name) b.drive.joint_name = "rear_wheel_joint"; + + if (!b.limits || typeof b.limits !== "object") b.limits = {}; + const lim = b.limits; + if (lim.cmd_vel_timeout_s === undefined) lim.cmd_vel_timeout_s = BICYCLE_DEFAULTS.cmd_vel_timeout_s; + if (!lim.linear || typeof lim.linear !== "object") lim.linear = {}; + if (lim.linear.max_velocity === undefined) lim.linear.max_velocity = BICYCLE_DEFAULTS.linear_max_velocity; + if (lim.linear.max_acceleration === undefined) { + lim.linear.max_acceleration = BICYCLE_DEFAULTS.linear_max_acceleration; + } + + ensureBicycleWheels(); + applyBicycleDisplayPx(); + if (!robot.frame_id) robot.frame_id = BICYCLE_DEFAULTS.frame_id; +} + +function ensureBicycleWheels() { + const b = state.layout.robot.bicycle; + const L = Number(b.wheelbase_m) || BICYCLE_DEFAULTS.wheelbase_m; + if (!Array.isArray(b.wheels) || b.wheels.length < 2) { + b.wheels = [ + { ...DEFAULT_BICYCLE_WHEELS.rear, motor: { ...DEFAULT_BICYCLE_WHEELS.rear.motor } }, + { + ...DEFAULT_BICYCLE_WHEELS.front, + x_m: L, + motor: { ...DEFAULT_BICYCLE_WHEELS.front.motor }, + }, + ]; + } + b.wheels.forEach((w) => { + if (!w.id) w.id = w.role === "steer" ? "front" : "rear"; + if (!w.role) w.role = w.id === "front" ? "steer" : "drive"; + if (!w.motor || typeof w.motor !== "object") { + const def = w.role === "steer" ? DEFAULT_BICYCLE_WHEELS.front : DEFAULT_BICYCLE_WHEELS.rear; + w.motor = { ...def.motor }; + } + if (!w.motor.vendor) w.motor.vendor = "custom"; + if (!w.motor.model) w.motor.model = "custom"; + if (w.motor.gear_ratio === undefined) { + const spec = getMotorModelSpec(w.motor.vendor, w.motor.model); + w.motor.gear_ratio = spec?.gear_ratio_default ?? 1; + } + if (w.motor.invert === undefined) w.motor.invert = false; + if (!w.joint_name) { + w.joint_name = w.role === "steer" ? b.steer.joint_name : b.drive.joint_name; + } + if (w.role === "steer" || w.id === "front") { + w.x_m = L; + w.y_m = 0; + } else { + w.x_m = 0; + w.y_m = 0; + } + }); +} + +function applyBicycleDisplayPx() { + const b = state.layout.robot.bicycle; + const s = b.display.scale_m_per_px; + b.display.L_px = Number(b.wheelbase_m) / s; + b.display.r_px = (2 * Number(b.wheel_radius_m)) / s; +} + +function getBicycleWheelbasePx() { + ensureBicycleSchema(); + return state.layout.robot.bicycle.display.L_px; +} + +function getBicycleWheelDiameterPx() { + ensureBicycleSchema(); + return state.layout.robot.bicycle.display.r_px; +} + +function validateBicycle() { + ensureBicycleSchema(); + const b = state.layout.robot.bicycle; + const L = Number(b.wheelbase_m); + const r = Number(b.wheel_radius_m); + const msgs = []; + if (!(L > 2 * r)) msgs.push("Wheelbase L nên lớn hơn đường kính bánh (L > 2r)."); + const maxDeg = Number(b.steer.max_angle_deg); + const preview = Number(b.steer.preview_deg); + if (Math.abs(preview) > maxDeg) msgs.push("Góc xem trước vượt δ max."); + + ensureFootprint(); + const fp = state.layout.robot.footprint; + let minX = Infinity; + let maxX = -Infinity; + fp.forEach((p) => { + const x = Number(p.x); + if (Number.isFinite(x)) { + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + } + }); + const Lpx = getBicycleWheelbasePx(); + if (Number.isFinite(minX) && maxX - minX < Lpx * 0.85) { + msgs.push("Footprint có vẻ ngắn hơn wheelbase — kiểm tra lại."); + } + + if (msgs.length === 0) { + bicycleValidationEl.hidden = true; + bicycleValidationEl.textContent = ""; + return true; + } + bicycleValidationEl.hidden = false; + bicycleValidationEl.textContent = msgs.join(" "); + bicycleValidationEl.classList.add("error"); + return false; +} + +function syncBicycleFormFromState() { + ensureBicycleSchema(); + const b = state.layout.robot.bicycle; + const lim = b.limits; + + bicycleWheelbaseMEl.value = Number(b.wheelbase_m).toFixed(3); + bicycleWheelRadiusMEl.value = Number(b.wheel_radius_m).toFixed(3); + bicycleScaleMPerPxEl.value = Number(b.display.scale_m_per_px).toFixed(4); + bicycleSteerPreviewDegEl.value = Number(b.steer.preview_deg).toFixed(0); + bicycleSteerMaxDegEl.value = Number(b.steer.max_angle_deg).toFixed(0); + bicycleCmdVelTimeoutEl.value = Number(lim.cmd_vel_timeout_s).toFixed(2); + bicycleLinearMaxVelEl.value = Number(lim.linear.max_velocity).toFixed(2); + bicycleLinearMaxAccelEl.value = Number(lim.linear.max_acceleration).toFixed(2); + + renderBicycleMotorWheels(); + validateBicycle(); +} + +function applyBicycleFormToState() { + ensureBicycleSchema(); + const robot = state.layout.robot; + const b = robot.bicycle; + const lim = b.limits; + + robot.model = "bicycle"; + if (!robot.frame_id) robot.frame_id = BICYCLE_DEFAULTS.frame_id; + b.wheelbase_m = clamp(Number(bicycleWheelbaseMEl.value), 0.2, 5); + b.wheel_radius_m = clamp(Number(bicycleWheelRadiusMEl.value), 0.02, 1); + b.display.scale_m_per_px = clamp(Number(bicycleScaleMPerPxEl.value), 0.001, 0.1); + b.steer.preview_deg = clamp(Number(bicycleSteerPreviewDegEl.value), -60, 60); + b.steer.max_angle_deg = clamp(Number(bicycleSteerMaxDegEl.value), 5, 60); + lim.cmd_vel_timeout_s = clamp(Number(bicycleCmdVelTimeoutEl.value), 0.05, 5); + lim.linear.max_velocity = clamp(Number(bicycleLinearMaxVelEl.value), 0.01, 5); + lim.linear.max_acceleration = clamp(Number(bicycleLinearMaxAccelEl.value), 0.01, 10); + + ensureBicycleWheels(); + applyBicycleMotorWheelsFromDOM(); + applyBicycleDisplayPx(); + validateBicycle(); + updateRobotDiffSummary(); +} + +function onBicycleFieldChange() { + applyBicycleFormToState(); + markLayoutDirty(); + renderCanvas(); +} + +function findBicycleWheelById(wheelId) { + return state.layout.robot.bicycle?.wheels?.find((w) => w.id === wheelId) || null; +} + +function renderBicycleMotorWheels() { + if (!bicycleMotorWheelsEl) return; + ensureBicycleSchema(); + const wheels = state.layout.robot.bicycle.wheels; + const vendors = getVendorOptions(); + + bicycleMotorWheelsEl.innerHTML = wheels + .map((w) => { + const roleLabel = w.role === "steer" ? "Bánh trước (steer)" : "Bánh sau (drive)"; + const vendor = w.motor?.vendor || "custom"; + const model = w.motor?.model || "custom"; + const vendorOpts = vendors + .map( + (v) => + ``, + ) + .join(""); + const models = getModelOptions(vendor); + const modelOpts = models + .map( + (m) => + ``, + ) + .join(""); + const spec = getMotorModelSpec(vendor, model); + const specTxt = spec + ? `${spec.interface || "—"} • ${spec.max_rpm ?? "—"} rpm` + : "—"; + return ` +
+
${escapeHtml(roleLabel)} (${escapeHtml(w.joint_name || "")})
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
${escapeHtml(specTxt)}
+
`; + }) + .join(""); +} + +function applyBicycleMotorWheelsFromDOM() { + if (!bicycleMotorWheelsEl) return; + ensureBicycleSchema(); + const b = state.layout.robot.bicycle; + bicycleMotorWheelsEl.querySelectorAll(".wheelMotorBlock").forEach((block) => { + const wheelId = block.dataset.wheelId; + const w = findBicycleWheelById(wheelId); + if (!w) return; + const vendor = block.querySelector(".motorVendor")?.value || "custom"; + const model = block.querySelector(".motorModel")?.value || "custom"; + const joint = block.querySelector(".motorJoint")?.value?.trim() || ""; + const gear = Number(block.querySelector(".motorGear")?.value); + const invert = !!block.querySelector(".motorInvert")?.checked; + w.motor.vendor = vendor; + w.motor.model = model; + w.joint_name = joint || (w.role === "steer" ? b.steer.joint_name : b.drive.joint_name); + w.motor.gear_ratio = clamp(Number.isFinite(gear) ? gear : 1, 0.1, 200); + w.motor.invert = invert; + if (w.role === "steer") b.steer.joint_name = w.joint_name; + else b.drive.joint_name = w.joint_name; + }); +} + +function initBicycleMotorWheelsEvents() { + if (!bicycleMotorWheelsEl || bicycleMotorWheelsEl.dataset.bound === "1") return; + bicycleMotorWheelsEl.dataset.bound = "1"; + + bicycleMotorWheelsEl.addEventListener("change", (evt) => { + const vendorSel = evt.target.closest(".motorVendor"); + if (vendorSel) { + const w = findBicycleWheelById(vendorSel.dataset.wheelId); + if (w) { + const models = getModelOptions(vendorSel.value); + w.motor.vendor = vendorSel.value; + w.motor.model = models[0]?.id || "custom"; + const spec = getMotorModelSpec(w.motor.vendor, w.motor.model); + if (spec?.gear_ratio_default !== undefined) w.motor.gear_ratio = spec.gear_ratio_default; + renderBicycleMotorWheels(); + } + applyBicycleMotorWheelsFromDOM(); + updateRobotDiffSummary(); + return; + } + applyBicycleMotorWheelsFromDOM(); + updateRobotDiffSummary(); + }); + + bicycleMotorWheelsEl.addEventListener("input", (evt) => { + if (evt.target.closest(".motorJoint") || evt.target.closest(".motorGear")) { + applyBicycleMotorWheelsFromDOM(); + updateRobotDiffSummary(); + } + }); +} + +function syncDiffFormFromState() { + ensureDiffSchema(); + const robot = state.layout.robot; + const d = robot.diff; + const lim = d.limits; + + robotModelEl.value = robot.model || "diff"; + wheelSeparationMEl.value = Number(d.wheel_separation_m).toFixed(3); + wheelRadiusMEl.value = Number(d.wheel_radius_m).toFixed(3); + scaleMPerPxEl.value = Number(d.display.scale_m_per_px).toFixed(4); + wheelSeparationMultEl.value = Number(d.wheel_separation_multiplier).toFixed(2); + wheelRadiusMultEl.value = Number(d.wheel_radius_multiplier).toFixed(2); + cmdVelTimeoutEl.value = Number(lim.cmd_vel_timeout_s).toFixed(2); + linearMaxVelEl.value = Number(lim.linear.max_velocity).toFixed(2); + linearMinVelEl.value = Number(lim.linear.min_velocity).toFixed(2); + linearMaxAccelEl.value = Number(lim.linear.max_acceleration).toFixed(2); + angularMaxVelEl.value = Number(lim.angular.max_velocity).toFixed(2); + angularMaxAccelEl.value = Number(lim.angular.max_acceleration).toFixed(2); + + const isDiff = robotModelEl.value === "diff"; + const isBicycle = robotModelEl.value === "bicycle"; + diffParamsEl.hidden = !isDiff; + bicycleParamsEl.hidden = !isBicycle; + if (diffValidationEl) diffValidationEl.hidden = !isDiff; + if (bicycleValidationEl) bicycleValidationEl.hidden = !isBicycle; + + if (isDiff) { + renderMotorWheels(); + validateDiff(); + } else if (isBicycle) { + syncBicycleFormFromState(); + } + updateRobotDiffSummary(); +} + +function applyDiffFormToState() { + ensureDiffSchema(); + const robot = state.layout.robot; + const d = robot.diff; + const lim = d.limits; + + robot.model = robotModelEl.value || "diff"; + if (!robot.frame_id) robot.frame_id = DIFF_DEFAULTS.frame_id; + d.wheel_separation_m = clamp(Number(wheelSeparationMEl.value), 0.05, 5); + d.wheel_radius_m = clamp(Number(wheelRadiusMEl.value), 0.02, 1); + d.display.scale_m_per_px = clamp(Number(scaleMPerPxEl.value), 0.001, 0.1); + d.wheel_separation_multiplier = clamp(Number(wheelSeparationMultEl.value), 0.5, 2); + d.wheel_radius_multiplier = clamp(Number(wheelRadiusMultEl.value), 0.5, 2); + lim.cmd_vel_timeout_s = clamp(Number(cmdVelTimeoutEl.value), 0.05, 5); + lim.linear.max_velocity = clamp(Number(linearMaxVelEl.value), 0.01, 5); + lim.linear.min_velocity = clamp(Number(linearMinVelEl.value), -5, 0); + lim.linear.max_acceleration = clamp(Number(linearMaxAccelEl.value), 0.01, 10); + lim.linear.min_acceleration = -Math.abs(lim.linear.max_acceleration); + lim.angular.max_velocity = clamp(Number(angularMaxVelEl.value), 0.01, 10); + lim.angular.max_acceleration = clamp(Number(angularMaxAccelEl.value), 0.01, 10); + + syncWheelPositionsFromSeparation(); + applyMotorWheelsFromDOM(); + syncDiffDisplayPx(); + validateDiff(); + updateRobotDiffSummary(); +} + +function onDiffFieldChange() { + applyDiffFormToState(); + markLayoutDirty(); + renderCanvas(); +} + +function robotToAbs(xRobot, yRobot) { + // Robot frame (ROS REP-103): x forward, y left. + // Convert robot-frame (x,y) into canvas absolute coordinates. + const r = state.layout.robot; + const yaw = yawCanvasRad(); + const xh = { x: Math.cos(yaw), y: Math.sin(yaw) }; + const yh = { x: Math.cos(yaw - Math.PI / 2), y: Math.sin(yaw - Math.PI / 2) }; + return { + x: r.x + xRobot * xh.x + yRobot * yh.x, + y: r.y + xRobot * xh.y + yRobot * yh.y, + }; +} + +function absToRobot(xAbs, yAbs) { + const r = state.layout.robot; + const dx = xAbs - r.x; + const dy = yAbs - r.y; + const yaw = yawCanvasRad(); + const xh = { x: Math.cos(yaw), y: Math.sin(yaw) }; + const yh = { x: Math.cos(yaw - Math.PI / 2), y: Math.sin(yaw - Math.PI / 2) }; + return { + x: dx * xh.x + dy * xh.y, + y: dx * yh.x + dy * yh.y, + }; +} + +function getLidarPoseAbs(id) { + const pose = state.layout.lidarPoses[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 }; +} + +const FOOTPRINT_DEFAULT_PARAMS = { + length_m: 1.4, + width_m: 1.1, + radius_m: 0.55, + sides: 6, + segments: 32, +}; + +function getScaleMPerPx() { + const robot = state.layout.robot; + if ((robot.model || "diff") === "bicycle") { + return Number(robot.bicycle?.display?.scale_m_per_px) || BICYCLE_DEFAULTS.scale_m_per_px; + } + return Number(robot.diff?.display?.scale_m_per_px) || DIFF_DEFAULTS.scale_m_per_px; +} + +function mToRobotPx(m) { + return Number(m) / getScaleMPerPx(); +} + +function robotPxToM(px) { + return Number(px) * getScaleMPerPx(); +} + +function isCustomFootprintShape() { + return (state.layout.robot.footprint_shape || "custom") === "custom"; +} + +function ensureFootprintSchema() { + const robot = state.layout.robot; + if (!robot.footprint_shape) robot.footprint_shape = "custom"; + if (!robot.footprint_params || typeof robot.footprint_params !== "object") { + robot.footprint_params = { ...FOOTPRINT_DEFAULT_PARAMS }; + } + const p = robot.footprint_params; + if (p.length_m === undefined) p.length_m = FOOTPRINT_DEFAULT_PARAMS.length_m; + if (p.width_m === undefined) p.width_m = FOOTPRINT_DEFAULT_PARAMS.width_m; + if (p.radius_m === undefined) p.radius_m = FOOTPRINT_DEFAULT_PARAMS.radius_m; + if (p.sides === undefined) p.sides = FOOTPRINT_DEFAULT_PARAMS.sides; + if (p.segments === undefined) p.segments = FOOTPRINT_DEFAULT_PARAMS.segments; +} + +function footprintBoundsPx(points) { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + points.forEach((pt) => { + const x = Number(pt.x); + const y = Number(pt.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + }); + if (!Number.isFinite(minX)) return null; + return { minX, maxX, minY, maxY }; +} + +function inferFootprintParamsFromPoints(points) { + const b = footprintBoundsPx(points); + if (!b) return { ...FOOTPRINT_DEFAULT_PARAMS }; + const lengthPx = b.maxX - b.minX; + const widthPx = b.maxY - b.minY; + const radiusPx = Math.max(lengthPx, widthPx) / 2; + return { + length_m: robotPxToM(lengthPx), + width_m: robotPxToM(widthPx), + radius_m: robotPxToM(radiusPx), + sides: FOOTPRINT_DEFAULT_PARAMS.sides, + segments: FOOTPRINT_DEFAULT_PARAMS.segments, + }; +} + +function regularPolygonPoints(radiusPx, sides) { + const pts = []; + const n = clamp(Math.round(sides), 3, 64); + for (let i = 0; i < n; i++) { + const a = (2 * Math.PI * i) / n; + pts.push({ x: radiusPx * Math.cos(a), y: radiusPx * Math.sin(a) }); + } + return pts; +} + +function generateFootprintPoints(shape, params) { + const p = params || {}; + switch (shape) { + case "rectangle": { + const L = mToRobotPx(clamp(Number(p.length_m), 0.1, 20)); + const W = mToRobotPx(clamp(Number(p.width_m), 0.1, 20)); + const hx = L / 2; + const hy = W / 2; + return [ + { x: hx, y: hy }, + { x: hx, y: -hy }, + { x: -hx, y: -hy }, + { x: -hx, y: hy }, + ]; + } + case "circle": { + const r = mToRobotPx(clamp(Number(p.radius_m), 0.05, 10)); + const n = clamp(Math.round(Number(p.segments) || 32), 8, 64); + return regularPolygonPoints(r, n); + } + case "regular_polygon": { + const r = mToRobotPx(clamp(Number(p.radius_m), 0.05, 10)); + const sides = clamp(Math.round(Number(p.sides) || 6), 3, 32); + return regularPolygonPoints(r, sides); + } + default: + return null; + } +} + +function readFootprintParamsFromDOM() { + const robot = state.layout.robot; + if (!robot.footprint_params) robot.footprint_params = { ...FOOTPRINT_DEFAULT_PARAMS }; + const p = robot.footprint_params; + const shape = footprintShapeEl?.value || robot.footprint_shape; + if (shape === "rectangle") { + p.length_m = clamp(Number(fpLengthMEl?.value), 0.1, 20); + p.width_m = clamp(Number(fpWidthMEl?.value), 0.1, 20); + } else if (shape === "circle") { + p.radius_m = clamp(Number(fpRadiusMEl?.value), 0.05, 10); + p.segments = clamp(Math.round(Number(fpCircleSegmentsEl?.value) || 32), 8, 64); + } else if (shape === "regular_polygon") { + p.radius_m = clamp(Number(fpPolyRadiusMEl?.value), 0.05, 10); + p.sides = clamp(Math.round(Number(fpPolySidesEl?.value) || 6), 3, 32); + } + return p; +} + +function applyFootprintPreset() { + ensureFootprintSchema(); + const robot = state.layout.robot; + const shape = footprintShapeEl?.value || robot.footprint_shape; + if (shape === "custom") { + setStatus("Chế độ tùy chỉnh — chỉnh đỉnh trên canvas"); + return; + } + readFootprintParamsFromDOM(); + robot.footprint_shape = shape; + const pts = generateFootprintPoints(shape, robot.footprint_params); + if (!pts || pts.length < 3) { + setStatus("Không tạo được footprint"); + return; + } + robot.footprint = pts; + state.selectedFootprintVertex = null; + validateDiff(); + updateFootprintEditHint(); + renderCanvas(); + markLayoutDirty(); + setStatus(`Đã áp dụng footprint: ${footprintShapeLabel(shape)}`); +} + +function footprintShapeLabel(shape) { + const labels = { + rectangle: "Hình chữ nhật", + circle: "Hình tròn", + regular_polygon: "Đa giác đều", + custom: "Tùy chỉnh", + }; + return labels[shape] || shape; +} + +function updateFootprintPresetPanelVisibility() { + const shape = footprintShapeEl?.value || state.layout.robot.footprint_shape || "custom"; + const isCustom = shape === "custom"; + if (footprintPresetPanelEl) footprintPresetPanelEl.classList.toggle("hidden", isCustom); + if (footprintCustomPanelEl) footprintCustomPanelEl.classList.toggle("hidden", !isCustom); + if (fpRectParamsEl) fpRectParamsEl.hidden = shape !== "rectangle"; + if (fpCircleParamsEl) fpCircleParamsEl.hidden = shape !== "circle"; + if (fpPolyParamsEl) fpPolyParamsEl.hidden = shape !== "regular_polygon"; + updateFootprintVertexUI(); +} + +function syncFootprintUIFromState() { + ensureFootprintSchema(); + const robot = state.layout.robot; + const shape = robot.footprint_shape || "custom"; + const p = robot.footprint_params; + if (footprintShapeEl) footprintShapeEl.value = shape; + if (fpLengthMEl) fpLengthMEl.value = Number(p.length_m).toFixed(2); + if (fpWidthMEl) fpWidthMEl.value = Number(p.width_m).toFixed(2); + if (fpRadiusMEl) fpRadiusMEl.value = Number(p.radius_m).toFixed(2); + if (fpCircleSegmentsEl) fpCircleSegmentsEl.value = String(Math.round(p.segments)); + if (fpPolyRadiusMEl) fpPolyRadiusMEl.value = Number(p.radius_m).toFixed(2); + if (fpPolySidesEl) fpPolySidesEl.value = String(Math.round(p.sides)); + updateFootprintPresetPanelVisibility(); + updateFootprintEditHint(); +} + +function updateFootprintEditHint() { + if (!footprintEditHint) return; + if (!state.editFootprint) return; + if (isCustomFootprintShape()) { + footprintEditHint.textContent = + "Click canvas: thêm đỉnh (trên cạnh = chèn giữa) • Kéo đỉnh • Delete / nút Xóa đỉnh • Esc bỏ chọn"; + } else { + footprintEditHint.textContent = + "Chỉnh kích thước ở trên rồi nhấn «Áp dụng hình dạng» — hoặc chuyển sang Tùy chỉnh để kéo từng đỉnh"; + } +} + +function onFootprintShapeChange() { + ensureFootprintSchema(); + const prev = state.layout.robot.footprint_shape || "custom"; + const next = footprintShapeEl.value || "custom"; + state.layout.robot.footprint_shape = next; + if (next !== "custom" && prev === "custom") { + state.layout.robot.footprint_params = inferFootprintParamsFromPoints(state.layout.robot.footprint); + } + syncFootprintUIFromState(); + if (next !== "custom") applyFootprintPreset(); + else { + validateDiff(); + renderCanvas(); + setStatus("Chế độ tùy chỉnh — bật «Sửa footprint» để chỉnh đỉnh"); + } +} + +function initFootprintEvents() { + if (footprintShapeEl && footprintShapeEl.dataset.bound !== "1") { + footprintShapeEl.dataset.bound = "1"; + footprintShapeEl.addEventListener("change", onFootprintShapeChange); + } + if (applyFootprintPresetBtn && applyFootprintPresetBtn.dataset.bound !== "1") { + applyFootprintPresetBtn.dataset.bound = "1"; + applyFootprintPresetBtn.addEventListener("click", () => { + applyFootprintPreset(); + markLayoutDirty(); + persistLayoutDebounced(); + }); + } + if (fpAddVertexBtn && fpAddVertexBtn.dataset.bound !== "1") { + fpAddVertexBtn.dataset.bound = "1"; + fpAddVertexBtn.addEventListener("click", () => { + addFootprintVertexFromUI(); + persistLayoutDebounced(); + }); + } + if (fpRemoveVertexBtn && fpRemoveVertexBtn.dataset.bound !== "1") { + fpRemoveVertexBtn.dataset.bound = "1"; + fpRemoveVertexBtn.addEventListener("click", () => { + if (removeSelectedFootprintVertex()) persistLayoutDebounced(); + }); + } + [ + fpLengthMEl, + fpWidthMEl, + fpRadiusMEl, + fpCircleSegmentsEl, + fpPolyRadiusMEl, + fpPolySidesEl, + ].forEach((node) => { + if (!node || node.dataset.bound === "1") return; + node.dataset.bound = "1"; + node.addEventListener("change", () => { + readFootprintParamsFromDOM(); + if (!isCustomFootprintShape()) applyFootprintPreset(); + }); + }); +} + +function ensureFootprint() { + ensureFootprintSchema(); + if (!Array.isArray(state.layout.robot.footprint) || state.layout.robot.footprint.length < 3) { + state.layout.robot.footprint = [ + { x: 120, y: 80 }, + { x: 120, y: -80 }, + { x: -90, y: -80 }, + { x: -90, y: 80 }, + ]; + if (!state.layout.robot.footprint_shape) state.layout.robot.footprint_shape = "rectangle"; + state.layout.robot.footprint_params = inferFootprintParamsFromPoints(state.layout.robot.footprint); + } +} + +function getFootprintAbsPoints() { + ensureFootprint(); + return state.layout.robot.footprint + .map((p) => robotToAbs(Number(p.x || 0), Number(p.y || 0))) + .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)); +} + +const FOOTPRINT_MIN_VERTICES = 3; +const FOOTPRINT_MAX_VERTICES = 64; + +function hitTestFootprintVertex(x, y, radius = 10) { + radius = radius / Math.max(state.view.scale, 0.12); + const pts = getFootprintAbsPoints(); + const r2 = radius * radius; + for (let i = pts.length - 1; i >= 0; i--) { + const dx = x - pts[i].x; + const dy = y - pts[i].y; + if (dx * dx + dy * dy <= r2) return i; + } + return null; +} + +function distPointToSegment(px, py, x1, y1, x2, y2) { + const dx = x2 - x1; + const dy = y2 - y1; + const len2 = dx * dx + dy * dy; + if (len2 < 1e-6) { + return { dist: Math.hypot(px - x1, py - y1), qx: x1, qy: y1 }; + } + const t = clamp(((px - x1) * dx + (py - y1) * dy) / len2, 0, 1); + const qx = x1 + t * dx; + const qy = y1 + t * dy; + return { dist: Math.hypot(px - qx, py - qy), qx, qy }; +} + +function hitTestFootprintEdge(x, y, threshold = 12) { + threshold = threshold / Math.max(state.view.scale, 0.12); + const pts = getFootprintAbsPoints(); + if (pts.length < 2) return null; + let best = null; + for (let i = 0; i < pts.length; i++) { + const j = (i + 1) % pts.length; + const seg = distPointToSegment(x, y, pts[i].x, pts[i].y, pts[j].x, pts[j].y); + if (seg.dist <= threshold && (!best || seg.dist < best.dist)) { + const rel = absToRobot(seg.qx, seg.qy); + best = { insertAfter: i, rel, dist: seg.dist }; + } + } + return best; +} + +function updateFootprintVertexUI() { + ensureFootprint(); + const fp = state.layout.robot.footprint; + const n = fp.length; + if (fpVertexCountEl) fpVertexCountEl.textContent = String(n); + const sel = state.selectedFootprintVertex; + if (fpSelectedVertexTextEl) { + fpSelectedVertexTextEl.textContent = + sel !== null && sel >= 0 && sel < n ? `Đã chọn: đỉnh #${sel}` : "Chưa chọn đỉnh"; + } + if (fpRemoveVertexBtn) { + fpRemoveVertexBtn.disabled = sel === null || n <= FOOTPRINT_MIN_VERTICES; + } + if (fpAddVertexBtn) fpAddVertexBtn.disabled = n >= FOOTPRINT_MAX_VERTICES; +} + +function insertFootprintVertex(insertAfter, rel) { + ensureFootprint(); + const fp = state.layout.robot.footprint; + if (fp.length >= FOOTPRINT_MAX_VERTICES) { + setStatus(`Tối đa ${FOOTPRINT_MAX_VERTICES} đỉnh`); + return null; + } + const idx = insertAfter + 1; + fp.splice(idx, 0, { x: rel.x, y: rel.y }); + state.layout.robot.footprint_shape = "custom"; + if (footprintShapeEl) footprintShapeEl.value = "custom"; + state.selectedFootprintVertex = idx; + updateFootprintPresetPanelVisibility(); + updateFootprintVertexUI(); + validateDiff(); + renderCanvas(); + return idx; +} + +function addFootprintVertexAt(relX, relY) { + ensureFootprint(); + const fp = state.layout.robot.footprint; + if (fp.length >= FOOTPRINT_MAX_VERTICES) { + setStatus(`Tối đa ${FOOTPRINT_MAX_VERTICES} đỉnh`); + return null; + } + fp.push({ x: relX, y: relY }); + state.layout.robot.footprint_shape = "custom"; + if (footprintShapeEl) footprintShapeEl.value = "custom"; + state.selectedFootprintVertex = fp.length - 1; + updateFootprintPresetPanelVisibility(); + updateFootprintVertexUI(); + validateDiff(); + renderCanvas(); + return fp.length - 1; +} + +function addFootprintVertexFromUI() { + ensureFootprint(); + const fp = state.layout.robot.footprint; + const sel = state.selectedFootprintVertex; + if (sel !== null && sel >= 0 && sel < fp.length) { + const a = fp[sel]; + const b = fp[(sel + 1) % fp.length]; + const rel = { + x: (Number(a.x) + Number(b.x)) / 2, + y: (Number(a.y) + Number(b.y)) / 2, + }; + insertFootprintVertex(sel, rel); + setStatus(`Đã thêm đỉnh giữa #${sel} và #${(sel + 1) % fp.length}`); + return; + } + let cx = 0; + let cy = 0; + fp.forEach((p) => { + cx += Number(p.x); + cy += Number(p.y); + }); + cx /= fp.length || 1; + cy /= fp.length || 1; + const scale = getScaleMPerPx(); + addFootprintVertexAt(cx + 40 * scale, cy); + setStatus("Đã thêm đỉnh mới"); +} + +function addFootprintVertexFromCanvas(absX, absY) { + const edge = hitTestFootprintEdge(absX, absY, 14); + if (edge) { + const idx = insertFootprintVertex(edge.insertAfter, edge.rel); + if (idx !== null) setStatus(`Đã chèn đỉnh #${idx} trên cạnh`); + return; + } + const rel = absToRobot(absX, absY); + const idx = addFootprintVertexAt(rel.x, rel.y); + if (idx !== null) setStatus(`Đã thêm đỉnh #${idx} — click cạnh để chèn giữa 2 đỉnh`); +} + +function removeSelectedFootprintVertex() { + if (state.selectedFootprintVertex === null) { + setStatus("Chọn đỉnh cần xóa (click trên canvas)"); + return false; + } + ensureFootprint(); + const fp = state.layout.robot.footprint; + if (fp.length <= FOOTPRINT_MIN_VERTICES) { + setStatus(`Footprint cần ít nhất ${FOOTPRINT_MIN_VERTICES} đỉnh`); + return false; + } + const removed = state.selectedFootprintVertex; + fp.splice(removed, 1); + state.selectedFootprintVertex = null; + updateFootprintVertexUI(); + validateDiff(); + renderCanvas(); + setStatus(`Đã xóa đỉnh #${removed}`); + return true; +} + +function setLidarListPanelCollapsed(collapsed) { + state.lidarListPanelCollapsed = collapsed; + lidarListCard.classList.toggle("collapsed", collapsed); + lidarListCardToggle.setAttribute("aria-expanded", String(!collapsed)); + try { + localStorage.setItem("lidarListPanelCollapsed", collapsed ? "1" : "0"); + } catch { + /* ignore */ + } +} + +function setRobotModelPanelCollapsed(collapsed) { + state.robotModelPanelCollapsed = collapsed; + robotModelCard.classList.toggle("collapsed", collapsed); + robotModelCardToggle.setAttribute("aria-expanded", String(!collapsed)); + try { + localStorage.setItem("robotModelPanelCollapsed", collapsed ? "1" : "0"); + } catch { + /* ignore */ + } +} + +function toggleLidarItemCollapsed(id) { + state.lidarItemCollapsed[id] = !state.lidarItemCollapsed[id]; + const item = listEl.querySelector(`.item[data-lidar-id="${id}"]`); + if (item) { + item.classList.toggle("collapsed", !!state.lidarItemCollapsed[id]); + const btn = item.querySelector('[data-action="toggle-item"]'); + if (btn) btn.setAttribute("aria-expanded", String(!state.lidarItemCollapsed[id])); + } +} + +let persistLayoutTimer = null; + +function markLayoutDirty() { + state.layoutDirty = true; + updateLayoutActiveHint(); +} + +function clearLayoutDirty() { + state.layoutDirty = false; + updateLayoutActiveHint(); +} + +function updateLayoutActiveHint() { + if (!layoutActiveHintEl) return; + const name = state.activeLayoutName || "—"; + const dirty = state.layoutDirty ? " • chưa lưu" : ""; + layoutActiveHintEl.textContent = `Đang chỉnh: ${name}${dirty}`; +} + +function renderLayoutSelect() { + if (!layoutSelectEl) return; + const options = (state.layoutCatalog || []) + .map( + (p) => + ``, + ) + .join(""); + layoutSelectEl.innerHTML = options || ''; + updateLayoutActiveHint(); +} + +async function persistLayoutNow() { + if (state.activeLayoutId) { + await api(`/api/layouts/${state.activeLayoutId}`, { + method: "PUT", + body: JSON.stringify({ layout: state.layout, lidars: state.lidars }), + }); + } else { + await api("/api/layout", { method: "PUT", body: JSON.stringify(state.layout) }); + } + clearLayoutDirty(); +} + +function persistLayoutDebounced() { + clearTimeout(persistLayoutTimer); + persistLayoutTimer = setTimeout(async () => { + try { + await persistLayoutNow(); + } catch (e) { + setStatus(`Lỗi lưu layout: ${e.message}`); + } + }, 450); +} + +async function saveCurrentLayout() { + if ((state.layout.robot.model || "diff") === "bicycle") applyBicycleFormToState(); + else applyDiffFormToState(); + await persistLayoutNow(); +} + +async function createLayoutFromUI() { + const name = layoutNewNameEl?.value?.trim() || ""; + if (!name) { + setStatus("Nhập tên layout mới"); + return; + } + const clone = !!layoutCloneCurrentEl?.checked; + await api("/api/layouts", { + method: "POST", + body: JSON.stringify({ name, clone }), + }); + if (layoutNewNameEl) layoutNewNameEl.value = ""; + if (layoutCloneCurrentEl) layoutCloneCurrentEl.checked = false; + state.viewInitialized = false; + await loadAll(); + setStatus(`Đã tạo layout «${name}»`); +} + +async function switchToLayout(id) { + if (!id || id === state.activeLayoutId) return; + if (state.layoutDirty) { + const ok = window.confirm( + "Layout hiện tại có thay đổi chưa lưu. Chuyển layout sẽ không lưu các thay đổi đó. Tiếp tục?", + ); + if (!ok) { + renderLayoutSelect(); + return; + } + } + await api(`/api/layouts/${id}/activate`, { method: "POST" }); + state.viewInitialized = false; + await loadAll(); + setStatus("Đã chuyển layout"); +} + +async function deleteActiveLayoutFromUI() { + if (!state.activeLayoutId) return; + if ((state.layoutCatalog || []).length <= 1) { + setStatus("Không thể xóa layout cuối cùng"); + return; + } + const name = state.activeLayoutName || state.activeLayoutId; + if (!window.confirm(`Xóa layout «${name}»? Hành động không hoàn tác.`)) return; + await api(`/api/layouts/${state.activeLayoutId}`, { method: "DELETE" }); + state.viewInitialized = false; + await loadAll(); + setStatus(`Đã xóa layout «${name}»`); +} + +function initLayoutManagerEvents() { + if (layoutSelectEl && layoutSelectEl.dataset.bound !== "1") { + layoutSelectEl.dataset.bound = "1"; + layoutSelectEl.addEventListener("change", () => { + void switchToLayout(layoutSelectEl.value); + }); + } + if (layoutCreateBtn && layoutCreateBtn.dataset.bound !== "1") { + layoutCreateBtn.dataset.bound = "1"; + layoutCreateBtn.addEventListener("click", () => { + void createLayoutFromUI().catch((e) => setStatus(`Lỗi: ${e.message}`)); + }); + } + if (layoutDeleteBtn && layoutDeleteBtn.dataset.bound !== "1") { + layoutDeleteBtn.dataset.bound = "1"; + layoutDeleteBtn.addEventListener("click", () => { + void deleteActiveLayoutFromUI().catch((e) => setStatus(`Lỗi: ${e.message}`)); + }); + } +} + +/** Sync list row meta + X/Y/θ inputs from robot-frame pose (e.g. after canvas drag). */ +function updateLidarItemPoseUI(id) { + const item = listEl.querySelector(`.item[data-lidar-id="${id}"]`); + if (!item) return; + const l = state.lidars.find((x) => x.id === id); + const pose = state.layout.lidarPoses[id]; + const meta = item.querySelector(".itemMeta"); + if (!pose) { + if (meta) meta.textContent = l ? `${l.ip}:${l.port} • chưa đặt pose` : "chưa đặt pose"; + return; + } + + const x = Number(pose.x || 0); + const y = Number(pose.y || 0); + const th = Number(pose.theta_deg || 0); + const posTxt = `theo tâm robot: x=${x.toFixed(0)}, y=${y.toFixed(0)}, θ=${th.toFixed(0)}°`; + if (meta && l) meta.textContent = `${l.ip}:${l.port} • ${posTxt}`; + else if (meta) meta.textContent = posTxt; + + const active = document.activeElement; + const xIn = item.querySelector('input.poseInput[data-action="x"]'); + const yIn = item.querySelector('input.poseInput[data-action="y"]'); + const tIn = item.querySelector('input.poseInput[data-action="theta"]'); + if (xIn && active !== xIn) xIn.value = x.toFixed(0); + if (yIn && active !== yIn) yIn.value = y.toFixed(0); + if (tIn && active !== tIn) tIn.value = String(Math.round(th)); +} + +function refreshLidarSelectionUI() { + listEl.querySelectorAll(".item[data-lidar-id]").forEach((item) => { + const id = item.dataset.lidarId; + const l = state.lidars.find((x) => x.id === id); + if (!l) return; + const nameEl = item.querySelector(".itemName"); + if (!nameEl) return; + const selected = state.selectedId === id ? `selected` : ""; + nameEl.innerHTML = `${escapeHtml(l.name)} ${selected}`; + }); +} + +function onLidarPoseInputChange(id, action, value) { + ensureDefaultPose(id, 0); + const pose = state.layout.lidarPoses[id]; + if (!pose) return; + + if (action === "theta") { + pose.theta_deg = clamp(Number(value), -180, 180); + } else if (action === "x") { + pose.x = Number(value); + } else if (action === "y") { + pose.y = Number(value); + } + + if (state.selectedId === id) setSelectedRelText(); + updateLidarItemPoseUI(id); + renderCanvas(); + persistLayoutDebounced(); +} + +function initLidarListEvents() { + listEl.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(); + toggleLidarItemCollapsed(id); + return; + } + if (action === "select") { + state.selectedId = id; + selectedText.textContent = id; + setSelectedRelText(); + refreshLidarSelectionUI(); + renderCanvas(); + return; + } + if (action === "delete") { + if (!confirm("Xóa LiDAR này?")) return; + try { + await api(`/api/lidars/${id}`, { method: "DELETE" }); + 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"; + } + setSelectedRelText(); + renderList(); + renderCanvas(); + setStatus("Đã xóa LiDAR"); + } catch (e) { + setStatus(`Lỗi: ${e.message}`); + } + } + }); + + listEl.addEventListener("change", (evt) => { + const input = evt.target.closest("input.poseInput[data-action][data-id]"); + if (!input) return; + onLidarPoseInputChange(input.dataset.id, input.dataset.action, input.value); + if (input.dataset.action === "theta") { + input.value = String(state.layout.lidarPoses[input.dataset.id].theta_deg); + } + }); +} + +function setEditFootprintMode(on) { + state.editFootprint = on; + editFootprintBtn.classList.toggle("active", on); + canvas.classList.toggle("edit-footprint", on); + if (footprintEditHint) footprintEditHint.hidden = !on; + if (on) { + updateFootprintEditHint(); + updateFootprintVertexUI(); + } + if (!on) { + state.selectedFootprintVertex = null; + state.draggingFootprint = null; + updateFootprintVertexUI(); + } + renderCanvas(); +} + +function setSelectedRelText() { + if (!state.selectedId) { + selectedRelText.textContent = "—"; + return; + } + const pose = getLidarPoseAbs(state.selectedId); + if (!pose) { + selectedRelText.textContent = "—"; + return; + } + const th = Number(pose.theta_deg || 0); + selectedRelText.textContent = `(x=${Number(pose.x || 0).toFixed(0)}, y=${Number(pose.y || 0).toFixed(0)}, θ=${th.toFixed(0)}°)`; +} + +function renderList() { + if (!state.lidars.length) { + listEl.innerHTML = `
Chưa có LiDAR
Hãy thêm LiDAR ở form phía trên.
`; + return; + } + + listEl.innerHTML = state.lidars + .map((l, idx) => { + ensureDefaultPose(l.id, idx); + const pose = getLidarPoseAbs(l.id); + let posTxt = "chưa đặt pose"; + let xRobot = 0; + let yRobot = 0; + let thetaDeg = 0; + if (pose) { + xRobot = Number(pose.x || 0); + yRobot = Number(pose.y || 0); + thetaDeg = Number(pose.theta_deg || 0); + posTxt = `theo tâm robot: x=${xRobot.toFixed(0)}, y=${yRobot.toFixed(0)}, θ=${thetaDeg.toFixed(0)}°`; + } + const selected = state.selectedId === l.id ? `selected` : ""; + const itemCollapsed = !!state.lidarItemCollapsed[l.id]; + return ` +
+
+
+ +
+
${escapeHtml(l.name)} ${selected}
+
${escapeHtml(l.ip)}:${l.port} • ${posTxt}
+
+
+
+ + +
+
+
+
+
+ X + +
+
+ Y + +
+
+ θ + +
+
+
+
`; + }) + .join(""); +} + +let lastCanvasW = 0; +let lastCanvasH = 0; + +function syncCanvasSize() { + const wrap = canvas.parentElement; + if (!wrap) return false; + const w = Math.max(1, Math.floor(wrap.clientWidth)); + const h = Math.max(1, Math.floor(wrap.clientHeight)); + if (w === lastCanvasW && h === lastCanvasH) return false; + lastCanvasW = w; + lastCanvasH = h; + canvas.width = w; + canvas.height = h; + return true; +} + +function fitViewToWorld() { + syncCanvasSize(); + const worldW = state.layout.map.width; + const worldH = state.layout.map.height; + const pad = 36; + const scale = Math.min( + (canvas.width - pad * 2) / worldW, + (canvas.height - pad * 2) / worldH, + 4, + ); + state.view.scale = Math.max(0.15, scale); + state.view.panX = (canvas.width - worldW * state.view.scale) / 2; + state.view.panY = (canvas.height - worldH * state.view.scale) / 2; +} + +function canvasScreenPoint(evt) { + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + return { + x: (evt.clientX - rect.left) * scaleX, + y: (evt.clientY - rect.top) * scaleY, + }; +} + +function screenToWorld(sx, sy) { + return { + x: (sx - state.view.panX) / state.view.scale, + y: (sy - state.view.panY) / state.view.scale, + }; +} + +function canvasPoint(evt) { + const s = canvasScreenPoint(evt); + return screenToWorld(s.x, s.y); +} + +function zoomAtScreen(sx, sy, factor) { + const before = screenToWorld(sx, sy); + const next = clamp(state.view.scale * factor, 0.12, 12); + state.view.scale = next; + state.view.panX = sx - before.x * next; + state.view.panY = sy - before.y * next; +} + +function getVisibleWorldBounds() { + const pts = [ + screenToWorld(0, 0), + screenToWorld(canvas.width, 0), + screenToWorld(canvas.width, canvas.height), + screenToWorld(0, canvas.height), + ]; + return { + minX: Math.min(...pts.map((p) => p.x)), + maxX: Math.max(...pts.map((p) => p.x)), + minY: Math.min(...pts.map((p) => p.y)), + maxY: Math.max(...pts.map((p) => p.y)), + }; +} + +/** Grid step in world px so lines stay ~40–56 px apart on screen when zooming. */ +function getGridStep() { + const targetScreenPx = 48; + const raw = targetScreenPx / Math.max(state.view.scale, 0.01); + const pow = 10 ** Math.floor(Math.log10(raw)); + const norm = raw / pow; + let nice = 10; + if (norm <= 1) nice = 1; + else if (norm <= 2) nice = 2; + else if (norm <= 5) nice = 5; + return nice * pow; +} + +function drawGrid() { + const w = state.layout.map.width; + const h = state.layout.map.height; + const vis = getVisibleWorldBounds(); + const pad = getGridStep() * 2; + const minX = vis.minX - pad; + const maxX = vis.maxX + pad; + const minY = vis.minY - pad; + const maxY = vis.maxY + pad; + + ctx.fillStyle = "rgba(0,0,0,0.25)"; + ctx.fillRect(minX, minY, maxX - minX, maxY - minY); + + const major = getGridStep(); + const minor = major / 5; + const lineW = 1 / Math.max(state.view.scale, 0.01); + + function strokeLines(step, alpha) { + ctx.strokeStyle = `rgba(255,255,255,${alpha})`; + ctx.lineWidth = lineW; + const x0 = Math.floor(minX / step) * step; + const y0 = Math.floor(minY / step) * step; + for (let x = x0; x <= maxX; x += step) { + ctx.beginPath(); + ctx.moveTo(x, minY); + ctx.lineTo(x, maxY); + ctx.stroke(); + } + for (let y = y0; y <= maxY; y += step) { + ctx.beginPath(); + ctx.moveTo(minX, y); + ctx.lineTo(maxX, y); + ctx.stroke(); + } + } + + if (minor >= 2 && state.view.scale >= 0.35) strokeLines(minor, 0.04); + strokeLines(major, 0.1); + + ctx.strokeStyle = "rgba(255,255,255,0.22)"; + ctx.lineWidth = lineW * 1.5; + ctx.strokeRect(0, 0, w, h); +} + +function renderCanvas() { + syncCanvasSize(); + const w = state.layout.map.width; + const h = state.layout.map.height; + + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.fillStyle = "#0b1220"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.save(); + ctx.setTransform(state.view.scale, 0, 0, state.view.scale, state.view.panX, state.view.panY); + + drawGrid(); + + const r = state.layout.robot; + robotCenterText.textContent = `(${r.x.toFixed(0)}, ${r.y.toFixed(0)})`; + + // ROS-like axes icon at robot center (x red, y green), rotated by yaw_deg. + // Canvas coordinates: +x right, +y down. + // Interpret yaw_deg as ROS yaw (CCW around +Z). Convert to canvas angle by negating. + const yaw = yawCanvasRad(); + const axisLen = 90; + const headLen = 12; + + function arrow(fromX, fromY, toX, toY, color) { + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(fromX, fromY); + ctx.lineTo(toX, toY); + ctx.stroke(); + + const ang = Math.atan2(toY - fromY, toX - fromX); + ctx.beginPath(); + ctx.moveTo(toX, toY); + ctx.lineTo(toX - headLen * Math.cos(ang - Math.PI / 7), toY - headLen * Math.sin(ang - Math.PI / 7)); + ctx.lineTo(toX - headLen * Math.cos(ang + Math.PI / 7), toY - headLen * Math.sin(ang + Math.PI / 7)); + ctx.closePath(); + ctx.fill(); + } + + // Unit vectors for robot frame in canvas space (ROS): x forward, y left. + const ux = Math.cos(yaw); + const uy = Math.sin(yaw); + const vx = Math.cos(yaw - Math.PI / 2); + const vy = Math.sin(yaw - Math.PI / 2); + + const xEnd = { x: r.x + ux * axisLen, y: r.y + uy * axisLen }; + const yEnd = { x: r.x + vx * axisLen, y: r.y + vy * axisLen }; + + arrow(r.x, r.y, xEnd.x, xEnd.y, "rgba(255, 80, 80, 0.95)"); // X (red) + arrow(r.x, r.y, yEnd.x, yEnd.y, "rgba(110, 255, 140, 0.95)"); // Y (green) + + ctx.font = "12px ui-sans-serif, system-ui"; + ctx.fillStyle = "rgba(255, 140, 140, 0.95)"; + ctx.fillText("x", xEnd.x + 6, xEnd.y + 4); + ctx.fillStyle = "rgba(160, 255, 190, 0.95)"; + ctx.fillText("y", yEnd.x + 6, yEnd.y + 4); + + // robot body (circle) + ctx.fillStyle = "rgba(128,237,153,0.18)"; + ctx.strokeStyle = "rgba(128,237,153,0.55)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(r.x, r.y, 38, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // Robot footprint (ROS-like polygon), points are in robot frame (x forward, y left) + const pts = getFootprintAbsPoints(); + if (pts.length >= 3) { + const editing = state.editFootprint; + ctx.fillStyle = editing ? "rgba(76, 201, 240, 0.14)" : "rgba(76, 201, 240, 0.08)"; + ctx.strokeStyle = editing ? "rgba(76, 201, 240, 0.85)" : "rgba(76, 201, 240, 0.55)"; + ctx.lineWidth = editing ? 2.5 : 2; + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + if (editing && isCustomFootprintShape()) { + const fp = state.layout.robot.footprint; + pts.forEach((pt, i) => { + const selected = state.selectedFootprintVertex === i; + ctx.fillStyle = selected ? "rgba(255, 200, 80, 0.95)" : "rgba(76, 201, 240, 0.95)"; + ctx.strokeStyle = "rgba(255,255,255,0.9)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(pt.x, pt.y, selected ? 9 : 7, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "rgba(232,238,252,0.85)"; + ctx.font = "10px ui-sans-serif, system-ui"; + const rel = fp[i]; + ctx.fillText(`${i}`, pt.x + 10, pt.y - 6); + ctx.fillText(`(${Number(rel.x).toFixed(0)},${Number(rel.y).toFixed(0)})`, pt.x + 10, pt.y + 6); + }); + } + } + + // Differential drive wheels (when model = diff) + if ((state.layout.robot.model || "diff") === "diff") { + const dPx = getWheelDiameterPx(); + const wheelR = Math.max(6, dPx / 2); + const wheelL = Math.max(18, dPx * 0.65); + const wheelW = Math.max(8, dPx * 0.22); + const wheels = getDiffWheelsForDraw(); + const scale = state.layout.robot.diff.display.scale_m_per_px; + const centers = wheels.map((w) => { + const yRobot = Number(w.y_m ?? 0); + const yPx = yRobot / scale; + return { w, abs: robotToAbs(0, yPx) }; + }); + + function roundRect(ctx2, x, y, w2, h2, r2) { + const rr = Math.min(r2, w2 / 2, h2 / 2); + ctx2.beginPath(); + ctx2.moveTo(x + rr, y); + ctx2.arcTo(x + w2, y, x + w2, y + h2, rr); + ctx2.arcTo(x + w2, y + h2, x, y + h2, rr); + ctx2.arcTo(x, y + h2, x, y, rr); + ctx2.arcTo(x, y, x + w2, y, rr); + ctx2.closePath(); + } + + function drawWheel(center, w) { + const isLeft = w.side === "left" || w.id === "left"; + const vendorShort = (w.motor?.vendor || "?").slice(0, 2).toUpperCase(); + ctx.save(); + ctx.translate(center.x, center.y); + ctx.rotate(yaw); + + ctx.fillStyle = "rgba(255,255,255,0.10)"; + ctx.strokeStyle = "rgba(255,255,255,0.28)"; + ctx.lineWidth = 2; + roundRect(ctx, -wheelL / 2, -wheelW / 2, wheelL, wheelW, 6); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = "rgba(76,201,240,0.18)"; + ctx.strokeStyle = "rgba(76,201,240,0.35)"; + ctx.beginPath(); + ctx.arc(0, 0, Math.min(10, wheelW / 2 - 2), 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = "rgba(232,238,252,0.70)"; + ctx.font = "10px ui-sans-serif, system-ui"; + ctx.fillText(isLeft ? `L:${vendorShort}` : `R:${vendorShort}`, wheelL / 2 + 6, 3); + + ctx.restore(); + } + + if (centers.length >= 2) { + ctx.strokeStyle = "rgba(160, 255, 190, 0.35)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(centers[0].abs.x, centers[0].abs.y); + for (let i = 1; i < centers.length; i++) ctx.lineTo(centers[i].abs.x, centers[i].abs.y); + ctx.stroke(); + } + centers.forEach(({ w, abs }) => drawWheel(abs, w)); + } + + // Kinematic bicycle (rear axle reference, front steer) + if ((state.layout.robot.model || "diff") === "bicycle") { + ensureBicycleSchema(); + const b = state.layout.robot.bicycle; + const Lpx = getBicycleWheelbasePx(); + const dPx = getBicycleWheelDiameterPx(); + const wheelL = Math.max(18, dPx * 0.65); + const wheelW = Math.max(8, dPx * 0.22); + const deltaDeg = Number(b.steer.preview_deg) || 0; + const delta = (deltaDeg * Math.PI) / 180; + + const rearAbs = robotToAbs(0, 0); + const frontAbs = robotToAbs(Lpx, 0); + + function roundRectLocal(ctx2, x, y, w2, h2, r2) { + const rr = Math.min(r2, w2 / 2, h2 / 2); + ctx2.beginPath(); + ctx2.moveTo(x + rr, y); + ctx2.arcTo(x + w2, y, x + w2, y + h2, rr); + ctx2.arcTo(x + w2, y + h2, x, y + h2, rr); + ctx2.arcTo(x, y + h2, x, y, rr); + ctx2.arcTo(x, y, x + w2, y, rr); + ctx2.closePath(); + } + + function drawBicycleWheel(center, steerRad, label) { + ctx.save(); + ctx.translate(center.x, center.y); + ctx.rotate(yaw + (steerRad || 0)); + ctx.fillStyle = "rgba(255,255,255,0.12)"; + ctx.strokeStyle = "rgba(255,255,255,0.32)"; + ctx.lineWidth = 2; + roundRectLocal(ctx, -wheelL / 2, -wheelW / 2, wheelL, wheelW, 6); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "rgba(232,238,252,0.75)"; + ctx.font = "10px ui-sans-serif, system-ui"; + ctx.fillText(label, wheelL / 2 + 6, 3); + ctx.restore(); + } + + // Wheelbase L (chassis line) + ctx.strokeStyle = "rgba(100, 160, 255, 0.75)"; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(rearAbs.x, rearAbs.y); + ctx.lineTo(frontAbs.x, frontAbs.y); + ctx.stroke(); + + // ICR preview: tan(δ) = L/R → R = L/tan(δ), ICR at (0, R) in robot frame (y left) + if (Math.abs(deltaDeg) > 0.5) { + const Rpx = Lpx / Math.tan(Math.abs(delta)); + const icrY = deltaDeg > 0 ? Rpx : -Rpx; + const icrAbs = robotToAbs(0, icrY); + ctx.strokeStyle = "rgba(255, 100, 100, 0.45)"; + ctx.lineWidth = 1.5; + ctx.setLineDash([6, 6]); + ctx.beginPath(); + ctx.moveTo(rearAbs.x, rearAbs.y); + ctx.lineTo(icrAbs.x, icrAbs.y); + ctx.moveTo(frontAbs.x, frontAbs.y); + ctx.lineTo(icrAbs.x, icrAbs.y); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = "rgba(255, 90, 90, 0.95)"; + ctx.beginPath(); + ctx.arc(icrAbs.x, icrAbs.y, 5, 0, Math.PI * 2); + ctx.fill(); + ctx.font = "11px ui-sans-serif, system-ui"; + ctx.fillStyle = "rgba(255, 140, 140, 0.95)"; + ctx.fillText("ICR", icrAbs.x + 8, icrAbs.y - 4); + ctx.fillStyle = "rgba(232,238,252,0.65)"; + ctx.font = "10px ui-sans-serif, system-ui"; + ctx.fillText(`δ=${deltaDeg.toFixed(0)}°`, frontAbs.x + 10, frontAbs.y - 10); + } + + drawBicycleWheel(rearAbs, 0, "rear"); + drawBicycleWheel(frontAbs, delta, "steer"); + + // Rear axle marker (reference point) + ctx.fillStyle = "rgba(100, 160, 255, 0.9)"; + ctx.beginPath(); + ctx.arc(rearAbs.x, rearAbs.y, 4, 0, Math.PI * 2); + ctx.fill(); + } + + // draw lidars + const iconR = 14; + const lidarAxisLen = 32; + const lidarHeadLen = 10; + state.lidars.forEach((l, idx) => { + ensureDefaultPose(l.id, idx); + const p = getLidarPoseAbs(l.id); + const isSelected = state.selectedId === l.id; + const absX = p.absX; + const absY = p.absY; + // theta_deg is also ROS-style (CCW around +Z). Convert to canvas angle by negating. + const lidarTheta = (-(Number(p.theta_deg || 0) * Math.PI) / 180); + const yawCanvas = yaw + lidarTheta; + + ctx.fillStyle = isSelected ? "rgba(76,201,240,0.65)" : "rgba(255,255,255,0.20)"; + ctx.strokeStyle = isSelected ? "rgba(76,201,240,0.95)" : "rgba(255,255,255,0.30)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(absX, absY, iconR, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // LiDAR axes icon (same style as robot center), rotated by (robot yaw + lidar theta) + const lux = Math.cos(yawCanvas); + const luy = Math.sin(yawCanvas); + const lvx = Math.cos(yawCanvas - Math.PI / 2); + const lvy = Math.sin(yawCanvas - Math.PI / 2); + const lxEnd = { x: absX + lux * lidarAxisLen, y: absY + luy * lidarAxisLen }; + const lyEnd = { x: absX + lvx * lidarAxisLen, y: absY + lvy * lidarAxisLen }; + + // Temporarily override arrowhead size for lidar + const savedHeadLen = headLen; + // Reuse arrow() but with local head length by inlining a small arrowhead draw + function arrowSmall(fromX, fromY, toX, toY, color) { + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.moveTo(fromX, fromY); + ctx.lineTo(toX, toY); + ctx.stroke(); + + const ang = Math.atan2(toY - fromY, toX - fromX); + ctx.beginPath(); + ctx.moveTo(toX, toY); + ctx.lineTo(toX - lidarHeadLen * Math.cos(ang - Math.PI / 7), toY - lidarHeadLen * Math.sin(ang - Math.PI / 7)); + ctx.lineTo(toX - lidarHeadLen * Math.cos(ang + Math.PI / 7), toY - lidarHeadLen * Math.sin(ang + Math.PI / 7)); + ctx.closePath(); + ctx.fill(); + } + + arrowSmall(absX, absY, lxEnd.x, lxEnd.y, "rgba(255, 80, 80, 0.95)"); // x (red) + arrowSmall(absX, absY, lyEnd.x, lyEnd.y, "rgba(110, 255, 140, 0.95)"); // y (green) + + ctx.fillStyle = "rgba(232,238,252,0.92)"; + ctx.font = "12px ui-sans-serif, system-ui"; + ctx.fillText(l.name, absX + iconR + 8, absY + 4); + + // Relative coordinate label (to robot center) + ctx.fillStyle = isSelected ? "rgba(76,201,240,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)}, θ=${Number(p.theta_deg || 0).toFixed(0)}°)`, absX + iconR + 8, absY + 18); + }); + + ctx.restore(); +} + +function hitTest(x, y) { + const iconR = 16 / Math.max(state.view.scale, 0.12); + // search top-most: reverse order + 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; + } + return null; +} + +canvas.addEventListener("wheel", (evt) => { + evt.preventDefault(); + const s = canvasScreenPoint(evt); + const factor = evt.deltaY < 0 ? 1.12 : 1 / 1.12; + zoomAtScreen(s.x, s.y, factor); + renderCanvas(); +}, { passive: false }); + +canvas.addEventListener("mousedown", (evt) => { + if (evt.button !== 0) return; + const p = canvasPoint(evt); + + if (evt.shiftKey) { + if (state.editFootprint && isCustomFootprintShape()) { + const vIdx = hitTestFootprintVertex(p.x, p.y, 12); + if (vIdx !== null) { + state.selectedFootprintVertex = vIdx; + const pts = getFootprintAbsPoints(); + state.draggingFootprint = { index: vIdx, dx: p.x - pts[vIdx].x, dy: p.y - pts[vIdx].y }; + setStatus(`Đỉnh #${vIdx} — kéo di chuyển, Delete hoặc «Xóa đỉnh»`); + updateFootprintVertexUI(); + renderCanvas(); + return; + } + } + const s = canvasScreenPoint(evt); + state.panning = { + startSx: s.x, + startSy: s.y, + startPanX: state.view.panX, + startPanY: state.view.panY, + moved: false, + }; + state.pendingFootprintClick = + state.editFootprint && isCustomFootprintShape() ? { sx: s.x, sy: s.y } : null; + canvasWrap.classList.add("panning"); + evt.preventDefault(); + return; + } + + if (state.editFootprint && isCustomFootprintShape()) { + const vIdx = hitTestFootprintVertex(p.x, p.y, 12); + if (vIdx !== null) { + state.selectedFootprintVertex = vIdx; + const pts = getFootprintAbsPoints(); + state.draggingFootprint = { index: vIdx, dx: p.x - pts[vIdx].x, dy: p.y - pts[vIdx].y }; + setStatus(`Đỉnh #${vIdx} — kéo di chuyển, Delete hoặc «Xóa đỉnh»`); + updateFootprintVertexUI(); + renderCanvas(); + return; + } + addFootprintVertexFromCanvas(p.x, p.y); + persistLayoutDebounced(); + return; + } + if (state.editFootprint && !isCustomFootprintShape()) { + setStatus("Chọn «Tùy chỉnh» hoặc chỉnh thông số + Áp dụng hình dạng"); + 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); + renderCanvas(); +}); + +window.addEventListener("mousemove", (evt) => { + if (state.panning) { + const s = canvasScreenPoint(evt); + const dx = s.x - state.panning.startSx; + const dy = s.y - state.panning.startSy; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { + state.panning.moved = true; + state.pendingFootprintClick = null; + } + state.view.panX = state.panning.startPanX + dx; + state.view.panY = state.panning.startPanY + dy; + renderCanvas(); + return; + } + + const p = canvasPoint(evt); + + if (state.draggingFootprint && isCustomFootprintShape()) { + const idx = state.draggingFootprint.index; + const nx = p.x - state.draggingFootprint.dx; + const ny = p.y - state.draggingFootprint.dy; + const rel = absToRobot(nx, ny); + ensureFootprint(); + state.layout.robot.footprint[idx] = { x: rel.x, y: rel.y }; + state.layout.robot.footprint_shape = "custom"; + if (footprintShapeEl) footprintShapeEl.value = "custom"; + updateFootprintPresetPanelVisibility(); + renderCanvas(); + return; + } + + if (!state.dragging) return; + 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); + renderCanvas(); +}); + +window.addEventListener("mouseup", (evt) => { + const draggedLidarId = state.dragging?.id ?? null; + + if (state.panning) { + if ( + state.pendingFootprintClick && + !state.panning.moved && + state.editFootprint && + isCustomFootprintShape() + ) { + const s = canvasScreenPoint(evt); + const p = screenToWorld(s.x, s.y); + ensureFootprint(); + const rel = absToRobot(p.x, p.y); + state.layout.robot.footprint.push({ x: rel.x, y: rel.y }); + state.selectedFootprintVertex = state.layout.robot.footprint.length - 1; + setStatus("Đã thêm đỉnh footprint (Shift+click)"); + renderCanvas(); + } + state.panning = null; + state.pendingFootprintClick = null; + canvasWrap.classList.remove("panning"); + return; + } + const hadFootprintDrag = state.draggingFootprint !== null; + state.dragging = null; + state.draggingFootprint = null; + + if (draggedLidarId) { + updateLidarItemPoseUI(draggedLidarId); + persistLayoutDebounced(); + } else if (hadFootprintDrag) { + persistLayoutDebounced(); + } +}); + +window.addEventListener("keydown", (evt) => { + if (!state.editFootprint || !isCustomFootprintShape()) return; + if (evt.key === "Escape") { + state.selectedFootprintVertex = null; + updateFootprintVertexUI(); + renderCanvas(); + setStatus("Đã bỏ chọn đỉnh"); + return; + } + if (evt.key !== "Delete" && evt.key !== "Backspace") return; + if (state.selectedFootprintVertex === null) return; + evt.preventDefault(); + if (removeSelectedFootprintVertex()) persistLayoutDebounced(); +}); + +function clamp(v, a, b) { + return Math.max(a, Math.min(b, v)); +} + +function escapeHtml(s) { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +let loadAllInFlight = null; + +function reconcileLidarPoses() { + if (!state.layout.lidarPoses) state.layout.lidarPoses = {}; + const valid = new Set(state.lidars.map((l) => l.id)); + Object.keys(state.layout.lidarPoses).forEach((id) => { + if (!valid.has(id)) delete state.layout.lidarPoses[id]; + }); +} + +async function loadAll() { + if (loadAllInFlight) return loadAllInFlight; + + loadAllInFlight = (async () => { + const st = await api("/api/state"); + state.activeLayoutId = st.active_layout_id || null; + state.activeLayoutName = st.active_layout_name || ""; + state.layoutCatalog = st.layouts || []; + state.lidars = st.lidars || []; + state.layout = st.layout || state.layout; + clearLayoutDirty(); + renderLayoutSelect(); + state.layout.map = state.layout.map || { width: 800, height: 600 }; + state.layout.robot = state.layout.robot || { x: 400, y: 300, yaw_deg: 0, model: "diff" }; + if (!state.layout.robot.model) state.layout.robot.model = "diff"; + ensureDiffSchema(); + ensureFootprint(); + // migrate old schema (lidarPositions: {id:{x,y}}) -> lidarPoses: {id:{x,y,theta_deg}} + if (!state.layout.lidarPoses) state.layout.lidarPoses = {}; + if (state.layout.lidarPositions && typeof state.layout.lidarPositions === "object") { + Object.entries(state.layout.lidarPositions).forEach(([id, p]) => { + if (!state.layout.lidarPoses[id] && p && typeof p === "object") { + state.layout.lidarPoses[id] = { x: Number(p.x), y: Number(p.y), theta_deg: 0 }; + } + }); + delete state.layout.lidarPositions; + } + state.layout.lidarPoses = state.layout.lidarPoses || {}; + // migrate from older versions where lidarPoses stored ABS canvas x/y + if (!state.layout.lidarPosesFrame) { + Object.entries(state.layout.lidarPoses).forEach(([id, p]) => { + if (!p || typeof p !== "object") return; + const rel = absToRobot(Number(p.x || 0), Number(p.y || 0)); + p.x = rel.x; + p.y = rel.y; + if (p.theta_deg === undefined) p.theta_deg = 0; + }); + state.layout.lidarPosesFrame = "robot"; + } + if (state.layout.lidarPosesFrame !== "robot") state.layout.lidarPosesFrame = "robot"; + + reconcileLidarPoses(); + + let addedDefaultPoses = false; + state.lidars.forEach((l, idx) => { + if (!state.layout.lidarPoses[l.id]) { + ensureDefaultPose(l.id, idx); + addedDefaultPoses = true; + } + }); + if (addedDefaultPoses) await persistLayoutNow(); + + syncDiffFormFromState(); + syncFootprintUIFromState(); + + if (state.selectedId && !state.lidars.find((l) => l.id === state.selectedId)) { + state.selectedId = null; + selectedText.textContent = "none"; + } + setSelectedRelText(); + renderList(); + if (!state.viewInitialized) { + fitViewToWorld(); + state.viewInitialized = true; + } + renderCanvas(); + })(); + + try { + return await loadAllInFlight; + } finally { + loadAllInFlight = null; + } +} + +el("refreshBtn").addEventListener("click", async () => { + try { + state.viewInitialized = false; + await loadAll(); + setStatus("Đã tải lại"); + } catch (e) { + setStatus(`Lỗi: ${e.message}`); + } +}); + +let lidarFormBusy = false; + +function normalizeLidarFields(name, ip, port) { + return { + name: String(name || "").trim(), + ip: String(ip || "").trim(), + port: Number(port), + }; +} + +function findDuplicateLidar(name, ip, port, excludeId = null) { + const p = normalizeLidarFields(name, ip, port); + if (!p.name || !p.ip || !Number.isFinite(p.port)) return null; + return ( + state.lidars.find( + (l) => + l.id !== excludeId && + String(l.name || "").trim() === p.name && + String(l.ip || "").trim() === p.ip && + Number(l.port) === p.port, + ) || null + ); +} + +function setLidarFormHint(msg) { + if (!lidarFormHintEl) return; + if (!msg) { + lidarFormHintEl.hidden = true; + lidarFormHintEl.textContent = ""; + return; + } + lidarFormHintEl.hidden = false; + lidarFormHintEl.textContent = msg; +} + +async function submitAddLidar() { + if (lidarFormBusy) return; + lidarFormBusy = true; + + const addBtn = el("addLidarBtn"); + if (addBtn) addBtn.disabled = true; + + const payload = normalizeLidarFields(el("name").value, el("ip").value, el("port").value); + if (!payload.name || !payload.ip) { + setLidarFormHint("Nhập đủ tên và IP."); + setStatus("Nhập đủ tên và IP"); + lidarFormBusy = false; + if (addBtn) addBtn.disabled = false; + return; + } + if (payload.port < 1 || payload.port > 65535) { + setLidarFormHint("Port phải từ 1 đến 65535."); + setStatus("Port không hợp lệ"); + lidarFormBusy = false; + if (addBtn) addBtn.disabled = false; + return; + } + + const dup = findDuplicateLidar(payload.name, payload.ip, payload.port); + if (dup) { + const msg = + `LiDAR trùng (tên, IP, port): "${dup.name}" ${dup.ip}:${dup.port} — không thêm bản ghi mới.`; + setLidarFormHint(msg); + setStatus(msg); + lidarFormBusy = false; + if (addBtn) addBtn.disabled = false; + return; + } + + setLidarFormHint(""); + + try { + const created = await api("/api/lidars", { method: "POST", body: JSON.stringify(payload) }); + state.lidars.push(created); + ensureDefaultPose(created.id, state.lidars.length - 1); + reconcileLidarPoses(); + await persistLayoutNow(); + el("name").value = ""; + el("ip").value = ""; + renderList(); + renderCanvas(); + setStatus("Đã thêm LiDAR"); + } catch (e) { + setStatus(`Lỗi: ${e.message}`); + } finally { + lidarFormBusy = false; + if (addBtn) addBtn.disabled = false; + } +} + +function initLidarForm() { + const form = el("lidarForm"); + if (form.dataset.bound === "1") return; + form.dataset.bound = "1"; + + form.addEventListener("submit", (evt) => { + evt.preventDefault(); + void submitAddLidar(); + }); + el("addLidarBtn").addEventListener("click", (evt) => { + evt.preventDefault(); + void submitAddLidar(); + }); + + ["name", "ip", "port"].forEach((id) => { + el(id).addEventListener("input", () => setLidarFormHint("")); + }); +} + +robotModelEl.addEventListener("change", () => { + const m = robotModelEl.value || "diff"; + state.layout.robot.model = m; + if (m === "bicycle") applyBicycleFormToState(); + else applyDiffFormToState(); + syncDiffFormFromState(); + markLayoutDirty(); + renderCanvas(); +}); + +[ + wheelSeparationMEl, + wheelRadiusMEl, + scaleMPerPxEl, + wheelSeparationMultEl, + wheelRadiusMultEl, + cmdVelTimeoutEl, + linearMaxVelEl, + linearMinVelEl, + linearMaxAccelEl, + angularMaxVelEl, + angularMaxAccelEl, +].forEach((node) => { + node.addEventListener("change", onDiffFieldChange); + node.addEventListener("input", () => { + applyDiffFormToState(); + markLayoutDirty(); + renderCanvas(); + }); +}); + +[ + bicycleWheelbaseMEl, + bicycleWheelRadiusMEl, + bicycleScaleMPerPxEl, + bicycleSteerPreviewDegEl, + bicycleSteerMaxDegEl, + bicycleCmdVelTimeoutEl, + bicycleLinearMaxVelEl, + bicycleLinearMaxAccelEl, +].forEach((node) => { + if (!node) return; + node.addEventListener("change", onBicycleFieldChange); + node.addEventListener("input", () => { + applyBicycleFormToState(); + markLayoutDirty(); + renderCanvas(); + }); +}); + +editFootprintBtn.addEventListener("click", () => { + setEditFootprintMode(!state.editFootprint); + setStatus(state.editFootprint ? "Chế độ sửa footprint: bật" : "Chế độ sửa footprint: tắt"); +}); + +function initLidarListPanelCollapse() { + try { + const saved = localStorage.getItem("lidarListPanelCollapsed"); + if (saved === "1") setLidarListPanelCollapsed(true); + } catch { + /* ignore */ + } + const toggle = () => setLidarListPanelCollapsed(!state.lidarListPanelCollapsed); + lidarListCardToggle.addEventListener("click", toggle); + lidarListCardToggle.addEventListener("keydown", (evt) => { + if (evt.key === "Enter" || evt.key === " ") { + evt.preventDefault(); + toggle(); + } + }); +} + +function initRobotModelPanelCollapse() { + try { + const saved = localStorage.getItem("robotModelPanelCollapsed"); + if (saved === "1") setRobotModelPanelCollapsed(true); + } catch { + /* ignore */ + } + const toggle = () => setRobotModelPanelCollapsed(!state.robotModelPanelCollapsed); + robotModelCardToggle.addEventListener("click", toggle); + robotModelCardToggle.addEventListener("keydown", (evt) => { + if (evt.key === "Enter" || evt.key === " ") { + evt.preventDefault(); + toggle(); + } + }); +} + +initLayoutManagerEvents(); +initLidarForm(); +initMotorWheelsEvents(); +initBicycleMotorWheelsEvents(); +initFootprintEvents(); +initLidarListEvents(); +initLidarListPanelCollapse(); +initRobotModelPanelCollapse(); + +if (typeof ResizeObserver !== "undefined") { + let resizeRaf = 0; + new ResizeObserver(() => { + if (resizeRaf) cancelAnimationFrame(resizeRaf); + resizeRaf = requestAnimationFrame(() => { + resizeRaf = 0; + renderCanvas(); + }); + }).observe(canvasWrap); +} else { + window.addEventListener("resize", () => renderCanvas()); +} + +window.addEventListener("keydown", (evt) => { + if (evt.key === "Shift") canvasWrap.classList.add("shift-pan"); +}); +window.addEventListener("keyup", (evt) => { + if (evt.key === "Shift") canvasWrap.classList.remove("shift-pan"); +}); + +saveLayoutBtn.addEventListener("click", async () => { + try { + await saveCurrentLayout(); + setStatus(`Đã lưu layout «${state.activeLayoutName || ""}»`); + } catch (e) { + setStatus(`Lỗi: ${e.message}`); + } +}); + +(async () => { + try { + await api("/api/health"); + await loadMotorCatalog(); + await loadAll(); + selectedText.textContent = "none"; + selectedRelText.textContent = "—"; + setStatus("Sẵn sàng"); + } catch (e) { + const msg = String(e.message || e); + if (msg.includes("stack") || msg.includes("Maximum call")) { + setStatus(`Lỗi JavaScript: ${msg}`); + } else { + setStatus(`Không kết nối được backend: ${msg}`); + } + } +})(); + diff --git a/www/data/motor_catalog.json b/www/data/motor_catalog.json new file mode 100644 index 0000000..13015d6 --- /dev/null +++ b/www/data/motor_catalog.json @@ -0,0 +1,92 @@ +{ + "vendors": { + "moons": { + "label": "Moons", + "models": { + "m2dc10a": { + "label": "M2DC-10A (CANopen)", + "interface": "canopen", + "max_rpm": 3000, + "rated_torque_nm": 2.5, + "gear_ratio_default": 20 + }, + "m2dc15a": { + "label": "M2DC-15A (CANopen)", + "interface": "canopen", + "max_rpm": 3000, + "rated_torque_nm": 4.0, + "gear_ratio_default": 16 + } + } + }, + "veichi": { + "label": "Veichi", + "models": { + "sd700": { + "label": "SD700 servo", + "interface": "modbus", + "max_rpm": 3500, + "rated_torque_nm": 2.2, + "gear_ratio_default": 15 + }, + "sd710": { + "label": "SD710 servo", + "interface": "modbus", + "max_rpm": 3000, + "rated_torque_nm": 3.5, + "gear_ratio_default": 12 + } + } + }, + "md": { + "label": "MD / Leadshine", + "models": { + "t3d": { + "label": "T3D stepper driver", + "interface": "pulse_dir", + "max_rpm": 1200, + "rated_torque_nm": 1.2, + "gear_ratio_default": 10 + }, + "elm": { + "label": "ELM servo", + "interface": "pulse_dir", + "max_rpm": 2500, + "rated_torque_nm": 1.8, + "gear_ratio_default": 18 + } + } + }, + "oriental": { + "label": "Oriental Motor", + "models": { + "ble2": { + "label": "BLE2 series", + "interface": "network", + "max_rpm": 3000, + "rated_torque_nm": 1.5, + "gear_ratio_default": 25 + }, + "az_series": { + "label": "AZ series", + "interface": "pulse_dir", + "max_rpm": 2800, + "rated_torque_nm": 2.0, + "gear_ratio_default": 20 + } + } + }, + "custom": { + "label": "Tùy chỉnh", + "models": { + "custom": { + "label": "Motor tùy chỉnh", + "interface": "other", + "max_rpm": 3000, + "rated_torque_nm": 1.0, + "gear_ratio_default": 1 + } + } + } + } +} diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..017054c --- /dev/null +++ b/www/index.html @@ -0,0 +1,433 @@ + + + + + + LiDAR Manager + + + +
+ + +
+
+
+
PhenikaaX Robotics
+
Cấu Hình
+
+
+ + +
+
+ +
+
+
+
+
+
Quản lý layout
+
Nhiều cấu hình robot — mỗi layout có LiDAR và model riêng.
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+

+
+
+ +
+
+
+
LiDARs
+
Đăng ký tên, IP, port và chỉnh pose theo robot frame.
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+
+
+ +
+
+
+
Model robot
+
Kinematic differential — bánh, động cơ và giới hạn vận tốc.
+
+ +
+
+
+
+ + +
+
+
+ Hình học bánh +
+
+ +
+ + m +
+
+
+ +
+ + m +
+
+
+ +
+ + m/px +
+
+
+ Hiệu chỉnh (nâng cao) +
+
+ + +
+
+ + +
+
+
+
+
+ +
+ Động cơ +
+

Mỗi bánh gán một động cơ — chọn hãng và model.

+
+
+
+ +
+ Giới hạn vận tốc +
+
+ +
+ + s +
+
+
+ +
+ + m/s +
+
+
+ +
+ + m/s +
+
+
+ +
+ + m/s² +
+
+
+ +
+ + rad/s +
+
+
+ +
+ + rad/s² +
+
+
+
+ + +
+ + + +
+ Footprint +
+

Hình dạng robot (ROS polygon) — tọa độ theo robot frame.

+
+ + +
+
+
+
+ +
+ + m +
+
+
+ +
+ + m +
+
+
+ + + +
+ + + +
+
+
+
+
+
+ +
+
+
+
Bố trí trên robot
+
+
+ +
+
+ +
+
+
Cuộn chuột: zoom • Shift + kéo: di chuyển vùng nhìn
+
+
Robot center:
+
Selected: none
+
Pose:
+
+
+
+
+
+
+ + + + diff --git a/www/style.css b/www/style.css new file mode 100644 index 0000000..1fd44ec --- /dev/null +++ b/www/style.css @@ -0,0 +1,524 @@ +:root { + --bg: #f6f8fb; + --panel: #ffffff; + --panel2: #f2f5fb; + --text: #0f172a; + --muted: #64748b; + --border: rgba(15, 23, 42, 0.12); + --accent: #2563eb; + --accent2: #10b981; + --danger: #ef4444; + --shadow: 0 8px 20px rgba(15, 23, 42, 0.08); + --shadow2: 0 14px 36px rgba(15, 23, 42, 0.10); +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", + "Segoe UI Emoji"; + background: var(--bg); + color: var(--text); +} + +.shell { + display: grid; + grid-template-columns: 260px 1fr; + min-height: 100vh; +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; + background: linear-gradient(180deg, #0b1220, #0b1220); + color: #e8eefc; + padding: 16px 14px; + border-right: 1px solid rgba(255, 255, 255, 0.08); +} +.brand { + display: grid; + grid-template-columns: 40px 1fr; + gap: 10px; + align-items: center; + padding: 10px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} +.brandIcon { + width: 40px; + height: 40px; + border-radius: 12px; + display: grid; + place-items: center; + background: rgba(37, 99, 235, 0.22); + border: 1px solid rgba(37, 99, 235, 0.35); + font-weight: 800; +} +.brandTitle { font-weight: 800; font-size: 13px; letter-spacing: 0.2px; } +.brandSub { color: rgba(232,238,252,0.75); font-size: 12px; margin-top: 2px; } + +.navTitle { + margin-top: 16px; + padding: 0 10px; + font-size: 11px; + color: rgba(232,238,252,0.65); + letter-spacing: 0.12em; +} +.nav { margin-top: 8px; display: grid; gap: 6px; } +.navItem { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 10px; + border-radius: 12px; + color: rgba(232,238,252,0.85); + text-decoration: none; + border: 1px solid transparent; +} +.navItem:hover { background: rgba(255,255,255,0.05); } +.navItem.active { + background: rgba(37, 99, 235, 0.22); + border-color: rgba(37, 99, 235, 0.30); + color: #ffffff; +} +.navDot { + width: 10px; + height: 10px; + border-radius: 999px; + background: rgba(255,255,255,0.25); +} +.navItem.active .navDot { background: rgba(37, 99, 235, 1); } + +.sidebarFooter { + position: absolute; + left: 14px; + right: 14px; + bottom: 14px; +} +.statusBadge { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); +} +.statusLed { + width: 10px; + height: 10px; + border-radius: 999px; + background: rgba(16, 185, 129, 0.85); + box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12); +} +.statusText { color: rgba(232,238,252,0.85); font-size: 12px; } + +.body { + display: grid; + grid-template-rows: 72px 1fr; + min-width: 0; +} +.topbar { + background: var(--panel); + border-bottom: 1px solid var(--border); + padding: 14px 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} +.kicker { font-size: 12px; color: var(--muted); } +.pageTitle { font-size: 16px; font-weight: 800; letter-spacing: 0.2px; margin-top: 2px; } +.topbarActions { display: flex; gap: 10px; align-items: center; } + +.content { + padding: 18px; + display: grid; + grid-template-columns: min(460px, 100%) 1fr; + gap: 16px; + align-items: start; +} +.contentLeft { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} +.modelForm { display: grid; gap: 10px; } +.modelParams { display: grid; gap: 10px; } +.modelParams[hidden] { display: none; } +.modelForm .rowWide { grid-template-columns: 1fr; gap: 6px; align-items: stretch; } +.modelForm .rowWide label { line-height: 1.3; } +.inputUnit { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + align-items: center; +} +.inputUnit input { min-width: 0; } +.inputUnit .unit { + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} +.acc { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel2); + overflow: hidden; +} +.acc > summary { + padding: 10px 12px; + font-size: 13px; + font-weight: 700; + cursor: pointer; + list-style: none; + user-select: none; +} +.acc > summary::-webkit-details-marker { display: none; } +.acc > summary::after { + content: ""; + float: right; + width: 8px; + height: 8px; + margin-top: 4px; + border-right: 2px solid var(--muted); + border-bottom: 2px solid var(--muted); + transform: rotate(45deg); + transition: transform 0.15s ease; +} +.acc[open] > summary::after { transform: rotate(-135deg); margin-top: 7px; } +.accBody { padding: 0 12px 12px; display: grid; gap: 10px; } +.accNested { margin-top: 4px; } +.validation { + margin: 0; + padding: 8px 10px; + border-radius: 8px; + font-size: 12px; + line-height: 1.35; + background: rgba(234, 179, 8, 0.12); + border: 1px solid rgba(234, 179, 8, 0.35); + color: #92400e; +} +.validation.error { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.35); + color: #b91c1c; +} +.yamlPreview { + margin: 0; + padding: 10px 12px; + max-height: 220px; + overflow: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 1.45; + background: #0f172a; + color: #e2e8f0; + border-radius: 10px; + border: 1px solid var(--border); + white-space: pre-wrap; + word-break: break-word; +} +.btnBlock { width: 100%; justify-content: center; } +.mutedNote { margin: 0; font-size: 12px; color: var(--muted); } +.mutedNote .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 11px; } +.mutedNote a { color: rgba(37, 99, 235, 0.9); } +.layoutManagerActions { display: flex; gap: 8px; margin-top: 4px; } +.layoutManagerActions .btn { flex: 1; justify-content: center; } +#layoutSelect { width: 100%; } +.motorWheels { display: grid; gap: 12px; } +.wheelMotorBlock { + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + background: #fff; +} +.wheelMotorTitle { + font-size: 13px; + font-weight: 700; + margin-bottom: 8px; + color: var(--text); +} +.wheelMotorMeta { + font-size: 11px; + color: var(--muted); + margin-top: 6px; + line-height: 1.35; +} +.wheelMotorBlock .rowWide { margin-bottom: 6px; } +.wheelMotorBlock .rowWide:last-child { margin-bottom: 0; } +.wheelMotorBlock input[type="checkbox"] { + width: auto; + margin-right: 6px; +} +.checkRow { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--muted); +} +.robotDiffSummary { + width: 100%; + font-size: 12px; + color: rgba(37, 99, 235, 0.95); +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: var(--shadow); + overflow: hidden; +} + +.cardHeader { + padding: 14px 16px; + border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, rgba(37, 99, 235, 0.06), transparent 70%); +} +.cardHeaderToggle { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + cursor: pointer; + user-select: none; +} +.cardHeaderToggle:hover { background: linear-gradient(180deg, rgba(37, 99, 235, 0.10), transparent 70%); } +.cardChevron { + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel2); + position: relative; + margin-top: 2px; + transition: transform 0.2s ease; +} +.cardChevron::before { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 8px; + height: 8px; + border-right: 2px solid var(--muted); + border-bottom: 2px solid var(--muted); + transform: translate(-50%, -65%) rotate(45deg); +} +.card.collapsed .cardChevron { transform: rotate(-90deg); } +.card.collapsed .cardBody { display: none; } +.card.collapsed .cardHeader { border-bottom: none; } + +.cardTitle { font-size: 14px; font-weight: 800; letter-spacing: 0.2px; } +.cardSub { margin-top: 4px; color: var(--muted); font-size: 12px; line-height: 1.35; } +.cardBody { padding: 14px 16px; } + +.form { display: grid; gap: 10px; margin-bottom: 12px; } +.formHint { + margin: 0; + font-size: 12px; + line-height: 1.35; + color: #b91c1c; +} +.formHint[hidden] { display: none; } +.row { display: grid; grid-template-columns: 90px 1fr; gap: 10px; align-items: center; } +label { color: var(--muted); font-size: 12px; } +input, select { + width: 100%; + padding: 10px 10px; + background: var(--panel2); + border: 1px solid var(--border); + color: var(--text); + border-radius: 10px; + outline: none; +} +input:focus, select:focus { border-color: rgba(37, 99, 235, 0.55); box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12); } + +.actions { display: flex; gap: 10px; } +.btn, button { + cursor: pointer; + border: 1px solid var(--border); + background: var(--panel2); + color: var(--text); + padding: 9px 12px; + border-radius: 10px; + font-weight: 600; +} +.btn.primary { background: rgba(37, 99, 235, 0.92); border-color: rgba(37, 99, 235, 0.92); color: #fff; } +.btn.primary:hover { background: rgba(37, 99, 235, 1); } +.btn.subtle { background: var(--panel2); } +.btn.danger { color: var(--danger); border-color: rgba(239, 68, 68, 0.35); } +.btn:active, button:active { transform: translateY(1px); } +.btn.active, button.active { box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12); border-color: rgba(37, 99, 235, 0.55); } + +.list { display: grid; gap: 10px; } +.item { + border: 1px solid var(--border); + background: #ffffff; + border-radius: 12px; + padding: 10px; +} +.itemTop { display: flex; justify-content: space-between; gap: 10px; align-items: flex-start; } +.itemMain { flex: 1; min-width: 0; } +.itemToggle { + flex-shrink: 0; + width: 26px; + height: 26px; + margin-top: 2px; + margin-right: 8px; + padding: 0; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel2); + cursor: pointer; + position: relative; +} +.itemToggle::before { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 7px; + height: 7px; + border-right: 2px solid var(--muted); + border-bottom: 2px solid var(--muted); + transform: translate(-50%, -65%) rotate(45deg); + transition: transform 0.2s ease; +} +.item.collapsed .itemToggle::before { transform: translate(-50%, -35%) rotate(-135deg); } +.item.collapsed .itemBody { display: none; } +.itemTopRow { display: flex; align-items: flex-start; } +.itemName { font-weight: 700; } +.itemMeta { color: var(--muted); font-size: 12px; margin-top: 4px; } +.itemBtns { display: flex; gap: 8px; } +.poseRow { + margin-top: 8px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.poseField { + display: grid; + grid-template-columns: 18px 1fr; + gap: 8px; + align-items: center; +} +.poseLabel { + color: var(--muted); + font-size: 12px; + text-align: right; +} +.poseInput { + padding: 8px 9px; + border-radius: 10px; + background: var(--panel2); +} +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + background: rgba(16, 185, 129, 0.10); + border: 1px solid rgba(16, 185, 129, 0.22); + color: rgba(16, 185, 129, 1); + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: end; + margin-bottom: 10px; +} +.tool { display: grid; gap: 6px; } +.tool label { font-size: 12px; } +.tool input { width: 120px; } +.toolGroup { + display: flex; + gap: 10px; + align-items: end; +} +.toolGroup[hidden] { display: none; } +.tool select { width: 170px; } +.toolGroup .tool input { width: 110px; } + +.canvasWrap { + background: #ffffff; + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px; + height: min(72vh, 680px); + min-height: 420px; +} +canvas { + display: block; + width: 100%; + height: 100%; + border-radius: 10px; + background: #0b1220; + touch-action: none; +} +.canvasWrap.panning canvas { cursor: grabbing; } +.canvasWrap.shift-pan canvas { cursor: grab; } + +.metaBar { + margin-top: 10px; + display: flex; + flex-wrap: wrap; + gap: 10px 14px; + color: var(--muted); + font-size: 12px; +} +.fpHint { color: rgba(37, 99, 235, 0.95); margin-top: 8px; font-size: 12px; line-height: 1.4; } +.footprintPresetPanel { + display: grid; + gap: 8px; + margin-bottom: 10px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: #f8fafc; +} +.footprintPresetPanel .fpShapeParams { display: grid; gap: 6px; } +.footprintPresetPanel.hidden { display: none; } +.footprintCustomPanel { + display: grid; + gap: 8px; + margin-bottom: 10px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: #f8fafc; +} +.footprintCustomPanel.hidden { display: none; } +.fpVertexRow { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 6px 12px; + font-size: 12px; +} +.fpVertexLabel strong { color: var(--text); } +.fpSelectedVertex { font-size: 11px; } +.fpVertexActions { display: flex; gap: 8px; } +.fpVertexActions .btn { flex: 1; justify-content: center; font-size: 12px; padding: 6px 10px; } +.viewHint { color: var(--muted); font-size: 12px; width: 100%; } +.canvasWrap canvas.edit-footprint { cursor: crosshair; } + +@media (max-width: 980px) { + .shell { grid-template-columns: 1fr; } + .sidebar { position: relative; height: auto; } + .body { grid-template-rows: auto 1fr; } + .content { grid-template-columns: 1fr; } +} +