first commit

This commit is contained in:
2026-05-29 16:28:13 +07:00
parent 8797fd49d5
commit 8b633edb01
10 changed files with 5576 additions and 1 deletions

43
CMakeLists.txt Normal file
View File

@@ -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
)

View File

@@ -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/`

View File

@@ -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"
}

View File

@@ -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"
}

22
data/state.json Normal file
View File

@@ -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
}

1252
src/main.cpp Normal file

File diff suppressed because it is too large Load Diff

2799
www/app.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
}
}
}
}

433
www/index.html Normal file
View File

@@ -0,0 +1,433 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LiDAR Manager</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<div class="brandIcon">R</div>
<div class="brandText">
<div class="brandTitle">PhenikaaX</div>
<div class="brandSub">RobotApp</div>
</div>
</div>
<div class="navTitle">WORKSPACE</div>
<nav class="nav">
<a class="navItem active" href="#" data-page="overview" aria-current="page">
<span class="navDot"></span>
Tổng quan
</a>
<a class="navItem" href="#" data-page="config">
<span class="navDot"></span>
Cấu hình
</a>
</nav>
<div class="sidebarFooter">
<div class="statusBadge">
<span class="statusLed"></span>
<span id="status" class="statusText"></span>
</div>
</div>
</aside>
<div class="body">
<header class="topbar">
<div class="topbarTitle">
<div class="kicker">PhenikaaX Robotics</div>
<div class="pageTitle">Cấu Hình</div>
</div>
<div class="topbarActions">
<button id="refreshBtn" type="button" class="btn subtle">Tải lại</button>
<button id="saveLayoutBtn" class="btn primary" type="button">Lưu layout</button>
</div>
</header>
<main class="content">
<div class="contentLeft">
<section class="card" id="layoutManagerCard">
<div class="cardHeader">
<div>
<div class="cardTitle">Quản lý layout</div>
<div class="cardSub">Nhiều cấu hình robot — mỗi layout có LiDAR và model riêng.</div>
</div>
</div>
<div class="cardBody">
<div class="row rowWide">
<label>Layout hiện tại</label>
<select id="layoutSelect"></select>
</div>
<div class="row rowWide">
<label>Tên layout mới</label>
<input id="layoutNewName" type="text" placeholder="VD: AGV kho A" />
</div>
<div class="checkRow">
<label>
<input id="layoutCloneCurrent" type="checkbox" />
Sao chép từ layout đang mở
</label>
</div>
<div class="layoutManagerActions">
<button id="layoutCreateBtn" type="button" class="btn subtle">Tạo layout</button>
<button id="layoutDeleteBtn" type="button" class="btn subtle danger">Xóa</button>
</div>
<p id="layoutActiveHint" class="mutedNote"></p>
</div>
</section>
<section class="card collapsible" id="lidarListCard">
<div
class="cardHeader cardHeaderToggle"
id="lidarListCardToggle"
role="button"
tabindex="0"
aria-expanded="true"
aria-controls="lidarListCardBody"
>
<div>
<div class="cardTitle">LiDARs</div>
<div class="cardSub">Đăng ký tên, IP, port và chỉnh pose theo robot frame.</div>
</div>
<span class="cardChevron" aria-hidden="true"></span>
</div>
<div class="cardBody" id="lidarListCardBody">
<form id="lidarForm" class="form">
<div class="row">
<label>Tên</label>
<input id="name" placeholder="Lidar trước" required />
</div>
<div class="row">
<label>IP</label>
<input id="ip" placeholder="192.168.0.10" required />
</div>
<div class="row">
<label>Port</label>
<input id="port" type="number" min="1" max="65535" value="2112" required />
</div>
<div class="actions">
<button id="addLidarBtn" class="btn primary" type="button">Thêm</button>
</div>
<p id="lidarFormHint" class="formHint" hidden></p>
</form>
<div id="lidarList" class="list"></div>
</div>
</section>
<section class="card collapsible" id="robotModelCard">
<div
class="cardHeader cardHeaderToggle"
id="robotModelCardToggle"
role="button"
tabindex="0"
aria-expanded="true"
aria-controls="robotModelCardBody"
>
<div>
<div class="cardTitle">Model robot</div>
<div class="cardSub">Kinematic differential — bánh, động cơ và giới hạn vận tốc.</div>
</div>
<span class="cardChevron" aria-hidden="true"></span>
</div>
<div class="cardBody" id="robotModelCardBody">
<div class="modelForm">
<div class="row rowWide">
<label>Model</label>
<select id="robotModel">
<option value="diff">Differential (2 bánh)</option>
<option value="bicycle">Bicycle</option>
</select>
</div>
<div id="diffParams" class="modelParams">
<details class="acc" open>
<summary>Hình học bánh</summary>
<div class="accBody">
<div class="row rowWide">
<label>Khoảng cách 2 bánh</label>
<div class="inputUnit">
<input id="wheelSeparationM" type="number" min="0.05" max="5" step="0.01" value="1.0" />
<span class="unit">m</span>
</div>
</div>
<div class="row rowWide">
<label>Bán kính bánh</label>
<div class="inputUnit">
<input id="wheelRadiusM" type="number" min="0.02" max="1" step="0.01" value="0.3" />
<span class="unit">m</span>
</div>
</div>
<div class="row rowWide">
<label>Tỷ lệ hiển thị</label>
<div class="inputUnit">
<input id="scaleMPerPx" type="number" min="0.001" max="0.1" step="0.001" value="0.005" />
<span class="unit">m/px</span>
</div>
</div>
<details class="acc accNested">
<summary>Hiệu chỉnh (nâng cao)</summary>
<div class="accBody">
<div class="row rowWide">
<label>b multiplier</label>
<input id="wheelSeparationMult" type="number" min="0.5" max="2" step="0.01" value="1.0" />
</div>
<div class="row rowWide">
<label>r multiplier</label>
<input id="wheelRadiusMult" type="number" min="0.5" max="2" step="0.01" value="1.0" />
</div>
</div>
</details>
</div>
</details>
<details class="acc">
<summary>Động cơ</summary>
<div class="accBody">
<p class="mutedNote">Mỗi bánh gán một động cơ — chọn hãng và model.</p>
<div id="motorWheels" class="motorWheels"></div>
</div>
</details>
<details class="acc">
<summary>Giới hạn vận tốc</summary>
<div class="accBody">
<div class="row rowWide">
<label>cmd_vel timeout</label>
<div class="inputUnit">
<input id="cmdVelTimeout" type="number" min="0.05" max="5" step="0.05" value="0.25" />
<span class="unit">s</span>
</div>
</div>
<div class="row rowWide">
<label>Linear max</label>
<div class="inputUnit">
<input id="linearMaxVel" type="number" min="0.01" max="5" step="0.05" value="1.0" />
<span class="unit">m/s</span>
</div>
</div>
<div class="row rowWide">
<label>Linear min</label>
<div class="inputUnit">
<input id="linearMinVel" type="number" min="-5" max="0" step="0.05" value="-0.5" />
<span class="unit">m/s</span>
</div>
</div>
<div class="row rowWide">
<label>Linear accel max</label>
<div class="inputUnit">
<input id="linearMaxAccel" type="number" min="0.01" max="10" step="0.05" value="0.8" />
<span class="unit">m/s²</span>
</div>
</div>
<div class="row rowWide">
<label>Angular max</label>
<div class="inputUnit">
<input id="angularMaxVel" type="number" min="0.01" max="10" step="0.05" value="1.7" />
<span class="unit">rad/s</span>
</div>
</div>
<div class="row rowWide">
<label>Angular accel max</label>
<div class="inputUnit">
<input id="angularMaxAccel" type="number" min="0.01" max="10" step="0.05" value="1.5" />
<span class="unit">rad/s²</span>
</div>
</div>
</div>
</details>
<p id="diffValidation" class="validation" hidden></p>
</div>
<div id="bicycleParams" class="modelParams" hidden>
<p class="mutedNote">
Kinematic bicycle — tham chiếu trục sau, quan hệ
<span class="mono">ω = v·tan(δ)/L</span>
(<a href="https://thomasfermi.github.io/Algorithms-for-Automated-Driving/Control/BicycleModel.html" target="_blank" rel="noopener">mô hình</a>).
</p>
<details class="acc" open>
<summary>Hình học (wheelbase)</summary>
<div class="accBody">
<div class="row rowWide">
<label>Wheelbase L</label>
<div class="inputUnit">
<input id="bicycleWheelbaseM" type="number" min="0.2" max="5" step="0.05" value="1.2" />
<span class="unit">m</span>
</div>
</div>
<div class="row rowWide">
<label>Bán kính bánh</label>
<div class="inputUnit">
<input id="bicycleWheelRadiusM" type="number" min="0.02" max="1" step="0.01" value="0.15" />
<span class="unit">m</span>
</div>
</div>
<div class="row rowWide">
<label>Tỷ lệ hiển thị</label>
<div class="inputUnit">
<input id="bicycleScaleMPerPx" type="number" min="0.001" max="0.1" step="0.001" value="0.005" />
<span class="unit">m/px</span>
</div>
</div>
<div class="row rowWide">
<label>Góc lái xem trước</label>
<div class="inputUnit">
<input id="bicycleSteerPreviewDeg" type="number" min="-45" max="45" step="1" value="15" />
<span class="unit">°</span>
</div>
</div>
</div>
</details>
<details class="acc">
<summary>Động cơ</summary>
<div class="accBody">
<p class="mutedNote">Bánh sau (drive) và bánh trước (steer).</p>
<div id="bicycleMotorWheels" class="motorWheels"></div>
</div>
</details>
<details class="acc">
<summary>Giới hạn</summary>
<div class="accBody">
<div class="row rowWide">
<label>δ max (lái)</label>
<div class="inputUnit">
<input id="bicycleSteerMaxDeg" type="number" min="5" max="60" step="1" value="35" />
<span class="unit">°</span>
</div>
</div>
<div class="row rowWide">
<label>cmd_vel timeout</label>
<div class="inputUnit">
<input id="bicycleCmdVelTimeout" type="number" min="0.05" max="5" step="0.05" value="0.25" />
<span class="unit">s</span>
</div>
</div>
<div class="row rowWide">
<label>Linear max</label>
<div class="inputUnit">
<input id="bicycleLinearMaxVel" type="number" min="0.01" max="5" step="0.05" value="1.0" />
<span class="unit">m/s</span>
</div>
</div>
<div class="row rowWide">
<label>Linear accel max</label>
<div class="inputUnit">
<input id="bicycleLinearMaxAccel" type="number" min="0.01" max="10" step="0.05" value="0.8" />
<span class="unit">m/s²</span>
</div>
</div>
</div>
</details>
<p id="bicycleValidation" class="validation" hidden></p>
</div>
<details class="acc">
<summary>Footprint</summary>
<div class="accBody">
<p class="mutedNote">Hình dạng robot (ROS polygon) — tọa độ theo robot frame.</p>
<div class="row rowWide">
<label>Hình dạng</label>
<select id="footprintShape">
<option value="rectangle">Hình chữ nhật</option>
<option value="circle">Hình tròn</option>
<option value="regular_polygon">Đa giác đều</option>
<option value="custom">Tùy chỉnh (đa giác)</option>
</select>
</div>
<div id="footprintPresetPanel" class="footprintPresetPanel">
<div id="fpRectParams" class="fpShapeParams">
<div class="row rowWide">
<label>Chiều dài</label>
<div class="inputUnit">
<input id="fpLengthM" type="number" min="0.1" max="10" step="0.05" value="1.4" />
<span class="unit">m</span>
</div>
</div>
<div class="row rowWide">
<label>Chiều rộng</label>
<div class="inputUnit">
<input id="fpWidthM" type="number" min="0.1" max="10" step="0.05" value="1.1" />
<span class="unit">m</span>
</div>
</div>
</div>
<div id="fpCircleParams" class="fpShapeParams" hidden>
<div class="row rowWide">
<label>Bán kính</label>
<div class="inputUnit">
<input id="fpRadiusM" type="number" min="0.1" max="5" step="0.05" value="0.55" />
<span class="unit">m</span>
</div>
</div>
<div class="row rowWide">
<label>Độ mịn (cạnh)</label>
<input id="fpCircleSegments" type="number" min="8" max="64" step="1" value="32" />
</div>
</div>
<div id="fpPolyParams" class="fpShapeParams" hidden>
<div class="row rowWide">
<label>Bán kính</label>
<div class="inputUnit">
<input id="fpPolyRadiusM" type="number" min="0.1" max="5" step="0.05" value="0.6" />
<span class="unit">m</span>
</div>
</div>
<div class="row rowWide">
<label>Số cạnh</label>
<input id="fpPolySides" type="number" min="3" max="32" step="1" value="6" />
</div>
</div>
<button id="applyFootprintPresetBtn" type="button" class="btn subtle btnBlock">Áp dụng hình dạng</button>
</div>
<div id="footprintCustomPanel" class="footprintCustomPanel hidden">
<div class="fpVertexRow">
<span class="fpVertexLabel">Số đỉnh: <strong id="fpVertexCount">0</strong></span>
<span id="fpSelectedVertexText" class="fpSelectedVertex mutedNote"></span>
</div>
<div class="fpVertexActions">
<button id="fpAddVertexBtn" type="button" class="btn subtle">Thêm đỉnh</button>
<button id="fpRemoveVertexBtn" type="button" class="btn subtle" disabled>Xóa đỉnh</button>
</div>
</div>
<button id="editFootprintBtn" type="button" class="btn subtle btnBlock">Sửa footprint</button>
<div id="footprintEditHint" class="fpHint" hidden></div>
</div>
</details>
</div>
</div>
</section>
</div>
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">Bố trí trên robot</div>
</div>
</div>
<div class="cardBody">
<div class="canvasWrap" id="canvasWrap">
<canvas id="canvas"></canvas>
</div>
<div class="metaBar">
<div class="viewHint">Cuộn chuột: zoom • Shift + kéo: di chuyển vùng nhìn</div>
<div id="robotDiffSummary" class="robotDiffSummary"></div>
<div>Robot center: <span id="robotCenterText"></span></div>
<div>Selected: <span id="selectedText">none</span></div>
<div>Pose: <span id="selectedRelText"></span></div>
</div>
</div>
</section>
</main>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

524
www/style.css Normal file
View File

@@ -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; }
}