This commit is contained in:
@@ -42,6 +42,7 @@ add_executable(lidar_manager_web
|
||||
src/domain/layout_profile.cpp
|
||||
src/storage/database.cpp
|
||||
src/storage/map_store.cpp
|
||||
src/storage/site_store.cpp
|
||||
src/storage/sound_store.cpp
|
||||
src/storage/dashboard_store.cpp
|
||||
src/storage/state_repository.cpp
|
||||
|
||||
53
data/Denso_1/Denso_1.pgm
Normal file
53
data/Denso_1/Denso_1.pgm
Normal file
File diff suppressed because one or more lines are too long
BIN
data/Denso_1/Denso_1.png
Normal file
BIN
data/Denso_1/Denso_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
BIN
data/Denso_1/Denso_1.xloc
Normal file
BIN
data/Denso_1/Denso_1.xloc
Normal file
Binary file not shown.
6
data/Denso_1/Denso_1.yaml
Normal file
6
data/Denso_1/Denso_1.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
image: Denso_1.png
|
||||
resolution: 0.050000
|
||||
origin: [-12.238091, -13.200000, 0.0]
|
||||
negate: 0
|
||||
occupied_thresh: 0.65
|
||||
free_thresh: 0.196
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "storage/dashboard_store.hpp"
|
||||
#include "storage/database.hpp"
|
||||
#include "storage/map_store.hpp"
|
||||
#include "storage/site_store.hpp"
|
||||
#include "storage/sound_store.hpp"
|
||||
#include "storage/state_repository.hpp"
|
||||
|
||||
@@ -45,6 +46,8 @@ int LidarManagerApp::run()
|
||||
MissionStore mission_store(database);
|
||||
RobotRuntime robot_runtime(database, mission_queue);
|
||||
MapStore map_store(database);
|
||||
SiteStore site_store(database);
|
||||
site_store.ensureDefaultSiteId();
|
||||
SoundStore sound_store(database);
|
||||
DashboardStore dashboard_store(database);
|
||||
|
||||
@@ -72,6 +75,7 @@ int LidarManagerApp::run()
|
||||
scheduler,
|
||||
robot_runtime,
|
||||
map_store,
|
||||
site_store,
|
||||
sound_store,
|
||||
dashboard_store);
|
||||
api.registerRoutes(svr);
|
||||
|
||||
@@ -22,6 +22,17 @@ nlohmann::json defaultPermissionsAllWrite()
|
||||
{
|
||||
return {{"dashboard", "write"},
|
||||
{"config", "write"},
|
||||
{"maps", "write"},
|
||||
{"missions", "write"},
|
||||
{"integrations", "write"},
|
||||
{"users", "write"}};
|
||||
}
|
||||
|
||||
nlohmann::json defaultPermissionsAdministrator()
|
||||
{
|
||||
return {{"dashboard", "write"},
|
||||
{"config", "none"},
|
||||
{"maps", "write"},
|
||||
{"missions", "write"},
|
||||
{"integrations", "write"},
|
||||
{"users", "write"}};
|
||||
@@ -30,7 +41,8 @@ nlohmann::json defaultPermissionsAllWrite()
|
||||
nlohmann::json defaultPermissionsUserGroup()
|
||||
{
|
||||
return {{"dashboard", "write"},
|
||||
{"config", "read"},
|
||||
{"config", "none"},
|
||||
{"maps", "none"},
|
||||
{"missions", "read"},
|
||||
{"integrations", "read"},
|
||||
{"users", "none"}};
|
||||
@@ -80,7 +92,7 @@ void AuthService::loadOrSeed()
|
||||
{{"id", "group_administrators"},
|
||||
{"name", "Administrators"},
|
||||
{"allow_pin", false},
|
||||
{"permissions", defaultPermissionsAllWrite()}},
|
||||
{"permissions", defaultPermissionsAdministrator()}},
|
||||
{{"id", "group_users"},
|
||||
{"name", "Users"},
|
||||
{"allow_pin", true},
|
||||
@@ -95,6 +107,43 @@ void AuthService::loadOrSeed()
|
||||
makeUser("user_operator", "User", "user", "group_users", "Operator"),
|
||||
});
|
||||
}
|
||||
|
||||
if (data_.value("version", 1) < 2)
|
||||
{
|
||||
if (data_.contains("groups") && data_["groups"].is_array())
|
||||
{
|
||||
for (auto& g : data_["groups"])
|
||||
{
|
||||
if (!g.contains("permissions") || !g["permissions"].is_object())
|
||||
g["permissions"] = nlohmann::json::object();
|
||||
const std::string id = g.value("id", "");
|
||||
if (id == "group_distributors")
|
||||
g["permissions"]["config"] = "write";
|
||||
else
|
||||
g["permissions"]["config"] = "none";
|
||||
}
|
||||
}
|
||||
data_["version"] = 2;
|
||||
}
|
||||
|
||||
if (data_.value("version", 1) < 3)
|
||||
{
|
||||
if (data_.contains("groups") && data_["groups"].is_array())
|
||||
{
|
||||
for (auto& g : data_["groups"])
|
||||
{
|
||||
if (!g.contains("permissions") || !g["permissions"].is_object())
|
||||
g["permissions"] = nlohmann::json::object();
|
||||
const std::string id = g.value("id", "");
|
||||
if (id == "group_distributors" || id == "group_administrators")
|
||||
g["permissions"]["maps"] = "write";
|
||||
else
|
||||
g["permissions"]["maps"] = "none";
|
||||
}
|
||||
}
|
||||
data_["version"] = 3;
|
||||
}
|
||||
|
||||
saveUnlocked();
|
||||
}
|
||||
|
||||
@@ -136,7 +185,8 @@ bool AuthService::isPublicApiPath(const std::string& path, const std::string& me
|
||||
{
|
||||
if (method == "OPTIONS")
|
||||
return true;
|
||||
return path == "/api/health" || path == "/api/auth/login" || path == "/api/auth/logout";
|
||||
return path == "/api/health" || path == "/api/auth/login" || path == "/api/auth/logout" ||
|
||||
path == "/api/auth/me";
|
||||
}
|
||||
|
||||
std::optional<std::string> AuthService::resourceForApiPath(const std::string& path)
|
||||
@@ -156,7 +206,13 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
|
||||
return "dashboard";
|
||||
if (path.rfind("/api/sounds", 0) == 0)
|
||||
return "integrations";
|
||||
if (path.rfind("/api/maps", 0) == 0 || path.rfind("/api/recordings", 0) == 0)
|
||||
if (path == "/api/robot/active_map")
|
||||
return "maps";
|
||||
if (path.rfind("/api/maps", 0) == 0 || path.rfind("/api/sites", 0) == 0 ||
|
||||
path.rfind("/api/recordings", 0) == 0)
|
||||
return "maps";
|
||||
if (path.rfind("/api/layout", 0) == 0 || path.rfind("/api/lidars", 0) == 0 ||
|
||||
path.rfind("/api/imus", 0) == 0)
|
||||
return "config";
|
||||
return std::nullopt;
|
||||
}
|
||||
@@ -629,6 +685,10 @@ bool AuthService::authorizeApiRequest(const httplib::Request& req, httplib::Resp
|
||||
return true;
|
||||
|
||||
const bool write = requiresWrite(req.method);
|
||||
if (!write && req.path.rfind("/api/maps", 0) == 0 &&
|
||||
permissionAllows(it->second.permissions, "dashboard", false))
|
||||
return true;
|
||||
|
||||
if (!permissionAllows(it->second.permissions, *resource, write))
|
||||
{
|
||||
HttpUtil::jsonError(res, 403, "insufficient permissions");
|
||||
@@ -700,16 +760,16 @@ void AuthService::registerRoutes(httplib::Server& svr)
|
||||
});
|
||||
|
||||
svr.Get("/api/auth/me", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
const std::string token = extractToken(req);
|
||||
const auto info = sessionInfo(token);
|
||||
if (!info)
|
||||
{
|
||||
HttpUtil::jsonError(res, 401, "not authenticated");
|
||||
res.set_content(R"({"user":null})", "application/json; charset=utf-8");
|
||||
return;
|
||||
}
|
||||
nlohmann::json out = {{"user", *info}};
|
||||
res.set_content(out.dump(), "application/json; charset=utf-8");
|
||||
HttpUtil::addCors(res);
|
||||
res.set_content(nlohmann::json({{"user", *info}}).dump(), "application/json; charset=utf-8");
|
||||
});
|
||||
|
||||
svr.Put("/api/auth/password", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
|
||||
@@ -63,6 +63,10 @@ void RobotRuntime::ensureDefaultsUnlocked()
|
||||
state_["cmd_angular"] = 0.0;
|
||||
if (!state_.contains("updated_at"))
|
||||
state_["updated_at"] = IdUtil::nowIso8601();
|
||||
if (!state_.contains("active_map_id"))
|
||||
state_["active_map_id"] = nullptr;
|
||||
if (!state_.contains("pose") || !state_["pose"].is_object())
|
||||
state_["pose"] = {{"x", 0.0}, {"y", 0.0}, {"yaw", 0.0}};
|
||||
saveUnlocked();
|
||||
}
|
||||
|
||||
@@ -104,6 +108,8 @@ nlohmann::json RobotRuntime::buildStatusUnlocked() const
|
||||
{"joystick_speed", state_.value("joystick_speed", "fast")},
|
||||
{"cmd_linear", state_.value("cmd_linear", 0.0)},
|
||||
{"cmd_angular", state_.value("cmd_angular", 0.0)},
|
||||
{"active_map_id", state_.contains("active_map_id") ? state_["active_map_id"] : nullptr},
|
||||
{"pose", state_.contains("pose") ? state_["pose"] : nlohmann::json::object({{"x", 0.0}, {"y", 0.0}, {"yaw", 0.0}})},
|
||||
{"runner", runner},
|
||||
{"queue_pending", pending},
|
||||
{"updated_at", state_.value("updated_at", "")}};
|
||||
@@ -235,6 +241,27 @@ bool RobotRuntime::setJoystick(bool engaged, const std::string& speed, std::stri
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RobotRuntime::setActiveMap(const std::string& map_id, std::string& err)
|
||||
{
|
||||
(void)err;
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
state_["active_map_id"] = map_id;
|
||||
state_["updated_at"] = IdUtil::nowIso8601();
|
||||
saveUnlocked();
|
||||
return true;
|
||||
}
|
||||
|
||||
void RobotRuntime::clearActiveMapIf(const std::string& map_id)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (state_.value("active_map_id", "") == map_id)
|
||||
{
|
||||
state_["active_map_id"] = nullptr;
|
||||
state_["updated_at"] = IdUtil::nowIso8601();
|
||||
saveUnlocked();
|
||||
}
|
||||
}
|
||||
|
||||
void RobotRuntime::tick()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
|
||||
@@ -21,6 +21,8 @@ public:
|
||||
bool resetError(std::string& err);
|
||||
bool setCmdVel(double linear, double angular, std::string& err);
|
||||
bool setJoystick(bool engaged, const std::string& speed, std::string& err);
|
||||
bool setActiveMap(const std::string& map_id, std::string& err);
|
||||
void clearActiveMapIf(const std::string& map_id);
|
||||
void tick();
|
||||
|
||||
private:
|
||||
|
||||
@@ -7,6 +7,61 @@ namespace lm {
|
||||
|
||||
void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
||||
{
|
||||
svr.Get("/api/sites", [this](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = nlohmann::json({{"sites", site_store_.list()}}).dump();
|
||||
});
|
||||
|
||||
svr.Post("/api/sites", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
nlohmann::json body;
|
||||
try
|
||||
{
|
||||
body = nlohmann::json::parse(req.body);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||
}
|
||||
std::string err;
|
||||
const auto created = site_store_.create(body, err);
|
||||
if (!created)
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
res.status = 201;
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = created->dump();
|
||||
});
|
||||
|
||||
svr.Put(R"(/api/sites/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
nlohmann::json body;
|
||||
try
|
||||
{
|
||||
body = nlohmann::json::parse(req.body);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||
}
|
||||
std::string err;
|
||||
if (!site_store_.update(id, body, err))
|
||||
return HttpUtil::jsonError(res, 404, err);
|
||||
const auto updated = site_store_.find(id);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||
});
|
||||
|
||||
svr.Delete(R"(/api/sites/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
std::string err;
|
||||
if (!site_store_.remove(id, err))
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
res.status = 204;
|
||||
});
|
||||
|
||||
svr.Get("/api/maps", [this](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
@@ -69,6 +124,7 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
||||
std::string err;
|
||||
if (!map_store_.remove(id, err))
|
||||
return HttpUtil::jsonError(res, 404, err);
|
||||
robot_runtime_.clearActiveMapIf(id);
|
||||
res.status = 204;
|
||||
});
|
||||
|
||||
|
||||
@@ -80,6 +80,29 @@ void ApiServer::registerRobotRoutes(httplib::Server& svr)
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = robot_runtime_.status().dump();
|
||||
});
|
||||
|
||||
svr.Post("/api/robot/active_map", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
nlohmann::json body;
|
||||
try
|
||||
{
|
||||
body = nlohmann::json::parse(req.body);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||
}
|
||||
const std::string map_id = body.value("map_id", "");
|
||||
if (map_id.empty())
|
||||
return HttpUtil::jsonError(res, 400, "map_id is required");
|
||||
if (!map_store_.find(map_id))
|
||||
return HttpUtil::jsonError(res, 404, "map not found");
|
||||
std::string err;
|
||||
if (!robot_runtime_.setActiveMap(map_id, err))
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = robot_runtime_.status().dump();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
|
||||
@@ -17,6 +17,7 @@ ApiServer::ApiServer(StateRepository& repo,
|
||||
MissionScheduler& scheduler,
|
||||
RobotRuntime& robot_runtime,
|
||||
MapStore& map_store,
|
||||
SiteStore& site_store,
|
||||
SoundStore& sound_store,
|
||||
DashboardStore& dashboard_store)
|
||||
: repo_(repo),
|
||||
@@ -26,6 +27,7 @@ ApiServer::ApiServer(StateRepository& repo,
|
||||
scheduler_(scheduler),
|
||||
robot_runtime_(robot_runtime),
|
||||
map_store_(map_store),
|
||||
site_store_(site_store),
|
||||
sound_store_(sound_store),
|
||||
dashboard_store_(dashboard_store)
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "robot/robot_runtime.hpp"
|
||||
#include "storage/dashboard_store.hpp"
|
||||
#include "storage/map_store.hpp"
|
||||
#include "storage/site_store.hpp"
|
||||
#include "storage/sound_store.hpp"
|
||||
#include "storage/state_repository.hpp"
|
||||
|
||||
@@ -24,6 +25,7 @@ public:
|
||||
MissionScheduler& scheduler,
|
||||
RobotRuntime& robot_runtime,
|
||||
MapStore& map_store,
|
||||
SiteStore& site_store,
|
||||
SoundStore& sound_store,
|
||||
DashboardStore& dashboard_store);
|
||||
|
||||
@@ -37,6 +39,7 @@ private:
|
||||
MissionScheduler& scheduler_;
|
||||
RobotRuntime& robot_runtime_;
|
||||
MapStore& map_store_;
|
||||
SiteStore& site_store_;
|
||||
SoundStore& sound_store_;
|
||||
DashboardStore& dashboard_store_;
|
||||
|
||||
|
||||
@@ -42,10 +42,19 @@ CREATE TABLE IF NOT EXISTS layout_profiles (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maps (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
site_id TEXT,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
width REAL,
|
||||
height REAL,
|
||||
resolution REAL,
|
||||
@@ -56,7 +65,8 @@ CREATE TABLE IF NOT EXISTS maps (
|
||||
yaml_file TEXT,
|
||||
zones_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sounds (
|
||||
@@ -188,6 +198,86 @@ bool Database::applySchema(std::string& err)
|
||||
return execSql(db_, kSchemaSql, err);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool tableHasColumn(sqlite3* db, const char* table, const char* column)
|
||||
{
|
||||
std::string sql = "PRAGMA table_info(";
|
||||
sql += table;
|
||||
sql += ")";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return false;
|
||||
bool found = false;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
{
|
||||
const char* name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||
if (name && column == std::string(name))
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return found;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Database::applySchemaMigrations(std::string& err)
|
||||
{
|
||||
const std::string ver = getMeta("schema_version").value_or("1");
|
||||
|
||||
if (ver == "1")
|
||||
{
|
||||
if (!execSql(db_,
|
||||
"CREATE TABLE IF NOT EXISTS sites ("
|
||||
"id TEXT PRIMARY KEY, name TEXT NOT NULL, "
|
||||
"created_at TEXT NOT NULL, updated_at TEXT NOT NULL)",
|
||||
err))
|
||||
return false;
|
||||
|
||||
if (!tableHasColumn(db_, "maps", "site_id"))
|
||||
{
|
||||
if (!execSql(db_, "ALTER TABLE maps ADD COLUMN site_id TEXT", err))
|
||||
return false;
|
||||
}
|
||||
if (!tableHasColumn(db_, "maps", "created_by"))
|
||||
{
|
||||
if (!execSql(db_, "ALTER TABLE maps ADD COLUMN created_by TEXT NOT NULL DEFAULT ''", err))
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"INSERT OR IGNORE INTO sites(id, name, created_at, updated_at) "
|
||||
"VALUES('site_configuration', 'ConfigurationSite', ?1, ?1)",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) == SQLITE_OK)
|
||||
{
|
||||
sqlite3_bind_text(stmt, 1, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"UPDATE maps SET site_id = 'site_configuration' WHERE site_id IS NULL OR site_id = ''",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) == SQLITE_OK)
|
||||
{
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
setMeta("schema_version", "2");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<std::string> Database::getMeta(const std::string& key) const
|
||||
{
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
@@ -400,6 +490,8 @@ bool Database::init(std::string& err)
|
||||
return false;
|
||||
if (!applySchema(err))
|
||||
return false;
|
||||
if (!applySchemaMigrations(err))
|
||||
return false;
|
||||
if (!ensureDataDirs(err))
|
||||
return false;
|
||||
if (!migrateFromJsonIfNeeded(err))
|
||||
|
||||
@@ -42,6 +42,7 @@ private:
|
||||
|
||||
bool openDb(std::string& err);
|
||||
bool applySchema(std::string& err);
|
||||
bool applySchemaMigrations(std::string& err);
|
||||
bool migrateFromJsonIfNeeded(std::string& err);
|
||||
bool ensureDataDirs(std::string& err);
|
||||
std::optional<std::string> getMeta(const std::string& key) const;
|
||||
|
||||
@@ -11,6 +11,11 @@ namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kMapSelect =
|
||||
"SELECT id, name, description, site_id, created_by, width, height, resolution, "
|
||||
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, created_at, updated_at "
|
||||
"FROM maps";
|
||||
|
||||
nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||
{
|
||||
auto textOrNull = [&](int col) -> nlohmann::json {
|
||||
@@ -25,11 +30,11 @@ nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||
};
|
||||
|
||||
nlohmann::json zones = nlohmann::json::array();
|
||||
if (sqlite3_column_type(stmt, 11) != SQLITE_NULL)
|
||||
if (sqlite3_column_type(stmt, 13) != SQLITE_NULL)
|
||||
{
|
||||
try
|
||||
{
|
||||
zones = nlohmann::json::parse(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 11)));
|
||||
zones = nlohmann::json::parse(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 13)));
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
@@ -40,17 +45,19 @@ nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||
return {{"id", textOrNull(0)},
|
||||
{"name", textOrNull(1)},
|
||||
{"description", textOrNull(2)},
|
||||
{"width", realOrNull(3)},
|
||||
{"height", realOrNull(4)},
|
||||
{"resolution", realOrNull(5)},
|
||||
{"origin_x", realOrNull(6)},
|
||||
{"origin_y", realOrNull(7)},
|
||||
{"origin_yaw", realOrNull(8)},
|
||||
{"image_file", textOrNull(9)},
|
||||
{"yaml_file", textOrNull(10)},
|
||||
{"site_id", textOrNull(3)},
|
||||
{"created_by", textOrNull(4)},
|
||||
{"width", realOrNull(5)},
|
||||
{"height", realOrNull(6)},
|
||||
{"resolution", realOrNull(7)},
|
||||
{"origin_x", realOrNull(8)},
|
||||
{"origin_y", realOrNull(9)},
|
||||
{"origin_yaw", realOrNull(10)},
|
||||
{"image_file", textOrNull(11)},
|
||||
{"yaml_file", textOrNull(12)},
|
||||
{"zones", zones},
|
||||
{"created_at", textOrNull(12)},
|
||||
{"updated_at", textOrNull(13)}};
|
||||
{"created_at", textOrNull(14)},
|
||||
{"updated_at", textOrNull(15)}};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -66,14 +73,9 @@ nlohmann::json MapStore::list() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
nlohmann::json maps = nlohmann::json::array();
|
||||
std::string sql = std::string(kMapSelect) + " ORDER BY name";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, name, description, width, height, resolution, "
|
||||
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
|
||||
"created_at, updated_at FROM maps ORDER BY name",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return maps;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
maps.push_back(rowToJson(stmt));
|
||||
@@ -84,14 +86,9 @@ nlohmann::json MapStore::list() const
|
||||
std::optional<nlohmann::json> MapStore::find(const std::string& id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
std::string sql = std::string(kMapSelect) + " WHERE id = ?1";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, name, description, width, height, resolution, "
|
||||
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
|
||||
"created_at, updated_at FROM maps WHERE id = ?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return std::nullopt;
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
std::optional<nlohmann::json> out;
|
||||
@@ -118,6 +115,8 @@ std::optional<nlohmann::json> MapStore::create(const nlohmann::json& payload, st
|
||||
const std::string id = payload.value("id", IdUtil::newId());
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
const std::string description = payload.value("description", "");
|
||||
const std::string site_id = payload.value("site_id", "");
|
||||
const std::string created_by = payload.value("created_by", "");
|
||||
const auto zones = payload.contains("zones") ? payload["zones"] : nlohmann::json::array();
|
||||
|
||||
std::error_code ec;
|
||||
@@ -131,10 +130,10 @@ std::optional<nlohmann::json> MapStore::create(const nlohmann::json& payload, st
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT INTO maps(id, name, description, width, height, resolution, "
|
||||
"INSERT INTO maps(id, name, description, site_id, created_by, width, height, resolution, "
|
||||
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
|
||||
"created_at, updated_at) "
|
||||
"VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
|
||||
"VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
@@ -146,27 +145,32 @@ std::optional<nlohmann::json> MapStore::create(const nlohmann::json& payload, st
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, description.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (payload.contains("width") && payload["width"].is_number())
|
||||
sqlite3_bind_double(stmt, 4, payload["width"].get<double>());
|
||||
else
|
||||
if (site_id.empty())
|
||||
sqlite3_bind_null(stmt, 4);
|
||||
if (payload.contains("height") && payload["height"].is_number())
|
||||
sqlite3_bind_double(stmt, 5, payload["height"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 5);
|
||||
if (payload.contains("resolution") && payload["resolution"].is_number())
|
||||
sqlite3_bind_double(stmt, 6, payload["resolution"].get<double>());
|
||||
sqlite3_bind_text(stmt, 4, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 5, created_by.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (payload.contains("width") && payload["width"].is_number())
|
||||
sqlite3_bind_double(stmt, 6, payload["width"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 6);
|
||||
sqlite3_bind_double(stmt, 7, payload.value("origin_x", 0.0));
|
||||
sqlite3_bind_double(stmt, 8, payload.value("origin_y", 0.0));
|
||||
sqlite3_bind_double(stmt, 9, payload.value("origin_yaw", 0.0));
|
||||
sqlite3_bind_null(stmt, 10);
|
||||
sqlite3_bind_null(stmt, 11);
|
||||
if (payload.contains("height") && payload["height"].is_number())
|
||||
sqlite3_bind_double(stmt, 7, payload["height"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 7);
|
||||
if (payload.contains("resolution") && payload["resolution"].is_number())
|
||||
sqlite3_bind_double(stmt, 8, payload["resolution"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 8);
|
||||
sqlite3_bind_double(stmt, 9, payload.value("origin_x", 0.0));
|
||||
sqlite3_bind_double(stmt, 10, payload.value("origin_y", 0.0));
|
||||
sqlite3_bind_double(stmt, 11, payload.value("origin_yaw", 0.0));
|
||||
sqlite3_bind_null(stmt, 12);
|
||||
sqlite3_bind_null(stmt, 13);
|
||||
const std::string zones_str = zones.dump();
|
||||
sqlite3_bind_text(stmt, 12, zones_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 13, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 14, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 14, zones_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 15, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 16, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE)
|
||||
{
|
||||
@@ -180,6 +184,8 @@ std::optional<nlohmann::json> MapStore::create(const nlohmann::json& payload, st
|
||||
created["id"] = id;
|
||||
created["name"] = name;
|
||||
created["description"] = description;
|
||||
created["site_id"] = site_id.empty() ? nullptr : nlohmann::json(site_id);
|
||||
created["created_by"] = created_by;
|
||||
if (payload.contains("width") && payload["width"].is_number())
|
||||
created["width"] = payload["width"];
|
||||
if (payload.contains("height") && payload["height"].is_number())
|
||||
@@ -207,7 +213,8 @@ bool MapStore::update(const std::string& id, const nlohmann::json& payload, std:
|
||||
}
|
||||
|
||||
nlohmann::json merged = *existing;
|
||||
for (const char* key : {"name", "description", "width", "height", "resolution", "origin_x", "origin_y", "origin_yaw"})
|
||||
for (const char* key :
|
||||
{"name", "description", "site_id", "created_by", "width", "height", "resolution", "origin_x", "origin_y", "origin_yaw"})
|
||||
{
|
||||
if (payload.contains(key))
|
||||
merged[key] = payload[key];
|
||||
@@ -221,8 +228,9 @@ bool MapStore::update(const std::string& id, const nlohmann::json& payload, std:
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"UPDATE maps SET name=?2, description=?3, width=?4, height=?5, resolution=?6, "
|
||||
"origin_x=?7, origin_y=?8, origin_yaw=?9, zones_json=?10, updated_at=?11 WHERE id=?1",
|
||||
"UPDATE maps SET name=?2, description=?3, site_id=?4, created_by=?5, width=?6, height=?7, "
|
||||
"resolution=?8, origin_x=?9, origin_y=?10, origin_yaw=?11, zones_json=?12, updated_at=?13 "
|
||||
"WHERE id=?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
@@ -234,23 +242,29 @@ bool MapStore::update(const std::string& id, const nlohmann::json& payload, std:
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, merged.value("name", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, merged.value("description", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (merged["width"].is_number())
|
||||
sqlite3_bind_double(stmt, 4, merged["width"].get<double>());
|
||||
else
|
||||
const std::string site_id = merged.value("site_id", "");
|
||||
if (site_id.empty())
|
||||
sqlite3_bind_null(stmt, 4);
|
||||
if (merged["height"].is_number())
|
||||
sqlite3_bind_double(stmt, 5, merged["height"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 5);
|
||||
if (merged["resolution"].is_number())
|
||||
sqlite3_bind_double(stmt, 6, merged["resolution"].get<double>());
|
||||
sqlite3_bind_text(stmt, 4, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 5, merged.value("created_by", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (merged["width"].is_number())
|
||||
sqlite3_bind_double(stmt, 6, merged["width"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 6);
|
||||
sqlite3_bind_double(stmt, 7, merged.value("origin_x", 0.0));
|
||||
sqlite3_bind_double(stmt, 8, merged.value("origin_y", 0.0));
|
||||
sqlite3_bind_double(stmt, 9, merged.value("origin_yaw", 0.0));
|
||||
sqlite3_bind_text(stmt, 10, zones_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 11, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (merged["height"].is_number())
|
||||
sqlite3_bind_double(stmt, 7, merged["height"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 7);
|
||||
if (merged["resolution"].is_number())
|
||||
sqlite3_bind_double(stmt, 8, merged["resolution"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 8);
|
||||
sqlite3_bind_double(stmt, 9, merged.value("origin_x", 0.0));
|
||||
sqlite3_bind_double(stmt, 10, merged.value("origin_y", 0.0));
|
||||
sqlite3_bind_double(stmt, 11, merged.value("origin_yaw", 0.0));
|
||||
sqlite3_bind_text(stmt, 12, zones_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 13, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
|
||||
242
src/storage/site_store.cpp
Normal file
242
src/storage/site_store.cpp
Normal file
@@ -0,0 +1,242 @@
|
||||
#include "storage/site_store.hpp"
|
||||
|
||||
#include "storage/database.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kDefaultSiteId = "site_configuration";
|
||||
constexpr const char* kDefaultSiteName = "ConfigurationSite";
|
||||
|
||||
nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||
{
|
||||
auto textOrNull = [&](int col) -> nlohmann::json {
|
||||
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
|
||||
return nullptr;
|
||||
return nlohmann::json(reinterpret_cast<const char*>(sqlite3_column_text(stmt, col)));
|
||||
};
|
||||
return {{"id", textOrNull(0)},
|
||||
{"name", textOrNull(1)},
|
||||
{"created_at", textOrNull(2)},
|
||||
{"updated_at", textOrNull(3)}};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SiteStore::SiteStore(Database& db) : db_(db) {}
|
||||
|
||||
std::string SiteStore::ensureDefaultSiteId()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(), "SELECT id FROM sites WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return kDefaultSiteId;
|
||||
sqlite3_bind_text(stmt, 1, kDefaultSiteId, -1, SQLITE_STATIC);
|
||||
bool exists = sqlite3_step(stmt) == SQLITE_ROW;
|
||||
sqlite3_finalize(stmt);
|
||||
if (exists)
|
||||
return kDefaultSiteId;
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT INTO sites(id, name, created_at, updated_at) VALUES(?1,?2,?3,?4)",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return kDefaultSiteId;
|
||||
sqlite3_bind_text(stmt, 1, kDefaultSiteId, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(stmt, 2, kDefaultSiteName, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 4, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return kDefaultSiteId;
|
||||
}
|
||||
|
||||
nlohmann::json SiteStore::list() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
nlohmann::json sites = nlohmann::json::array();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, name, created_at, updated_at FROM sites ORDER BY name",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return sites;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
sites.push_back(rowToJson(stmt));
|
||||
sqlite3_finalize(stmt);
|
||||
return sites;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> SiteStore::find(const std::string& id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, name, created_at, updated_at FROM sites WHERE id = ?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return std::nullopt;
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
std::optional<nlohmann::json> out;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
out = rowToJson(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> SiteStore::create(const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
if (!payload.is_object())
|
||||
{
|
||||
err = "payload must be an object";
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::string name = StringUtil::trimCopy(payload.value("name", ""));
|
||||
if (name.empty())
|
||||
{
|
||||
err = "name is required";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::string id = payload.value("id", IdUtil::newId());
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT INTO sites(id, name, created_at, updated_at) VALUES(?1,?2,?3,?4)",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return std::nullopt;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 4, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return std::nullopt;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
return nlohmann::json{{"id", id}, {"name", name}, {"created_at", now}, {"updated_at", now}};
|
||||
}
|
||||
|
||||
bool SiteStore::update(const std::string& id, const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
auto existing = find(id);
|
||||
if (!existing)
|
||||
{
|
||||
err = "site not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
nlohmann::json merged = *existing;
|
||||
if (payload.contains("name"))
|
||||
merged["name"] = payload["name"];
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"UPDATE sites SET name = ?2, updated_at = ?3 WHERE id = ?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, merged.value("name", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool SiteStore::remove(const std::string& id, std::string& err)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
|
||||
sqlite3_stmt* check = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(), "SELECT id FROM sites WHERE id = ?1", -1, &check, nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(check, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (sqlite3_step(check) != SQLITE_ROW)
|
||||
{
|
||||
sqlite3_finalize(check);
|
||||
err = "site not found";
|
||||
return false;
|
||||
}
|
||||
sqlite3_finalize(check);
|
||||
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT COUNT(*) FROM maps WHERE site_id = ?1",
|
||||
-1,
|
||||
&check,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(check, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
int map_count = 0;
|
||||
if (sqlite3_step(check) == SQLITE_ROW)
|
||||
map_count = sqlite3_column_int(check, 0);
|
||||
sqlite3_finalize(check);
|
||||
if (map_count > 0)
|
||||
{
|
||||
err = "site has maps and cannot be deleted";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sqlite3_prepare_v2(db_.handle(), "SELECT COUNT(*) FROM sites", -1, &check, nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
int site_count = 0;
|
||||
if (sqlite3_step(check) == SQLITE_ROW)
|
||||
site_count = sqlite3_column_int(check, 0);
|
||||
sqlite3_finalize(check);
|
||||
if (site_count <= 1)
|
||||
{
|
||||
err = "cannot delete the last site";
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM sites WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
30
src/storage/site_store.hpp
Normal file
30
src/storage/site_store.hpp
Normal file
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
class SiteStore
|
||||
{
|
||||
public:
|
||||
explicit SiteStore(Database& db);
|
||||
|
||||
nlohmann::json list() const;
|
||||
std::optional<nlohmann::json> find(const std::string& id) const;
|
||||
std::optional<nlohmann::json> create(const nlohmann::json& payload, std::string& err);
|
||||
bool update(const std::string& id, const nlohmann::json& payload, std::string& err);
|
||||
bool remove(const std::string& id, std::string& err);
|
||||
std::string ensureDefaultSiteId();
|
||||
|
||||
private:
|
||||
Database& db_;
|
||||
mutable std::mutex mu_;
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
10
www/app.js
10
www/app.js
@@ -7,6 +7,7 @@ const listEl = el("lidarList");
|
||||
const lidarFormHintEl = el("lidarFormHint");
|
||||
const pageOverviewEl = el("pageOverview");
|
||||
const pageConfigEl = el("pageConfig");
|
||||
const pageMapsEl = el("pageMaps");
|
||||
const pageMissionsEl = el("pageMissions");
|
||||
const pageIntegrationsEl = el("pageIntegrations");
|
||||
const pageMonitoringEl = el("pageMonitoring");
|
||||
@@ -123,8 +124,8 @@ const state = {
|
||||
};
|
||||
|
||||
function setActivePage(page) {
|
||||
const valid = ["dashboard", "config", "missions", "integrations", "monitoring", "help"];
|
||||
let p = valid.includes(page) ? page : "config";
|
||||
const valid = ["dashboard", "config", "maps", "missions", "integrations", "monitoring", "help"];
|
||||
let p = valid.includes(page) ? page : "missions";
|
||||
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
|
||||
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
|
||||
p = fallback || "dashboard";
|
||||
@@ -132,6 +133,7 @@ function setActivePage(page) {
|
||||
if (page === "overview") p = "dashboard";
|
||||
if (pageOverviewEl) pageOverviewEl.hidden = p !== "dashboard";
|
||||
if (pageConfigEl) pageConfigEl.hidden = p !== "config";
|
||||
if (pageMapsEl) pageMapsEl.hidden = p !== "maps";
|
||||
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
|
||||
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
||||
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
||||
@@ -141,6 +143,7 @@ function setActivePage(page) {
|
||||
if (contentEl) {
|
||||
contentEl.classList.toggle("content--dashboard", p === "dashboard");
|
||||
contentEl.classList.toggle("content--config", p === "config");
|
||||
contentEl.classList.toggle("content--maps", p === "maps");
|
||||
contentEl.classList.toggle("content--missions", p === "missions");
|
||||
contentEl.classList.toggle("content--integrations", p === "integrations");
|
||||
contentEl.classList.toggle("content--monitoring", p === "monitoring");
|
||||
@@ -148,6 +151,7 @@ function setActivePage(page) {
|
||||
}
|
||||
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
|
||||
else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide();
|
||||
if (p === "maps" && window.MapsApp) window.MapsApp.onPageShow();
|
||||
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
||||
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
|
||||
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
|
||||
@@ -162,7 +166,7 @@ function setActivePage(page) {
|
||||
|
||||
function initNavigation() {
|
||||
if (window.NavApp?.init) window.NavApp.init();
|
||||
else setActivePage("config");
|
||||
else setActivePage("missions");
|
||||
}
|
||||
|
||||
window.LmApp = { setActivePage };
|
||||
|
||||
13
www/auth.js
13
www/auth.js
@@ -138,10 +138,16 @@
|
||||
return perms[resource] || "none";
|
||||
}
|
||||
|
||||
function isDistributor() {
|
||||
return currentUser?.group_id === "group_distributors";
|
||||
}
|
||||
|
||||
function canAccessPage(page) {
|
||||
if (page === "config") return isDistributor();
|
||||
|
||||
const map = {
|
||||
dashboard: "dashboard",
|
||||
config: "config",
|
||||
maps: "maps",
|
||||
missions: "missions",
|
||||
integrations: "integrations",
|
||||
};
|
||||
@@ -159,6 +165,7 @@
|
||||
window.NavApp.applyPermissions();
|
||||
}
|
||||
document.body.classList.toggle("auth-readonly-config", !canWrite("config"));
|
||||
document.body.classList.toggle("auth-readonly-maps", !canWrite("maps"));
|
||||
document.body.classList.toggle("auth-readonly-missions", !canWrite("missions"));
|
||||
document.body.classList.toggle("auth-readonly-integrations", !canWrite("integrations"));
|
||||
}
|
||||
@@ -212,6 +219,10 @@
|
||||
async function tryRestoreSession() {
|
||||
try {
|
||||
const data = await apiJson("/api/auth/me");
|
||||
if (!data?.user) {
|
||||
lockApp();
|
||||
return false;
|
||||
}
|
||||
currentUser = data.user;
|
||||
unlockApp();
|
||||
return true;
|
||||
|
||||
722
www/dashboard.js
722
www/dashboard.js
@@ -3,6 +3,18 @@
|
||||
const STORAGE_KEY_V2 = "phenikaax_dashboard_v2";
|
||||
const PAGE_SIZE = 10;
|
||||
const DEFAULT_ID = "dashboard_default";
|
||||
const GRID_COLS = 12;
|
||||
const GRID_ROW_PX = 52;
|
||||
const GRID_GAP = 10;
|
||||
const DEFAULT_W = 4;
|
||||
const DEFAULT_H = 3;
|
||||
const MIN_W = 2;
|
||||
const MIN_H = 2;
|
||||
const MAX_W = 12;
|
||||
const MAX_H = 8;
|
||||
|
||||
let dragSession = null;
|
||||
let resizeSession = null;
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
@@ -18,6 +30,10 @@
|
||||
const listCountEl = el("dashboardListCount");
|
||||
const pageLabelEl = el("dashboardPageLabel");
|
||||
const designerTitleEl = el("dashboardDesignerTitle");
|
||||
const designerToolbarEl = el("dashboardDesignerToolbar");
|
||||
const editModeBtnEl = el("dashboardEditModeBtn");
|
||||
const saveBtnEl = el("dashboardSaveBtn");
|
||||
let activeWidgetTab = "missions";
|
||||
const editDialogEl = el("dashboardEditDialog");
|
||||
const permissionsDialogEl = el("dashboardPermissionsDialog");
|
||||
const addDialogEl = el("dashboardAddWidgetDialog");
|
||||
@@ -38,8 +54,229 @@
|
||||
pollActive: false,
|
||||
queueUnsub: null,
|
||||
userGroups: [],
|
||||
maps: [],
|
||||
mapsLoaded: false,
|
||||
robotPose: null,
|
||||
robotPoseAt: 0,
|
||||
mapPollTimer: null,
|
||||
};
|
||||
|
||||
function hasMapWidget(widgets = activeWidgets()) {
|
||||
return widgets.some((w) => w.type === "map" || w.type === "map_locked");
|
||||
}
|
||||
|
||||
async function ensureMapsLoaded(force = false) {
|
||||
if (store.mapsLoaded && !force) return store.maps;
|
||||
try {
|
||||
const res = await fetch("/api/maps", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
store.maps = Array.isArray(data.maps) ? data.maps : [];
|
||||
} else {
|
||||
store.maps = [];
|
||||
}
|
||||
} catch {
|
||||
store.maps = [];
|
||||
}
|
||||
store.mapsLoaded = true;
|
||||
return store.maps;
|
||||
}
|
||||
|
||||
async function fetchRobotPose(force = false) {
|
||||
const now = Date.now();
|
||||
if (!force && store.robotPoseAt && now - store.robotPoseAt < 1200) return store.robotPose;
|
||||
try {
|
||||
const res = await fetch("/api/robot/status", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const pose = data.pose && typeof data.pose === "object" ? data.pose : {};
|
||||
store.robotPose = {
|
||||
x: Number(pose.x) || 0,
|
||||
y: Number(pose.y) || 0,
|
||||
yaw: Number(pose.yaw) || 0,
|
||||
mapId: data.active_map_id || null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* keep last pose */
|
||||
}
|
||||
store.robotPoseAt = Date.now();
|
||||
return store.robotPose;
|
||||
}
|
||||
|
||||
function mapMeta(map) {
|
||||
return {
|
||||
resolution: Number(map?.resolution) || 0.05,
|
||||
originX: Number(map?.origin_x) || 0,
|
||||
originY: Number(map?.origin_y) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function worldToPixel(map, imgW, imgH, wx, wy) {
|
||||
const { resolution, originX, originY } = mapMeta(map);
|
||||
return {
|
||||
x: (wx - originX) / resolution,
|
||||
y: imgH - (wy - originY) / resolution,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultMapPose(map, imgW, imgH) {
|
||||
const { resolution, originX, originY } = mapMeta(map);
|
||||
return {
|
||||
x: originX + (imgW * resolution) / 2,
|
||||
y: originY + (imgH * resolution) / 2,
|
||||
yaw: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWidgetMap(widget) {
|
||||
const preferred = normalizeStr(widget.map_id) || store.robotPose?.mapId || "";
|
||||
if (preferred) {
|
||||
const hit = store.maps.find((m) => m.id === preferred);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return store.maps[0] || null;
|
||||
}
|
||||
|
||||
function poseForWidget(map, imgW, imgH) {
|
||||
const pose = store.robotPose;
|
||||
if (pose && (!pose.mapId || pose.mapId === map.id)) {
|
||||
return { x: pose.x, y: pose.y, yaw: pose.yaw };
|
||||
}
|
||||
return defaultMapPose(map, imgW, imgH);
|
||||
}
|
||||
|
||||
function mapOptions(selected = "", includeActive = true) {
|
||||
const opts = [];
|
||||
if (includeActive) {
|
||||
opts.push(
|
||||
`<option value="" ${!selected ? "selected" : ""}>${escapeHtml(t("dashboard.widget.mapActive"))}</option>`
|
||||
);
|
||||
}
|
||||
store.maps.forEach((map) => {
|
||||
opts.push(
|
||||
`<option value="${escapeHtml(map.id)}" ${map.id === selected ? "selected" : ""}>${escapeHtml(map.name || map.id)}</option>`
|
||||
);
|
||||
});
|
||||
return opts.join("");
|
||||
}
|
||||
|
||||
function mapImageUrl(map) {
|
||||
if (!map?.id || !map.image_file) return null;
|
||||
return `/api/maps/${encodeURIComponent(map.id)}/image`;
|
||||
}
|
||||
|
||||
function applyMapLayout(viewportEl, map, locked, pose, imgW, imgH) {
|
||||
if (!viewportEl || !map) return;
|
||||
const width = imgW || 800;
|
||||
const height = imgH || 600;
|
||||
const px = worldToPixel(map, width, height, pose.x, pose.y);
|
||||
const vw = viewportEl.clientWidth || 1;
|
||||
const vh = viewportEl.clientHeight || 1;
|
||||
const layer = viewportEl.querySelector("[data-map-layer]");
|
||||
const marker = viewportEl.querySelector("[data-map-marker]");
|
||||
if (!layer || !marker) return;
|
||||
|
||||
if (locked) {
|
||||
const scale = Math.max(vw / width, vh / height) * 1.35;
|
||||
const tx = vw / 2 - px.x * scale;
|
||||
const ty = vh / 2 - px.y * scale;
|
||||
layer.style.width = `${width}px`;
|
||||
layer.style.height = `${height}px`;
|
||||
layer.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
|
||||
marker.style.left = `${vw / 2}px`;
|
||||
marker.style.top = `${vh / 2}px`;
|
||||
} else {
|
||||
const scale = Math.min(vw / width, vh / height);
|
||||
const offsetX = (vw - width * scale) / 2;
|
||||
const offsetY = (vh - height * scale) / 2;
|
||||
layer.style.width = `${width}px`;
|
||||
layer.style.height = `${height}px`;
|
||||
layer.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
||||
marker.style.left = `${offsetX + px.x * scale}px`;
|
||||
marker.style.top = `${offsetY + px.y * scale}px`;
|
||||
}
|
||||
marker.style.transform = `translate(-50%, -50%) rotate(${pose.yaw}rad)`;
|
||||
viewportEl.dataset.mapId = map.id;
|
||||
}
|
||||
|
||||
function mountMapViewport(viewportEl, map, locked) {
|
||||
const imgUrl = mapImageUrl(map);
|
||||
const label = escapeHtml(map.name || map.id);
|
||||
viewportEl.classList.toggle("dashboardMapViewport--locked", locked);
|
||||
viewportEl.innerHTML = imgUrl
|
||||
? `
|
||||
<div class="dashboardMapLayer" data-map-layer>
|
||||
<img class="dashboardMapImage" data-map-image src="${escapeHtml(imgUrl)}" alt="${label}" draggable="false" />
|
||||
</div>
|
||||
<div class="dashboardMapMarker" data-map-marker aria-hidden="true"></div>`
|
||||
: `
|
||||
<div class="dashboardMapLayer dashboardMapLayer--grid" data-map-layer>
|
||||
<div class="dashboardMapGridFallback">
|
||||
<span class="dashboardMapGridTitle">${label}</span>
|
||||
<span class="mutedNote">${escapeHtml(t("dashboard.widget.mapNoImage"))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboardMapMarker" data-map-marker aria-hidden="true"></div>`;
|
||||
|
||||
const imgEl = viewportEl.querySelector("[data-map-image]");
|
||||
const layout = () => {
|
||||
const w = imgEl?.naturalWidth || 800;
|
||||
const h = imgEl?.naturalHeight || 600;
|
||||
const pose = poseForWidget(map, w, h);
|
||||
applyMapLayout(viewportEl, map, locked, pose, w, h);
|
||||
};
|
||||
|
||||
if (imgEl) {
|
||||
if (imgEl.complete) layout();
|
||||
else {
|
||||
imgEl.addEventListener("load", layout);
|
||||
imgEl.addEventListener("error", () => {
|
||||
viewportEl.innerHTML = `<div class="dashboardMapEmpty">${escapeHtml(t("dashboard.widget.mapImageError"))}</div>`;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
layout();
|
||||
}
|
||||
|
||||
if (!viewportEl.dataset.roObserved) {
|
||||
viewportEl.dataset.roObserved = "1";
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
const ro = new ResizeObserver(() => layout());
|
||||
ro.observe(viewportEl);
|
||||
viewportEl._mapRo = ro;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function layoutMapWidget(widget, bodyEl, locked) {
|
||||
let viewportEl = bodyEl.querySelector("[data-map-viewport]");
|
||||
if (!viewportEl) {
|
||||
bodyEl.innerHTML = `<div class="dashboardMapViewport${locked ? " dashboardMapViewport--locked" : ""}" data-map-viewport></div>`;
|
||||
viewportEl = bodyEl.querySelector("[data-map-viewport]");
|
||||
}
|
||||
|
||||
await ensureMapsLoaded();
|
||||
await fetchRobotPose();
|
||||
|
||||
const map = resolveWidgetMap(widget);
|
||||
if (!map) {
|
||||
bodyEl.innerHTML = `<div class="dashboardMapEmpty">${escapeHtml(t("dashboard.widget.mapEmpty"))}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
mountMapViewport(viewportEl, map, locked);
|
||||
}
|
||||
|
||||
function refreshMapWidgets() {
|
||||
activeWidgets().forEach((widget) => {
|
||||
if (widget.type !== "map" && widget.type !== "map_locked") return;
|
||||
const bodyEl = gridEl?.querySelector(`[data-widget-id="${widget.id}"] .dashboardWidgetBody`);
|
||||
if (!bodyEl) return;
|
||||
void layoutMapWidget(widget, bodyEl, widget.type === "map_locked");
|
||||
});
|
||||
}
|
||||
|
||||
function widgetTypeLabel(type) {
|
||||
return t(`dashboard.widget.${type}`) || type;
|
||||
}
|
||||
@@ -99,6 +336,108 @@
|
||||
if (db) db.widgets = widgets;
|
||||
}
|
||||
|
||||
function clamp(n, min, max) {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
function hasGridPos(widget) {
|
||||
return Number.isFinite(widget?.col) && Number.isFinite(widget?.row);
|
||||
}
|
||||
|
||||
function normalizeWidget(widget) {
|
||||
if (!widget || typeof widget !== "object") return widget;
|
||||
widget.w = clamp(Number(widget.w) || DEFAULT_W, MIN_W, MAX_W);
|
||||
widget.h = clamp(Number(widget.h) || DEFAULT_H, MIN_H, MAX_H);
|
||||
if (widget.col != null) widget.col = clamp(Math.round(Number(widget.col)), 1, GRID_COLS);
|
||||
if (widget.row != null) widget.row = Math.max(1, Math.round(Number(widget.row)));
|
||||
return widget;
|
||||
}
|
||||
|
||||
function gridMetrics() {
|
||||
const rect = gridEl.getBoundingClientRect();
|
||||
const colW = (rect.width - GRID_GAP * (GRID_COLS - 1)) / GRID_COLS;
|
||||
return { rect, colW, rowH: GRID_ROW_PX, gap: GRID_GAP };
|
||||
}
|
||||
|
||||
function pointerToGrid(clientX, clientY) {
|
||||
const { rect, colW, rowH, gap } = gridMetrics();
|
||||
const x = Math.max(0, clientX - rect.left);
|
||||
const y = Math.max(0, clientY - rect.top);
|
||||
const col = clamp(Math.floor(x / (colW + gap)) + 1, 1, GRID_COLS);
|
||||
const row = Math.max(1, Math.floor(y / (rowH + gap)) + 1);
|
||||
return { col, row };
|
||||
}
|
||||
|
||||
function rectsOverlap(c1, r1, w1, h1, c2, r2, w2, h2) {
|
||||
return c1 < c2 + w2 && c1 + w1 > c2 && r1 < r2 + h2 && r1 + h1 > r2;
|
||||
}
|
||||
|
||||
function widgetFits(widgets, ignoreId, col, row, w, h) {
|
||||
if (col < 1 || row < 1 || col + w - 1 > GRID_COLS) return false;
|
||||
return !widgets.some((other) => {
|
||||
if (other.id === ignoreId || !hasGridPos(other)) return false;
|
||||
return rectsOverlap(col, row, w, h, other.col, other.row, other.w, other.h);
|
||||
});
|
||||
}
|
||||
|
||||
function findFreeGridSpot(widgets, w, h) {
|
||||
const maxRow = widgets.reduce((max, item) => {
|
||||
if (!hasGridPos(item)) return max;
|
||||
return Math.max(max, item.row + item.h);
|
||||
}, 4);
|
||||
for (let row = 1; row <= maxRow + 6; row += 1) {
|
||||
for (let col = 1; col <= GRID_COLS - w + 1; col += 1) {
|
||||
if (widgetFits(widgets, null, col, row, w, h)) return { col, row };
|
||||
}
|
||||
}
|
||||
return { col: 1, row: maxRow + 1 };
|
||||
}
|
||||
|
||||
function ensureWidgetPositions(widgets) {
|
||||
let cursorCol = 1;
|
||||
let cursorRow = 1;
|
||||
let rowHeight = 0;
|
||||
|
||||
widgets.forEach((widget) => {
|
||||
normalizeWidget(widget);
|
||||
if (hasGridPos(widget)) return;
|
||||
|
||||
if (cursorCol + widget.w - 1 > GRID_COLS) {
|
||||
cursorRow += rowHeight || widget.h;
|
||||
cursorCol = 1;
|
||||
rowHeight = 0;
|
||||
}
|
||||
|
||||
widget.col = cursorCol;
|
||||
widget.row = cursorRow;
|
||||
cursorCol += widget.w;
|
||||
rowHeight = Math.max(rowHeight, widget.h);
|
||||
|
||||
if (cursorCol > GRID_COLS) {
|
||||
cursorRow += rowHeight;
|
||||
cursorCol = 1;
|
||||
rowHeight = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateGridCanvasHeight(widgets) {
|
||||
if (!gridEl) return;
|
||||
let maxRowEnd = 4;
|
||||
widgets.forEach((widget) => {
|
||||
if (hasGridPos(widget)) maxRowEnd = Math.max(maxRowEnd, widget.row + widget.h - 1);
|
||||
});
|
||||
const h = maxRowEnd * GRID_ROW_PX + Math.max(0, maxRowEnd - 1) * GRID_GAP;
|
||||
gridEl.style.minHeight = `${h}px`;
|
||||
}
|
||||
|
||||
function normalizeDashboards() {
|
||||
store.dashboards.forEach((db) => {
|
||||
if (!Array.isArray(db.widgets)) db.widgets = [];
|
||||
ensureWidgetPositions(db.widgets);
|
||||
});
|
||||
}
|
||||
|
||||
function bootstrapDefaultDashboard(widgets = []) {
|
||||
store.dashboards = [
|
||||
{
|
||||
@@ -140,6 +479,7 @@
|
||||
store.activeDashboardId = data.activeDashboardId || store.dashboards[0]?.id || null;
|
||||
if (!store.dashboards.length) bootstrapDefaultDashboard();
|
||||
else if (!store.activeDashboardId) store.activeDashboardId = store.dashboards[0].id;
|
||||
normalizeDashboards();
|
||||
} catch {
|
||||
bootstrapDefaultDashboard();
|
||||
}
|
||||
@@ -159,6 +499,7 @@
|
||||
store.activeDashboardId = data.activeDashboardId || store.dashboards[0]?.id || null;
|
||||
if (!store.dashboards.length) bootstrapDefaultDashboard();
|
||||
else if (!store.activeDashboardId) store.activeDashboardId = store.dashboards[0].id;
|
||||
normalizeDashboards();
|
||||
} catch {
|
||||
loadStoreLocal();
|
||||
}
|
||||
@@ -253,6 +594,7 @@
|
||||
|
||||
function setView(view) {
|
||||
store.view = view;
|
||||
document.querySelector(".dashboardShell")?.classList.toggle("dashboardShell--designer", view === "designer");
|
||||
if (listViewEl) listViewEl.hidden = view !== "list";
|
||||
if (createViewEl) createViewEl.hidden = view !== "create";
|
||||
if (designerViewEl) designerViewEl.hidden = view !== "designer";
|
||||
@@ -262,6 +604,7 @@
|
||||
} else if (view === "create") {
|
||||
stopDashboardPoll();
|
||||
} else {
|
||||
syncDesignerEditMode();
|
||||
renderDesignerChrome();
|
||||
renderDashboard();
|
||||
startDashboardPoll();
|
||||
@@ -332,9 +675,164 @@
|
||||
if (createBtn) createBtn.disabled = !canEditDashboardsModule();
|
||||
}
|
||||
|
||||
function setWidgetTab(tab) {
|
||||
activeWidgetTab = tab;
|
||||
designerToolbarEl?.querySelectorAll("[data-widget-tab]").forEach((btn) => {
|
||||
const active = btn.dataset.widgetTab === tab;
|
||||
btn.classList.toggle("is-active", active);
|
||||
btn.setAttribute("aria-selected", active ? "true" : "false");
|
||||
});
|
||||
designerToolbarEl?.querySelectorAll("[data-panel]").forEach((panel) => {
|
||||
panel.hidden = panel.dataset.panel !== tab;
|
||||
});
|
||||
}
|
||||
|
||||
function renderDesignerChrome() {
|
||||
const db = activeDashboard();
|
||||
if (designerTitleEl) designerTitleEl.textContent = db?.name || "—";
|
||||
const canEdit = dashboardCanEdit(db);
|
||||
if (designerToolbarEl) designerToolbarEl.hidden = !canEdit || !store.editMode;
|
||||
if (saveBtnEl) saveBtnEl.hidden = !canEdit || !store.editMode;
|
||||
if (editModeBtnEl) {
|
||||
editModeBtnEl.hidden = !canEdit;
|
||||
editModeBtnEl.textContent = store.editMode ? t("dashboard.editDone") : t("dashboard.editLayout");
|
||||
editModeBtnEl.setAttribute("aria-pressed", store.editMode ? "true" : "false");
|
||||
}
|
||||
if (!designerToolbarEl?.hidden) setWidgetTab(activeWidgetTab);
|
||||
}
|
||||
|
||||
function syncDesignerEditMode() {
|
||||
/* operate mode by default — edit toggled explicitly */
|
||||
}
|
||||
|
||||
function addWidget(type) {
|
||||
const db = activeDashboard();
|
||||
if (!db || !store.editMode || !dashboardCanEdit(db)) return;
|
||||
const isMap = type === "map_locked" || type === "map";
|
||||
const widget = normalizeWidget({
|
||||
id: newId("w"),
|
||||
type,
|
||||
title: type === "mission_queue" ? "Mission queue" : "",
|
||||
w: isMap ? 6 : DEFAULT_W,
|
||||
h: isMap ? 6 : DEFAULT_H,
|
||||
});
|
||||
const spot = findFreeGridSpot(db.widgets, widget.w, widget.h);
|
||||
widget.col = spot.col;
|
||||
widget.row = spot.row;
|
||||
db.widgets.push(widget);
|
||||
persistStore();
|
||||
renderDashboard();
|
||||
if (type === "mission_button" || type === "mission_group") openEditDialog(widget.id);
|
||||
else if (type === "map_locked" || type === "map") openEditDialog(widget.id);
|
||||
}
|
||||
|
||||
function applyWidgetGridStyle(card, widget) {
|
||||
normalizeWidget(widget);
|
||||
card.style.setProperty("--dw", String(widget.w));
|
||||
card.style.setProperty("--dh", String(widget.h));
|
||||
if (hasGridPos(widget)) {
|
||||
card.style.gridColumn = `${widget.col} / span ${widget.w}`;
|
||||
card.style.gridRow = `${widget.row} / span ${widget.h}`;
|
||||
} else {
|
||||
card.style.gridColumn = `span ${widget.w}`;
|
||||
card.style.gridRow = `span ${widget.h}`;
|
||||
}
|
||||
}
|
||||
|
||||
function startWidgetMove(evt, widget, card) {
|
||||
if (!store.editMode || resizeSession || dragSession) return;
|
||||
if (evt.button !== 0) return;
|
||||
if (!hasGridPos(widget)) return;
|
||||
|
||||
evt.preventDefault();
|
||||
const startCol = widget.col;
|
||||
const startRow = widget.row;
|
||||
const grab = pointerToGrid(evt.clientX, evt.clientY);
|
||||
const grabColOff = grab.col - widget.col;
|
||||
const grabRowOff = grab.row - widget.row;
|
||||
|
||||
dragSession = { widgetId: widget.id };
|
||||
card.classList.add("is-dragging");
|
||||
document.body.classList.add("dashboard-widget-dragging");
|
||||
|
||||
const onMove = (e) => {
|
||||
const pt = pointerToGrid(e.clientX, e.clientY);
|
||||
let col = pt.col - grabColOff;
|
||||
let row = pt.row - grabRowOff;
|
||||
col = clamp(col, 1, GRID_COLS - widget.w + 1);
|
||||
row = Math.max(1, row);
|
||||
widget.col = col;
|
||||
widget.row = row;
|
||||
applyWidgetGridStyle(card, widget);
|
||||
updateGridCanvasHeight(activeWidgets());
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
card.classList.remove("is-dragging");
|
||||
document.body.classList.remove("dashboard-widget-dragging");
|
||||
dragSession = null;
|
||||
|
||||
const widgets = activeWidgets();
|
||||
if (!widgetFits(widgets, widget.id, widget.col, widget.row, widget.w, widget.h)) {
|
||||
widget.col = startCol;
|
||||
widget.row = startRow;
|
||||
applyWidgetGridStyle(card, widget);
|
||||
} else {
|
||||
persistStore();
|
||||
}
|
||||
updateGridCanvasHeight(widgets);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
function startWidgetResize(evt, widget, card) {
|
||||
if (!store.editMode) return;
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const startX = evt.clientX;
|
||||
const startY = evt.clientY;
|
||||
const startW = widget.w;
|
||||
const startH = widget.h;
|
||||
const colW = gridEl.clientWidth / GRID_COLS;
|
||||
|
||||
const onMove = (e) => {
|
||||
const dw = Math.round((e.clientX - startX) / colW);
|
||||
const dh = Math.round((e.clientY - startY) / GRID_ROW_PX);
|
||||
widget.w = clamp(startW + dw, MIN_W, MAX_W);
|
||||
widget.h = clamp(startH + dh, MIN_H, MAX_H);
|
||||
applyWidgetGridStyle(card, widget);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
persistStore();
|
||||
resizeSession = null;
|
||||
};
|
||||
resizeSession = { widgetId: widget.id };
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
function attachWidgetInteractions(card, widget) {
|
||||
applyWidgetGridStyle(card, widget);
|
||||
const header = card.querySelector(".dashboardWidgetHeader");
|
||||
const pen = card.querySelector("[data-widget-config]");
|
||||
const resize = card.querySelector("[data-widget-resize]");
|
||||
|
||||
pen?.addEventListener("click", (evt) => {
|
||||
evt.stopPropagation();
|
||||
openEditDialog(widget.id);
|
||||
});
|
||||
|
||||
resize?.addEventListener("mousedown", (evt) => startWidgetResize(evt, widget, card));
|
||||
|
||||
if (!store.editMode) return;
|
||||
|
||||
header?.addEventListener("mousedown", (evt) => startWidgetMove(evt, widget, card));
|
||||
}
|
||||
|
||||
function openCreateView() {
|
||||
@@ -371,8 +869,11 @@
|
||||
editDialogEl?.showModal();
|
||||
}
|
||||
|
||||
function openDesignerFor(id) {
|
||||
function openDesignerFor(id, { edit = false } = {}) {
|
||||
setActiveDashboard(id);
|
||||
const db = activeDashboard();
|
||||
store.editMode = edit && dashboardCanEdit(db);
|
||||
if (store.editMode) setWidgetTab("missions");
|
||||
setView("designer");
|
||||
window.NavApp?.syncDashboardSection?.(`dashboard-${id}`);
|
||||
}
|
||||
@@ -456,6 +957,35 @@
|
||||
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" />
|
||||
</div>
|
||||
<p class="mutedNote">${escapeHtml(t("dashboard.widget.pauseHint"))}</p>`;
|
||||
} else if (type === "mission_action_log") {
|
||||
container.innerHTML = `
|
||||
<div class="row rowWide">
|
||||
<label>${t("dashboard.widget.field.title")}</label>
|
||||
<input data-field="title" type="text" value="${escapeHtml(widget.title || "Mission action log")}" />
|
||||
</div>`;
|
||||
} else if (type === "logout_button") {
|
||||
container.innerHTML = `
|
||||
<div class="row rowWide">
|
||||
<label>${t("dashboard.widget.field.title")}</label>
|
||||
<input data-field="title" type="text" value="${escapeHtml(widget.title || t("dashboard.widget.logout_button"))}" />
|
||||
</div>`;
|
||||
} else if (type === "map_locked" || type === "map") {
|
||||
container.innerHTML = `
|
||||
<div class="row rowWide">
|
||||
<label>${t("dashboard.widget.field.map")}</label>
|
||||
<select data-field="map_id">${mapOptions(widget.map_id || "")}</select>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label>${t("dashboard.widget.field.title")}</label>
|
||||
<input data-field="title" type="text" value="${escapeHtml(widget.title || widgetTypeLabel(type))}" />
|
||||
</div>
|
||||
<p class="mutedNote">${escapeHtml(t("dashboard.widget.mapHint"))}</p>`;
|
||||
} else if (type === "robot_summary") {
|
||||
container.innerHTML = `
|
||||
<div class="row rowWide">
|
||||
<label>${t("dashboard.widget.field.title")}</label>
|
||||
<input data-field="title" type="text" value="${escapeHtml(widget.title || t("dashboard.widget.robot_summary"))}" />
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,11 +1054,86 @@
|
||||
);
|
||||
}
|
||||
|
||||
function renderMissionActionLogWidget(widget, bodyEl) {
|
||||
const snap = missions()?.getQueueSnapshot?.();
|
||||
const runner = snap?.runner || {};
|
||||
const executing = (snap?.queue || []).find((e) => e.status === "executing");
|
||||
const lines = [];
|
||||
if (runner.current_action) {
|
||||
lines.push({ message: runner.current_action, current: true });
|
||||
} else if (runner.message) {
|
||||
lines.push({ message: runner.message, current: true });
|
||||
}
|
||||
if (executing?.log && Array.isArray(executing.log)) {
|
||||
executing.log
|
||||
.slice(-10)
|
||||
.reverse()
|
||||
.forEach((entry) => {
|
||||
if (entry?.message) lines.push({ message: entry.message, level: entry.level || "info" });
|
||||
});
|
||||
}
|
||||
if (!lines.length) {
|
||||
bodyEl.innerHTML = `<p class="mutedNote">${escapeHtml(t("dashboard.widget.actionLog.empty"))}</p>`;
|
||||
return;
|
||||
}
|
||||
bodyEl.innerHTML = `<ul class="dashboardActionLogList"></ul>`;
|
||||
const listEl = bodyEl.querySelector(".dashboardActionLogList");
|
||||
lines.forEach((line) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = `dashboardActionLogItem${line.current ? " is-current" : ""}${line.level ? ` level-${line.level}` : ""}`;
|
||||
li.textContent = line.message;
|
||||
listEl.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLogoutButtonWidget(widget, bodyEl) {
|
||||
const label = widget.title || t("dashboard.widget.logout_button");
|
||||
bodyEl.innerHTML = `<button type="button" class="dashboardLogoutBtn">${escapeHtml(label)}</button>`;
|
||||
bodyEl.querySelector(".dashboardLogoutBtn")?.addEventListener("click", () => window.AuthApp?.logout?.());
|
||||
}
|
||||
|
||||
function renderMapWidget(widget, bodyEl, locked = false) {
|
||||
bodyEl.innerHTML = `<div class="dashboardMapViewport${locked ? " dashboardMapViewport--locked" : ""}" data-map-viewport>
|
||||
<div class="dashboardMapLoading mutedNote">${escapeHtml(t("dashboard.widget.mapLoading"))}</div>
|
||||
</div>`;
|
||||
void layoutMapWidget(widget, bodyEl, locked);
|
||||
}
|
||||
|
||||
function renderRobotSummaryWidget(widget, bodyEl) {
|
||||
const title = widget.title || t("dashboard.widget.robot_summary");
|
||||
bodyEl.innerHTML = `
|
||||
<div class="dashboardRobotSummary">
|
||||
<div class="dashboardRobotSummaryIcon" aria-hidden="true">⬡</div>
|
||||
<div class="dashboardRobotSummaryMeta">
|
||||
<div class="dashboardRobotSummaryName">${escapeHtml(t("app.robotName"))}</div>
|
||||
<div class="dashboardRobotSummarySub mutedNote">${escapeHtml(title)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPauseContinueWidget(widget, bodyEl) {
|
||||
const snap = missions()?.getQueueSnapshot?.();
|
||||
const state = snap?.runner?.state || "idle";
|
||||
const paused = state === "paused" || snap?.runner?.paused;
|
||||
const running = state === "running" || paused;
|
||||
|
||||
if (!store.editMode) {
|
||||
bodyEl.innerHTML = `
|
||||
<button type="button" class="dashboardMirToggleBtn ${paused ? "is-continue" : "is-pause"}" data-pause-action="${paused ? "continue" : "pause"}" ${running ? "" : "disabled"}>
|
||||
${paused ? t("dashboard.widget.continue") : t("dashboard.widget.pause")}
|
||||
</button>`;
|
||||
bodyEl.querySelector("[data-pause-action]")?.addEventListener("click", async (evt) => {
|
||||
const action = evt.currentTarget.dataset.pauseAction;
|
||||
try {
|
||||
if (action === "pause") await missions()?.pauseRunner?.();
|
||||
else await missions()?.continueRunner?.();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
bodyEl.innerHTML = `
|
||||
<div class="dashboardRunnerControls">
|
||||
<button type="button" class="dashboardPauseBtn ${paused ? "is-paused" : ""}" data-pause-action="${paused ? "continue" : "pause"}" ${running ? "" : "disabled"}>
|
||||
@@ -538,7 +1143,7 @@
|
||||
${t("dashboard.widget.cancelMission")}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mutedNote dashboardWidgetHint">${running ? (paused ? t("dashboard.widget.runner.paused") : t("dashboard.widget.runner.running")) : t("dashboard.widget.runner.idle")}</p>`;
|
||||
<p class="mutedNote dashboardWidgetHint">${escapeHtml(t("dashboard.widget.pauseHint"))}</p>`;
|
||||
bodyEl.querySelector("[data-pause-action]")?.addEventListener("click", async (evt) => {
|
||||
const action = evt.currentTarget.dataset.pauseAction;
|
||||
try {
|
||||
@@ -558,19 +1163,22 @@
|
||||
}
|
||||
|
||||
function renderWidget(widget) {
|
||||
normalizeWidget(widget);
|
||||
const card = document.createElement("article");
|
||||
card.className = `dashboardWidget dashboardWidget--${widget.type}`;
|
||||
card.classList.toggle("dashboardWidget--operate", !store.editMode);
|
||||
card.classList.toggle("dashboardWidget--edit", store.editMode);
|
||||
card.dataset.widgetId = widget.id;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="dashboardWidgetHeader">
|
||||
${store.editMode ? `
|
||||
<div class="dashboardWidgetHeader" title="${escapeHtml(t("dashboard.designer.dragHint"))}">
|
||||
<div class="dashboardWidgetTitle">${escapeHtml(widgetTitle(widget))}</div>
|
||||
<div class="dashboardWidgetChrome" hidden>
|
||||
<button type="button" class="iconBtn" data-widget-config title="${escapeHtml(t("common.configure"))}">⚙</button>
|
||||
<button type="button" class="iconBtn danger" data-widget-delete title="${escapeHtml(t("common.delete"))}">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboardWidgetBody"></div>`;
|
||||
<span class="dashboardWidgetDragHint" aria-hidden="true">⠿</span>
|
||||
</div>` : ""}
|
||||
<div class="dashboardWidgetBody"></div>
|
||||
<button type="button" class="dashboardWidgetPen" data-widget-config title="${escapeHtml(t("dashboard.designer.configure"))}" aria-label="${escapeHtml(t("dashboard.designer.configure"))}" ${store.editMode ? "" : "hidden"}>✎</button>
|
||||
<div class="dashboardWidgetResize" data-widget-resize title="${escapeHtml(t("dashboard.designer.resize"))}" aria-label="${escapeHtml(t("dashboard.designer.resize"))}" ${store.editMode ? "" : "hidden"}>↘</div>`;
|
||||
|
||||
const bodyEl = card.querySelector(".dashboardWidgetBody");
|
||||
switch (widget.type) {
|
||||
@@ -586,12 +1194,26 @@
|
||||
case "pause_continue":
|
||||
renderPauseContinueWidget(widget, bodyEl);
|
||||
break;
|
||||
case "mission_action_log":
|
||||
renderMissionActionLogWidget(widget, bodyEl);
|
||||
break;
|
||||
case "logout_button":
|
||||
renderLogoutButtonWidget(widget, bodyEl);
|
||||
break;
|
||||
case "map_locked":
|
||||
renderMapWidget(widget, bodyEl, true);
|
||||
break;
|
||||
case "map":
|
||||
renderMapWidget(widget, bodyEl, false);
|
||||
break;
|
||||
case "robot_summary":
|
||||
renderRobotSummaryWidget(widget, bodyEl);
|
||||
break;
|
||||
default:
|
||||
bodyEl.innerHTML = `<p class="mutedNote">${t("dashboard.widget.unsupported")}</p>`;
|
||||
}
|
||||
|
||||
card.querySelector("[data-widget-config]")?.addEventListener("click", () => openEditDialog(widget.id));
|
||||
card.querySelector("[data-widget-delete]")?.addEventListener("click", () => deleteWidget(widget.id));
|
||||
attachWidgetInteractions(card, widget);
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -599,12 +1221,20 @@
|
||||
if (!gridEl) return;
|
||||
const widgets = activeWidgets();
|
||||
gridEl.innerHTML = "";
|
||||
if (designerEmptyEl) designerEmptyEl.hidden = widgets.length > 0;
|
||||
if (designerEmptyEl) {
|
||||
designerEmptyEl.hidden = widgets.length > 0;
|
||||
designerEmptyEl.textContent = store.editMode
|
||||
? t("dashboard.designer.emptyEdit")
|
||||
: t("dashboard.designer.empty");
|
||||
}
|
||||
gridEl.classList.toggle("dashboardGrid--edit", store.editMode);
|
||||
ensureWidgetPositions(widgets);
|
||||
widgets.forEach((w) => gridEl.appendChild(renderWidget(w)));
|
||||
gridEl.querySelectorAll(".dashboardWidgetChrome").forEach((n) => {
|
||||
n.hidden = !store.editMode;
|
||||
});
|
||||
updateGridCanvasHeight(widgets);
|
||||
renderDesignerChrome();
|
||||
if (hasMapWidget(widgets)) {
|
||||
void ensureMapsLoaded().then(() => refreshMapWidgets());
|
||||
}
|
||||
}
|
||||
|
||||
function refreshDynamicWidgets() {
|
||||
@@ -614,7 +1244,9 @@
|
||||
const bodyEl = card.querySelector(".dashboardWidgetBody");
|
||||
if (widget.type === "mission_queue") refreshQueueWidget(bodyEl);
|
||||
if (widget.type === "pause_continue") renderPauseContinueWidget(widget, bodyEl);
|
||||
if (widget.type === "mission_action_log") renderMissionActionLogWidget(widget, bodyEl);
|
||||
});
|
||||
if (hasMapWidget()) refreshMapWidgets();
|
||||
}
|
||||
|
||||
function openEditDialog(widgetId) {
|
||||
@@ -622,8 +1254,15 @@
|
||||
if (!widget) return;
|
||||
editWidgetIdEl.value = widget.id;
|
||||
editWidgetTypeEl.value = widgetTypeLabel(widget.type);
|
||||
const open = () => {
|
||||
fillTypeFields(editFieldsEl, widget.type, widget);
|
||||
editWidgetDialogEl.showModal();
|
||||
};
|
||||
if (widget.type === "map" || widget.type === "map_locked") {
|
||||
void ensureMapsLoaded().then(open);
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
function deleteWidget(widgetId) {
|
||||
@@ -645,6 +1284,7 @@
|
||||
const id = section.slice("dashboard-".length);
|
||||
if (store.dashboards.some((d) => d.id === id)) {
|
||||
setActiveDashboard(id);
|
||||
store.editMode = false;
|
||||
setView("designer");
|
||||
}
|
||||
}
|
||||
@@ -700,7 +1340,7 @@
|
||||
if (!btn) return;
|
||||
const id = btn.dataset.id;
|
||||
const action = btn.dataset.action;
|
||||
if (action === "design") openDesignerFor(id);
|
||||
if (action === "design") openDesignerFor(id, { edit: true });
|
||||
else if (action === "edit") openEditDashboardDialog(id);
|
||||
else if (action === "delete") deleteDashboard(id);
|
||||
});
|
||||
@@ -753,7 +1393,11 @@
|
||||
if (!db) return;
|
||||
const type = addTypeEl.value;
|
||||
const fields = readFields(addFieldsEl);
|
||||
db.widgets.push({ id: newId("w"), type, ...fields });
|
||||
const widget = normalizeWidget({ id: newId("w"), type, ...fields, w: DEFAULT_W, h: DEFAULT_H });
|
||||
const spot = findFreeGridSpot(db.widgets, widget.w, widget.h);
|
||||
widget.col = spot.col;
|
||||
widget.row = spot.row;
|
||||
db.widgets.push(widget);
|
||||
persistStore();
|
||||
addDialogEl.close();
|
||||
renderDashboard();
|
||||
@@ -771,6 +1415,39 @@
|
||||
});
|
||||
|
||||
el("dashboardDeleteWidgetBtn")?.addEventListener("click", () => deleteWidget(editWidgetIdEl.value));
|
||||
|
||||
editModeBtnEl?.addEventListener("click", async () => {
|
||||
if (!dashboardCanEdit(activeDashboard())) return;
|
||||
if (store.editMode) {
|
||||
clearTimeout(persistTimer);
|
||||
await syncStoreToBackend();
|
||||
}
|
||||
store.editMode = !store.editMode;
|
||||
if (store.editMode) setWidgetTab("missions");
|
||||
renderDashboard();
|
||||
});
|
||||
|
||||
saveBtnEl?.addEventListener("click", async () => {
|
||||
clearTimeout(persistTimer);
|
||||
await syncStoreToBackend();
|
||||
saveBtnEl.classList.add("is-saved");
|
||||
saveBtnEl.textContent = t("dashboard.designer.saved");
|
||||
setTimeout(() => {
|
||||
saveBtnEl.classList.remove("is-saved");
|
||||
saveBtnEl.textContent = t("dashboard.designer.save");
|
||||
}, 1600);
|
||||
});
|
||||
|
||||
designerToolbarEl?.addEventListener("click", (evt) => {
|
||||
const tabBtn = evt.target.closest("[data-widget-tab]");
|
||||
if (tabBtn) {
|
||||
setWidgetTab(tabBtn.dataset.widgetTab);
|
||||
return;
|
||||
}
|
||||
const btn = evt.target.closest("[data-add-widget]");
|
||||
if (!btn) return;
|
||||
addWidget(btn.dataset.addWidget);
|
||||
});
|
||||
}
|
||||
|
||||
function startDashboardPoll() {
|
||||
@@ -781,6 +1458,12 @@
|
||||
store.queueUnsub = missions()?.onQueueUpdate?.(() => refreshDynamicWidgets());
|
||||
missions()?.startQueuePoll?.();
|
||||
store.pollActive = true;
|
||||
if (hasMapWidget()) {
|
||||
void fetchRobotPose(true);
|
||||
store.mapPollTimer = setInterval(() => {
|
||||
void fetchRobotPose(true).then(() => refreshMapWidgets());
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopDashboardPoll() {
|
||||
@@ -792,6 +1475,10 @@
|
||||
store.queueUnsub();
|
||||
store.queueUnsub = null;
|
||||
}
|
||||
if (store.mapPollTimer) {
|
||||
clearInterval(store.mapPollTimer);
|
||||
store.mapPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
@@ -807,6 +1494,7 @@
|
||||
handleNav,
|
||||
onPageShow() {
|
||||
if (store.view === "designer") {
|
||||
syncDesignerEditMode();
|
||||
renderDesignerChrome();
|
||||
renderDashboard();
|
||||
startDashboardPoll();
|
||||
|
||||
268
www/i18n.js
268
www/i18n.js
@@ -28,6 +28,9 @@
|
||||
"common.error": "Lỗi: {msg}",
|
||||
"common.none": "none",
|
||||
"common.optional": "Tùy chọn",
|
||||
"common.yes": "Có",
|
||||
"common.no": "Không",
|
||||
"common.ok": "OK",
|
||||
|
||||
"login.prompt": "Chọn cách đăng nhập:",
|
||||
"login.tab.password": "Tên đăng nhập và mật khẩu",
|
||||
@@ -68,7 +71,8 @@
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.dashboardsList": "Dashboards",
|
||||
"nav.missions": "Missions",
|
||||
"nav.maps": "Maps & layout",
|
||||
"nav.maps": "Maps",
|
||||
"nav.build-robot": "Build Robot",
|
||||
"nav.monitoring-log": "System log",
|
||||
"nav.integrations": "Tích hợp",
|
||||
"nav.help-api": "API documentation",
|
||||
@@ -149,7 +153,19 @@
|
||||
"dashboard.create.submit": "Tạo dashboard",
|
||||
"dashboard.create.cancel": "Hủy",
|
||||
"dashboard.dialog.editDashboard.title": "Sửa dashboard",
|
||||
"dashboard.designer.empty": "Chưa có widget. Phase B sẽ thêm designer đầy đủ.",
|
||||
"dashboard.designer.empty": "Chưa có widget trên dashboard này.",
|
||||
"dashboard.designer.emptyEdit": "Chưa có widget. Chọn loại widget trên thanh Maps / Missions / Miscellaneous.",
|
||||
"dashboard.designer.dragHint": "Kéo thanh tiêu đề để di chuyển widget trên lưới",
|
||||
"dashboard.designer.configure": "Cấu hình widget",
|
||||
"dashboard.designer.resize": "Kéo để đổi kích thước",
|
||||
"dashboard.designer.save": "Lưu",
|
||||
"dashboard.designer.saved": "Đã lưu",
|
||||
"dashboard.menu.maps": "Maps",
|
||||
"dashboard.menu.missions": "Missions",
|
||||
"dashboard.menu.plc": "PLC Registers",
|
||||
"dashboard.menu.io": "I/O",
|
||||
"dashboard.menu.misc": "Miscellaneous",
|
||||
"dashboard.menu.comingSoon": "Widget nhóm này sẽ có trong bản cập nhật sau.",
|
||||
"dashboard.createdBy.system": "MiR",
|
||||
"dashboard.addWidget": "Thêm widget",
|
||||
"dashboard.editLayout": "Sửa layout",
|
||||
@@ -171,6 +187,19 @@
|
||||
"dashboard.widget.mission_group": "Nhóm mission",
|
||||
"dashboard.widget.mission_queue": "Mission queue",
|
||||
"dashboard.widget.pause_continue": "Tạm dừng / Tiếp tục",
|
||||
"dashboard.widget.mission_action_log": "Mission action log",
|
||||
"dashboard.widget.logout_button": "Nút đăng xuất",
|
||||
"dashboard.widget.map_locked": "Locked map",
|
||||
"dashboard.widget.map": "Map",
|
||||
"dashboard.widget.robot_summary": "Robot summary",
|
||||
"dashboard.widget.field.map": "Map",
|
||||
"dashboard.widget.mapActive": "Active map (robot)",
|
||||
"dashboard.widget.mapHint": "Chọn map cố định hoặc để «Active map» dùng map đang gắn với robot.",
|
||||
"dashboard.widget.mapLoading": "Đang tải map…",
|
||||
"dashboard.widget.mapEmpty": "Chưa có map. Distributor tạo map qua API /api/maps.",
|
||||
"dashboard.widget.mapNoImage": "Chưa có ảnh map — upload qua POST /api/maps/{id}/image",
|
||||
"dashboard.widget.mapImageError": "Không tải được ảnh map.",
|
||||
"dashboard.widget.actionLog.empty": "Chưa có action đang chạy.",
|
||||
"dashboard.widget.field.mission": "Mission",
|
||||
"dashboard.widget.field.group": "Nhóm mission",
|
||||
"dashboard.widget.field.title": "Tiêu đề widget (tùy chọn)",
|
||||
@@ -255,6 +284,107 @@
|
||||
"config.motor.custom": "Tùy chỉnh",
|
||||
"config.motor.customMotor": "Motor tùy chỉnh",
|
||||
|
||||
"maps.title": "Maps",
|
||||
"maps.subtitle": "Tạo và chỉnh sửa map.",
|
||||
"maps.create": "Tạo map",
|
||||
"maps.importSite": "Import site",
|
||||
"maps.clearFilters": "Xóa bộ lọc",
|
||||
"maps.filterLabel": "Lọc:",
|
||||
"maps.filterPlaceholder": "Nhập tên để lọc…",
|
||||
"maps.itemsFound": "{n} mục",
|
||||
"maps.pageOf": "Trang {page} / {total}",
|
||||
"maps.colName": "Tên",
|
||||
"maps.colCreatedBy": "Tạo bởi",
|
||||
"maps.colFunctions": "Chức năng",
|
||||
"maps.empty": "Chưa có map. Bấm Tạo map để bắt đầu.",
|
||||
"maps.emptyFilter": "Không có map khớp bộ lọc.",
|
||||
"maps.activeBadge": "ACTIVE",
|
||||
"maps.activeHint": "Map đang hoạt động: {name}",
|
||||
"maps.view": "Xem",
|
||||
"maps.importComingSoon": "Import site sẽ có trong phiên bản sau.",
|
||||
"maps.helpTitle": "Trợ giúp Maps",
|
||||
"maps.helpText": "Tạo map mới, upload ảnh PNG qua menu ⋮, sau đó kích hoạt map cho robot.",
|
||||
"maps.createDialog.title": "Tạo map",
|
||||
"maps.createDialog.name": "Tên *",
|
||||
"maps.createDialog.site": "Site *",
|
||||
"maps.createDialog.manageSite": "Tạo / Sửa site…",
|
||||
"maps.createDialog.submit": "Tạo map",
|
||||
"maps.createPage.title": "Tạo map",
|
||||
"maps.createPage.subtitle": "Tạo map mới.",
|
||||
"maps.createPage.goBack": "Quay lại",
|
||||
"maps.createPage.name": "Tên",
|
||||
"maps.createPage.namePlaceholder": "Nhập tên map…",
|
||||
"maps.createPage.nameHelp": "Tên hiển thị trong danh sách Maps.",
|
||||
"maps.createPage.site": "Site",
|
||||
"maps.createPage.siteHelp": "Site chứa map trong cơ sở.",
|
||||
"maps.createPage.siteManage": "Tạo / Sửa",
|
||||
"maps.createPage.submit": "Tạo map",
|
||||
"maps.createPage.cancel": "Hủy",
|
||||
"maps.createPage.helpText": "Nhập tên map và chọn site, sau đó bấm Tạo map để mở trình editor.",
|
||||
"maps.siteDialog.create": "Tạo site",
|
||||
"maps.siteDialog.edit": "Sửa site",
|
||||
"maps.siteDialog.name": "Tên *",
|
||||
"maps.siteForm.create": "Tạo site",
|
||||
"maps.siteForm.edit": "Sửa site",
|
||||
"maps.siteForm.name": "Tên *",
|
||||
"maps.sitesDialog.title": "Sites",
|
||||
"maps.sitesDialog.createSite": "Tạo site",
|
||||
"maps.sitesDialog.description": "Site là container chứa maps và dữ liệu cơ sở trên robot.",
|
||||
"maps.sitesDialog.empty": "Chưa có site.",
|
||||
"maps.sitesDialog.deleteConfirm": "Xóa site \"{name}\"?",
|
||||
"maps.deleteConfirm": "Xóa map \"{name}\"?",
|
||||
"maps.error.nameEmpty": "Tên map không được để trống.",
|
||||
"maps.error.noImage": "Map chưa có ảnh — upload PNG trước khi kích hoạt.",
|
||||
"maps.error.pngOnly": "Chỉ chấp nhận file PNG.",
|
||||
"maps.activateDialog.title": "Kích hoạt map?",
|
||||
"maps.activateDialog.text": "Đặt \"{name}\" làm map hoạt động của robot?",
|
||||
"maps.menu.title": "Upload, download and record maps",
|
||||
"maps.menu.uploadOverwrite": "Upload and overwrite",
|
||||
"maps.menu.uploadOverwriteDesc": "Thay map hiện tại bằng map upload.",
|
||||
"maps.menu.uploadAppend": "Upload and append",
|
||||
"maps.menu.uploadAppendDesc": "Upload map mới và ghép vào map hiện tại.",
|
||||
"maps.menu.download": "Download map",
|
||||
"maps.menu.downloadDesc": "Tải map hiện tại.",
|
||||
"maps.menu.recordOverwrite": "Record and overwrite",
|
||||
"maps.menu.recordOverwriteDesc": "Thay map hiện tại bằng bản ghi map mới.",
|
||||
"maps.menu.recordAppend": "Record and append",
|
||||
"maps.menu.recordAppendDesc": "Ghi map mới và ghép vào map hiện tại.",
|
||||
"maps.menu.comingSoon": "Sắp có",
|
||||
"maps.menu.recordHint": "Cần LiDAR",
|
||||
"maps.settings.title": "Cài đặt map",
|
||||
"maps.settings.name": "Tên",
|
||||
"maps.settings.description": "Mô tả",
|
||||
"maps.settings.resolution": "Resolution (m/px)",
|
||||
"maps.settings.originX": "Origin X",
|
||||
"maps.settings.originY": "Origin Y",
|
||||
"maps.settings.originYaw": "Origin yaw",
|
||||
"maps.editor.back": "Maps",
|
||||
"maps.editor.goBack": "Quay lại",
|
||||
"maps.editor.subtitle": "Chỉnh sửa và vẽ map.",
|
||||
"maps.editor.helpTitle": "Trợ giúp map editor",
|
||||
"maps.editor.helpText": "Dùng công cụ Pan để kéo map, zoom bằng nút +/- hoặc con lăn chuột. Menu ⋮ để upload/lưu map.",
|
||||
"maps.editor.toolbarAria": "Mapping tools",
|
||||
"maps.editor.canvasTip": "Kéo map để di chuyển vùng nhìn hoặc dùng nút zoom in/out để phóng to/thu nhỏ.",
|
||||
"maps.editor.unsaved": "Chưa lưu",
|
||||
"maps.editor.unsavedLeave": "Có thay đổi chưa lưu. Rời editor?",
|
||||
"maps.editor.menu": "Menu",
|
||||
"maps.editor.undo": "Hoàn tác",
|
||||
"maps.editor.save": "Lưu",
|
||||
"maps.editor.settings": "Cài đặt",
|
||||
"maps.editor.tool.search": "Tìm kiếm",
|
||||
"maps.editor.tool.save": "Lưu map",
|
||||
"maps.editor.tool.pan": "Pan — di chuyển vùng nhìn",
|
||||
"maps.editor.tool.crosshair": "Crosshair",
|
||||
"maps.editor.tool.center": "Căn giữa vùng nhìn",
|
||||
"maps.editor.tool.lidar": "Hiển thị LiDAR",
|
||||
"maps.editor.tool.waypoints": "Vị trí / waypoint",
|
||||
"maps.editor.fit": "Vừa khung",
|
||||
"maps.editor.zoomIn": "Phóng to",
|
||||
"maps.editor.zoomOut": "Thu nhỏ",
|
||||
"maps.editor.noData": "Chưa có dữ liệu map — mở menu ⋮ để upload PNG.",
|
||||
"maps.editor.objectTypesNone": "Chưa chọn object-type",
|
||||
"maps.menu.save": "Lưu map",
|
||||
|
||||
"missions.title": "Missions",
|
||||
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
|
||||
"missions.create": "Tạo mission",
|
||||
@@ -398,6 +528,9 @@
|
||||
"common.error": "Error: {msg}",
|
||||
"common.none": "none",
|
||||
"common.optional": "Optional",
|
||||
"common.yes": "Yes",
|
||||
"common.no": "No",
|
||||
"common.ok": "OK",
|
||||
|
||||
"login.prompt": "Choose sign-in method:",
|
||||
"login.tab.password": "Username and password",
|
||||
@@ -438,7 +571,8 @@
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.dashboardsList": "Dashboards",
|
||||
"nav.missions": "Missions",
|
||||
"nav.maps": "Maps & layout",
|
||||
"nav.maps": "Maps",
|
||||
"nav.build-robot": "Build Robot",
|
||||
"nav.monitoring-log": "System log",
|
||||
"nav.integrations": "Integrations",
|
||||
"nav.help-api": "API documentation",
|
||||
@@ -519,7 +653,19 @@
|
||||
"dashboard.create.submit": "Create dashboard",
|
||||
"dashboard.create.cancel": "Cancel",
|
||||
"dashboard.dialog.editDashboard.title": "Edit dashboard",
|
||||
"dashboard.designer.empty": "No widgets yet. Full designer coming in Phase B.",
|
||||
"dashboard.designer.empty": "This dashboard has no widgets yet.",
|
||||
"dashboard.designer.emptyEdit": "No widgets yet. Pick a type from the Maps / Missions / Miscellaneous toolbar.",
|
||||
"dashboard.designer.dragHint": "Drag the header bar to move the widget on the grid",
|
||||
"dashboard.designer.configure": "Configure widget",
|
||||
"dashboard.designer.resize": "Drag to resize",
|
||||
"dashboard.designer.save": "Save",
|
||||
"dashboard.designer.saved": "Saved",
|
||||
"dashboard.menu.maps": "Maps",
|
||||
"dashboard.menu.missions": "Missions",
|
||||
"dashboard.menu.plc": "PLC Registers",
|
||||
"dashboard.menu.io": "I/O",
|
||||
"dashboard.menu.misc": "Miscellaneous",
|
||||
"dashboard.menu.comingSoon": "Widgets in this category are coming in a future release.",
|
||||
"dashboard.createdBy.system": "MiR",
|
||||
"dashboard.addWidget": "Add widget",
|
||||
"dashboard.editLayout": "Edit layout",
|
||||
@@ -541,6 +687,19 @@
|
||||
"dashboard.widget.mission_group": "Mission group",
|
||||
"dashboard.widget.mission_queue": "Mission queue",
|
||||
"dashboard.widget.pause_continue": "Pause / Continue",
|
||||
"dashboard.widget.mission_action_log": "Mission action log",
|
||||
"dashboard.widget.logout_button": "Log-out button",
|
||||
"dashboard.widget.map_locked": "Locked map",
|
||||
"dashboard.widget.map": "Map",
|
||||
"dashboard.widget.robot_summary": "Robot summary",
|
||||
"dashboard.widget.field.map": "Map",
|
||||
"dashboard.widget.mapActive": "Active map (robot)",
|
||||
"dashboard.widget.mapHint": "Pick a fixed map or leave «Active map» to follow the robot's current map.",
|
||||
"dashboard.widget.mapLoading": "Loading map…",
|
||||
"dashboard.widget.mapEmpty": "No maps yet. A Distributor can create maps via /api/maps.",
|
||||
"dashboard.widget.mapNoImage": "No map image yet — upload via POST /api/maps/{id}/image",
|
||||
"dashboard.widget.mapImageError": "Could not load the map image.",
|
||||
"dashboard.widget.actionLog.empty": "No running action to show.",
|
||||
"dashboard.widget.field.mission": "Mission",
|
||||
"dashboard.widget.field.group": "Mission group",
|
||||
"dashboard.widget.field.title": "Widget title (optional)",
|
||||
@@ -625,6 +784,107 @@
|
||||
"config.motor.custom": "Custom",
|
||||
"config.motor.customMotor": "Custom motor",
|
||||
|
||||
"maps.title": "Maps",
|
||||
"maps.subtitle": "Create and edit maps.",
|
||||
"maps.create": "Create map",
|
||||
"maps.importSite": "Import site",
|
||||
"maps.clearFilters": "Clear filters",
|
||||
"maps.filterLabel": "Filter:",
|
||||
"maps.filterPlaceholder": "Write name to filter by...",
|
||||
"maps.itemsFound": "{n} item(s) found",
|
||||
"maps.pageOf": "Page {page} of {total}",
|
||||
"maps.colName": "Name",
|
||||
"maps.colCreatedBy": "Created by",
|
||||
"maps.colFunctions": "Functions",
|
||||
"maps.empty": "No maps yet. Click Create map to get started.",
|
||||
"maps.emptyFilter": "No maps match the filter.",
|
||||
"maps.activeBadge": "ACTIVE",
|
||||
"maps.activeHint": "Active map: {name}",
|
||||
"maps.view": "View",
|
||||
"maps.importComingSoon": "Import site will be available in a future release.",
|
||||
"maps.helpTitle": "Maps help",
|
||||
"maps.helpText": "Create a new map, upload a PNG via the ⋮ menu, then activate the map for the robot.",
|
||||
"maps.createDialog.title": "Create map",
|
||||
"maps.createDialog.name": "Name *",
|
||||
"maps.createDialog.site": "Site *",
|
||||
"maps.createDialog.manageSite": "Create / Edit site…",
|
||||
"maps.createDialog.submit": "Create map",
|
||||
"maps.createPage.title": "Create map",
|
||||
"maps.createPage.subtitle": "Create a new map.",
|
||||
"maps.createPage.goBack": "Go back",
|
||||
"maps.createPage.name": "Name",
|
||||
"maps.createPage.namePlaceholder": "Enter the map's name...",
|
||||
"maps.createPage.nameHelp": "Display name shown in the Maps list.",
|
||||
"maps.createPage.site": "Site",
|
||||
"maps.createPage.siteHelp": "The facility site that contains this map.",
|
||||
"maps.createPage.siteManage": "Create / Edit",
|
||||
"maps.createPage.submit": "Create map",
|
||||
"maps.createPage.cancel": "Cancel",
|
||||
"maps.createPage.helpText": "Enter a map name and select a site, then click Create map to open the editor.",
|
||||
"maps.siteDialog.create": "Create site",
|
||||
"maps.siteDialog.edit": "Edit site",
|
||||
"maps.siteDialog.name": "Name *",
|
||||
"maps.siteForm.create": "Create site",
|
||||
"maps.siteForm.edit": "Edit site",
|
||||
"maps.siteForm.name": "Name *",
|
||||
"maps.sitesDialog.title": "Sites",
|
||||
"maps.sitesDialog.createSite": "Create site",
|
||||
"maps.sitesDialog.description": "A site is a container for maps and other facility data on the robot.",
|
||||
"maps.sitesDialog.empty": "No sites yet.",
|
||||
"maps.sitesDialog.deleteConfirm": "Delete site \"{name}\"?",
|
||||
"maps.deleteConfirm": "Delete map \"{name}\"?",
|
||||
"maps.error.nameEmpty": "Map name is required.",
|
||||
"maps.error.noImage": "Map has no image — upload a PNG before activating.",
|
||||
"maps.error.pngOnly": "Only PNG files are accepted.",
|
||||
"maps.activateDialog.title": "Activate map?",
|
||||
"maps.activateDialog.text": "Set \"{name}\" as the robot's active map?",
|
||||
"maps.menu.title": "Upload, download and record maps",
|
||||
"maps.menu.uploadOverwrite": "Upload and overwrite",
|
||||
"maps.menu.uploadOverwriteDesc": "Replace existing map with uploaded map.",
|
||||
"maps.menu.uploadAppend": "Upload and append",
|
||||
"maps.menu.uploadAppendDesc": "Upload a new map and append it to current map.",
|
||||
"maps.menu.download": "Download map",
|
||||
"maps.menu.downloadDesc": "Download the current map.",
|
||||
"maps.menu.recordOverwrite": "Record and overwrite",
|
||||
"maps.menu.recordOverwriteDesc": "Replace existing map with new recording of map.",
|
||||
"maps.menu.recordAppend": "Record and append",
|
||||
"maps.menu.recordAppendDesc": "Record a new map and append it to current map.",
|
||||
"maps.menu.comingSoon": "Coming soon",
|
||||
"maps.menu.recordHint": "Requires LiDAR",
|
||||
"maps.settings.title": "Map settings",
|
||||
"maps.settings.name": "Name",
|
||||
"maps.settings.description": "Description",
|
||||
"maps.settings.resolution": "Resolution (m/px)",
|
||||
"maps.settings.originX": "Origin X",
|
||||
"maps.settings.originY": "Origin Y",
|
||||
"maps.settings.originYaw": "Origin yaw",
|
||||
"maps.editor.back": "Maps",
|
||||
"maps.editor.goBack": "Go back",
|
||||
"maps.editor.subtitle": "Edit and draw the map.",
|
||||
"maps.editor.helpTitle": "Map editor help",
|
||||
"maps.editor.helpText": "Use the Pan tool to drag the map, zoom with +/- buttons or the mouse wheel. Open ⋮ menu to upload or save the map.",
|
||||
"maps.editor.toolbarAria": "Mapping tools",
|
||||
"maps.editor.canvasTip": "Drag the map to move your view or use the zoom-in and -out buttons to zoom.",
|
||||
"maps.editor.unsaved": "Unsaved",
|
||||
"maps.editor.unsavedLeave": "You have unsaved changes. Leave the editor?",
|
||||
"maps.editor.menu": "Menu",
|
||||
"maps.editor.undo": "Undo",
|
||||
"maps.editor.save": "Save",
|
||||
"maps.editor.settings": "Settings",
|
||||
"maps.editor.tool.search": "Search",
|
||||
"maps.editor.tool.save": "Save map",
|
||||
"maps.editor.tool.pan": "Pan — move view",
|
||||
"maps.editor.tool.crosshair": "Crosshair",
|
||||
"maps.editor.tool.center": "Center view",
|
||||
"maps.editor.tool.lidar": "LiDAR overlay",
|
||||
"maps.editor.tool.waypoints": "Positions",
|
||||
"maps.editor.fit": "Fit to view",
|
||||
"maps.editor.zoomIn": "Zoom in",
|
||||
"maps.editor.zoomOut": "Zoom out",
|
||||
"maps.editor.noData": "No map data — open ⋮ menu to upload a PNG.",
|
||||
"maps.editor.objectTypesNone": "No object-type selected",
|
||||
"maps.menu.save": "Save map",
|
||||
|
||||
"missions.title": "Missions",
|
||||
"missions.subtitle": "Setup → Missions — robot task list.",
|
||||
"missions.create": "Create mission",
|
||||
|
||||
377
www/index.html
377
www/index.html
@@ -326,13 +326,49 @@
|
||||
</div>
|
||||
|
||||
<div id="dashboardDesignerView" class="dashboardDesignerView" hidden>
|
||||
<header class="dashboardDesignerHeader">
|
||||
<button id="dashboardBackToListBtn" type="button" class="btn subtle dashboardBackBtn" data-i18n="dashboard.list.back">← Danh sách</button>
|
||||
<h2 id="dashboardDesignerTitle" class="dashboardDesignerTitle">—</h2>
|
||||
<header class="dashboardMirDesignerBar">
|
||||
<button id="dashboardBackToListBtn" type="button" class="dashboardMirBarBtn dashboardMirBarBtn--back" data-i18n="dashboard.list.back">← Danh sách</button>
|
||||
<h2 id="dashboardDesignerTitle" class="dashboardMirDesignerTitle">—</h2>
|
||||
<div class="dashboardMirDesignerBarActions">
|
||||
<button id="dashboardSaveBtn" type="button" class="dashboardMirSaveBtn" hidden data-i18n="dashboard.designer.save">Lưu</button>
|
||||
<button id="dashboardEditModeBtn" type="button" class="dashboardMirBarBtn dashboardMirBarBtn--edit" hidden data-i18n="dashboard.editLayout">Sửa layout</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="dashboardDesignerBody">
|
||||
<div id="dashboardGrid" class="dashboardGrid"></div>
|
||||
<p id="dashboardDesignerEmpty" class="mutedNote dashboardDesignerEmpty" data-i18n="dashboard.designer.empty">Chưa có widget. Phase B sẽ thêm designer đầy đủ.</p>
|
||||
<nav id="dashboardDesignerToolbar" class="dashboardMirWidgetBar" hidden aria-label="Widget menus">
|
||||
<div class="dashboardMirWidgetTabs" role="tablist">
|
||||
<button type="button" class="dashboardMirWidgetTab" role="tab" data-widget-tab="maps" data-i18n="dashboard.menu.maps">Maps</button>
|
||||
<button type="button" class="dashboardMirWidgetTab is-active" role="tab" data-widget-tab="missions" data-i18n="dashboard.menu.missions">Missions</button>
|
||||
<button type="button" class="dashboardMirWidgetTab" role="tab" data-widget-tab="plc" data-i18n="dashboard.menu.plc">PLC Registers</button>
|
||||
<button type="button" class="dashboardMirWidgetTab" role="tab" data-widget-tab="io" data-i18n="dashboard.menu.io">I/O</button>
|
||||
<button type="button" class="dashboardMirWidgetTab" role="tab" data-widget-tab="misc" data-i18n="dashboard.menu.misc">Miscellaneous</button>
|
||||
</div>
|
||||
<div class="dashboardMirWidgetPanels">
|
||||
<div class="dashboardMirWidgetPanel" data-panel="maps" hidden>
|
||||
<button type="button" class="dashboardMirWidgetPick" data-add-widget="map_locked" data-i18n="dashboard.widget.map_locked">Locked map</button>
|
||||
<button type="button" class="dashboardMirWidgetPick" data-add-widget="map" data-i18n="dashboard.widget.map">Map</button>
|
||||
</div>
|
||||
<div class="dashboardMirWidgetPanel" data-panel="missions">
|
||||
<button type="button" class="dashboardMirWidgetPick" data-add-widget="mission_button" data-i18n="dashboard.widget.mission_button">Mission button</button>
|
||||
<button type="button" class="dashboardMirWidgetPick" data-add-widget="mission_group" data-i18n="dashboard.widget.mission_group">Mission group</button>
|
||||
<button type="button" class="dashboardMirWidgetPick" data-add-widget="mission_queue" data-i18n="dashboard.widget.mission_queue">Mission queue</button>
|
||||
<button type="button" class="dashboardMirWidgetPick" data-add-widget="pause_continue" data-i18n="dashboard.widget.pause_continue">Pause / Continue</button>
|
||||
<button type="button" class="dashboardMirWidgetPick" data-add-widget="mission_action_log" data-i18n="dashboard.widget.mission_action_log">Mission action log</button>
|
||||
</div>
|
||||
<div class="dashboardMirWidgetPanel" data-panel="plc" hidden>
|
||||
<p class="dashboardMirWidgetPanelNote" data-i18n="dashboard.menu.comingSoon">Widget nhóm này sẽ có trong bản cập nhật sau.</p>
|
||||
</div>
|
||||
<div class="dashboardMirWidgetPanel" data-panel="io" hidden>
|
||||
<p class="dashboardMirWidgetPanelNote" data-i18n="dashboard.menu.comingSoon">I/O widgets — sắp có.</p>
|
||||
</div>
|
||||
<div class="dashboardMirWidgetPanel" data-panel="misc" hidden>
|
||||
<button type="button" class="dashboardMirWidgetPick" data-add-widget="robot_summary" data-i18n="dashboard.widget.robot_summary">Robot summary</button>
|
||||
<button type="button" class="dashboardMirWidgetPick" data-add-widget="logout_button" data-i18n="dashboard.widget.logout_button">Log-out button</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="dashboardMirCanvasWrap">
|
||||
<div id="dashboardGrid" class="dashboardGrid dashboardMirGrid"></div>
|
||||
<p id="dashboardDesignerEmpty" class="dashboardMirCanvasEmpty" data-i18n="dashboard.designer.empty">Chưa có widget trên dashboard này.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -827,6 +863,331 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page" id="pageMaps" data-page-content="maps" hidden>
|
||||
<div id="mapsListView" class="mapsMirPage">
|
||||
<header class="mapsMirHeader">
|
||||
<div class="mapsMirHeaderText">
|
||||
<h1 class="mapsMirTitle" data-i18n="maps.title">Maps</h1>
|
||||
<p class="mapsMirSubtitle">
|
||||
<span data-i18n="maps.subtitle">Create and edit maps.</span>
|
||||
<button type="button" class="mapsMirHelpBtn" id="mapsHelpBtn" data-i18n-title="maps.helpTitle" aria-label="Help">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mapsMirHeaderActions">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="mapsCreateOpenBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<span data-i18n="maps.create">Create map</span>
|
||||
</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="mapsImportSiteBtn">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2v8M5 7l3 3 3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M3 12h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
<span data-i18n="maps.importSite">Import site</span>
|
||||
</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="mapsClearFiltersBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
<span data-i18n="maps.clearFilters">Clear filters</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="mapsActiveHint" class="mapsMirActiveHint" hidden></div>
|
||||
|
||||
<div class="mapsMirFilterBar">
|
||||
<label class="mapsMirFilterLabel" for="mapsFilterInput" data-i18n="maps.filterLabel">Filter:</label>
|
||||
<input type="search" id="mapsFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="maps.filterPlaceholder" placeholder="Write name to filter by..." autocomplete="off" />
|
||||
<span id="mapsFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
|
||||
<div class="mapsMirPager">
|
||||
<button type="button" class="mapsMirPageBtn" id="mapsPageFirst" aria-label="First page">«</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="mapsPagePrev" aria-label="Previous page">‹</button>
|
||||
<span id="mapsPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||
<button type="button" class="mapsMirPageBtn" id="mapsPageNext" aria-label="Next page">›</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="mapsPageLast" aria-label="Last page">»</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapsMirTableWrap">
|
||||
<table class="mapsMirTable" id="mapsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="maps.colName">Name</th>
|
||||
<th data-i18n="maps.colCreatedBy">Created by</th>
|
||||
<th class="mapsMirThFunctions" data-i18n="maps.colFunctions">Functions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mapsList"></tbody>
|
||||
</table>
|
||||
<div id="mapsListEmpty" class="mapsMirEmpty" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mapsCreateView" class="mapsMirCreatePage" hidden>
|
||||
<header class="mapsMirCreateHeader">
|
||||
<div class="mapsMirCreateHeaderIntro">
|
||||
<h1 class="mapsMirCreateTitle" data-i18n="maps.createPage.title">Create map</h1>
|
||||
<p class="mapsMirCreateSubtitle">
|
||||
<span data-i18n="maps.createPage.subtitle">Create a new map.</span>
|
||||
<button type="button" class="mapsMirHelpBtn" id="mapsCreateHelpBtn" data-i18n-title="maps.helpTitle" aria-label="Help">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="mapsMirGoBackBtn" id="mapsCreateGoBackBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9 3L5 7l4 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span data-i18n="maps.createPage.goBack">Go back</span>
|
||||
</button>
|
||||
</header>
|
||||
<form id="mapsCreateForm" class="mapsMirCreateForm">
|
||||
<div class="mapsMirCreatePanel">
|
||||
<label class="mapsMirCreateField" for="mapsCreateName">
|
||||
<span class="mapsMirCreateFieldLabel">
|
||||
<span data-i18n="maps.createPage.name">Name</span>
|
||||
<button type="button" class="mapsMirFieldHelpBtn" tabindex="-1" data-i18n-title="maps.createPage.nameHelp" title="Help">?</button>
|
||||
</span>
|
||||
<input type="text" id="mapsCreateName" required autocomplete="off" data-i18n-placeholder="maps.createPage.namePlaceholder" placeholder="Enter the map's name..." />
|
||||
</label>
|
||||
<div class="mapsMirCreateField">
|
||||
<span class="mapsMirCreateFieldLabel">
|
||||
<span data-i18n="maps.createPage.site">Site</span>
|
||||
<button type="button" class="mapsMirFieldHelpBtn" tabindex="-1" data-i18n-title="maps.createPage.siteHelp" title="Help">?</button>
|
||||
</span>
|
||||
<div class="mapsMirSitePicker">
|
||||
<select id="mapsCreateSite" required></select>
|
||||
<button type="button" class="mapsMirSiteManageBtn" id="mapsCreateSiteBtn" data-i18n="maps.createPage.siteManage">Create / Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mapsMirCreateActions">
|
||||
<button type="submit" class="mapsMirCreateSubmitBtn">
|
||||
<span class="mapsMirCircleIcon mapsMirCircleIcon--ok" aria-hidden="true">✓</span>
|
||||
<span data-i18n="maps.createPage.submit">Create map</span>
|
||||
</button>
|
||||
<button type="button" class="mapsMirCreateCancelBtn" id="mapsCreateCancelBtn">
|
||||
<span class="mapsMirCircleIcon" aria-hidden="true">←</span>
|
||||
<span data-i18n="maps.createPage.cancel">Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="mapEditorView" class="mapEditorPage" hidden>
|
||||
<header class="mapEditorHeader">
|
||||
<div class="mapEditorHeaderIntro">
|
||||
<div class="mapEditorTitleRow">
|
||||
<h1 class="mapEditorTitle" id="mapEditorTitle">—</h1>
|
||||
<button type="button" class="mapEditorGearBtn" id="mapEditorSettingsBtn" data-i18n-title="maps.editor.settings" aria-label="Settings">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><circle cx="9" cy="9" r="2.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M9 1.5v2M9 14.5v2M1.5 9h2M14.5 9h2M3.4 3.4l1.4 1.4M13.2 13.2l1.4 1.4M3.4 14.6l1.4-1.4M13.2 4.8l1.4-1.4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<span id="mapEditorDirty" class="mapEditorDirtyBadge" hidden data-i18n="maps.editor.unsaved">Unsaved</span>
|
||||
</div>
|
||||
<p class="mapEditorSubtitle">
|
||||
<span data-i18n="maps.editor.subtitle">Edit and draw the map.</span>
|
||||
<button type="button" class="mapsMirHelpBtn" id="mapEditorHelpBtn" data-i18n-title="maps.editor.helpTitle" aria-label="Help">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="mapEditorGoBackBtn" id="mapEditorBackBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9 3L5 7l4 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span data-i18n="maps.editor.goBack">Go back</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="mapEditorMappingBar" role="toolbar" data-i18n-aria="maps.editor.toolbarAria">
|
||||
<button type="button" class="mapEditorMapTool" id="mapEditorSearchBtn" disabled data-i18n-title="maps.editor.tool.search" title="Search">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12.5 12.5L17 17" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="mapEditorMapTool" id="mapEditorMenuBtn" data-i18n-title="maps.editor.menu" title="Menu">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="4" cy="10" r="1.6" fill="currentColor"/><circle cx="10" cy="10" r="1.6" fill="currentColor"/><circle cx="16" cy="10" r="1.6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button type="button" class="mapEditorMapTool" id="mapEditorUndoBtn" disabled data-i18n-title="maps.editor.undo" title="Undo">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M7 6H4v3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 9a6.5 6.5 0 1 0 1.6 4.3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="mapEditorMapTool" id="mapEditorSaveBtn" disabled data-i18n-title="maps.editor.tool.save" title="Save">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M4 3h9l3 3v11H4V3z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M6 3v5h7V3" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="6" y="12" width="8" height="5" rx="0.5" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
|
||||
</button>
|
||||
<button type="button" class="mapEditorMapTool is-active" id="mapEditorPanBtn" data-tool="pan" data-i18n-title="maps.editor.tool.pan" title="Pan">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 2.5v15M2.5 10h15" stroke="currentColor" stroke-width="1.2"/><path d="M10 2.5L8 5.5h4L10 2.5zM10 17.5l-2-3h4l-2 3zM2.5 10l3-2v4l-3-2zM17.5 10l-3-2v4l3-2z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<div class="mapEditorMappingBarSpacer" aria-hidden="true"></div>
|
||||
<select class="mapEditorObjectSelect" id="mapEditorObjectSelect" disabled>
|
||||
<option value="" data-i18n="maps.editor.objectTypesNone">No object-type selected</option>
|
||||
</select>
|
||||
<button type="button" class="mapEditorMapTool" id="mapEditorCrosshairBtn" disabled data-i18n-title="maps.editor.tool.crosshair" title="Crosshair">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="1.3"/></svg>
|
||||
</button>
|
||||
<button type="button" class="mapEditorMapTool" id="mapEditorFitBtn" data-i18n-title="maps.editor.fit" title="Fit">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M3 7V3h4M13 3h4v4M17 13h-4v4M7 17H3v-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="mapEditorMapTool" id="mapEditorCenterBtn" data-i18n-title="maps.editor.tool.center" title="Center">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M8 3v3H3M12 3h5v5M17 12h-5v5M3 12h5v5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="mapEditorMapTool" id="mapEditorLidarBtn" disabled data-i18n-title="maps.editor.tool.lidar" title="LiDAR">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M3.5 14a6.5 6.5 0 0 1 13 0" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M10 14V7M6.5 12.5L10 7l3.5 5.5M4.5 11l5.5-4 5.5 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="mapEditorMapTool" id="mapEditorWaypointsBtn" disabled data-i18n-title="maps.editor.tool.waypoints" title="Positions">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3v14" stroke="currentColor" stroke-width="1.3"/><path d="M10 4l4 2.5-1.2 2.4L10 8.2 7.2 8.9 6 6.5 10 4z" fill="currentColor"/><path d="M10 9l4 2.5-1.2 2.4L10 13.2 7.2 13.9 6 11.5 10 9z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button type="button" class="mapEditorMapTool" id="mapEditorZoomInBtn" data-i18n-title="maps.editor.zoomIn" title="Zoom in">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12.5 12.5L17 17M8.5 6v5M6 8.5h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="mapEditorMapTool" id="mapEditorZoomOutBtn" data-i18n-title="maps.editor.zoomOut" title="Zoom out">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12.5 12.5L17 17M6 8.5h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mapEditorCanvasWrap" id="mapEditorCanvasWrap">
|
||||
<div class="mapEditorCanvasTip" id="mapEditorCanvasTip" role="status" data-i18n="maps.editor.canvasTip">Drag the map to move your view or use the zoom-in and -out buttons to zoom.</div>
|
||||
<div class="mapEditorViewport">
|
||||
<div class="mapEditorCanvasInner" id="mapEditorCanvasInner">
|
||||
<div class="mapEditorSheet" id="mapEditorSheet">
|
||||
<img id="mapEditorImage" class="mapEditorImage" alt="" hidden />
|
||||
<div id="mapEditorEmpty" class="mapEditorEmpty" hidden data-i18n="maps.editor.noData">No map data — open ⋮ menu to upload a PNG.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="mapsSitesDialog" class="mapsMirSitesDialog">
|
||||
<div class="mapsMirSitesDialogInner">
|
||||
<header class="mapsMirSitesHeader">
|
||||
<h2 class="mapsMirSitesTitle" data-i18n="maps.sitesDialog.title">Sites</h2>
|
||||
<button type="button" class="mapsMirSitesCreateBtn" id="mapsSitesCreateBtn" data-i18n="maps.sitesDialog.createSite">Create site</button>
|
||||
</header>
|
||||
<p class="mapsMirSitesHelp" data-i18n="maps.sitesDialog.description">A site is a container for maps and other facility data on the robot.</p>
|
||||
<div class="mapsMirSitesDivider" role="separator"></div>
|
||||
<ul class="mapsMirSitesList" id="mapsSitesList" aria-label="Sites"></ul>
|
||||
<div class="mapsMirSitesFooter">
|
||||
<button type="button" class="mapsMirSitesOkBtn" id="mapsSitesOkBtn">
|
||||
<span class="mapsMirCircleIcon mapsMirCircleIcon--ok" aria-hidden="true">✓</span>
|
||||
<span data-i18n="common.ok">OK</span>
|
||||
</button>
|
||||
<button type="button" class="mapsMirSitesCancelBtn" id="mapsSitesCancelBtn">
|
||||
<span data-i18n="common.cancel">Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="mapsSiteFormDialog" class="mapsMirDialog">
|
||||
<form id="mapsSiteForm" method="dialog">
|
||||
<h2 class="mapsMirDialogTitle" id="mapsSiteFormTitle" data-i18n="maps.siteForm.create">Create site</h2>
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="maps.siteForm.name">Name *</span>
|
||||
<input type="text" id="mapsSiteName" required autocomplete="off" />
|
||||
</label>
|
||||
<div class="mapsMirDialogFooter">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" data-close-dialog="mapsSiteFormDialog" data-i18n="common.cancel">Cancel</button>
|
||||
<button type="submit" class="mapsMirBtn mapsMirBtn--green" data-i18n="common.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="mapEditorMenuDialog" class="mapsMirDialog mapsMirDialog--mapMenu">
|
||||
<h2 class="mapsMirDialogTitle mapsMirMapMenuTitle" data-i18n="maps.menu.title">Upload, download and record maps</h2>
|
||||
<div class="mapsMirMapMenuGrid">
|
||||
<button type="button" class="mapsMirMapMenuAction" id="mapMenuUploadOverwrite">
|
||||
<span class="mapsMirMapMenuIcon mapsMirMapMenuIcon--file" aria-hidden="true">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32"><path fill="#5cae4c" d="M22.5 24H8.5a5.5 5.5 0 0 1-.9-10.9A6.5 6.5 0 0 1 23.5 14a4.5 4.5 0 0 1 .5 9.2V24z"/><path fill="#fff" d="M16 12v7.5M13.5 16.5 16 19l2.5-2.5"/><path stroke="#fff" stroke-width="2.2" stroke-linecap="round" d="M11.5 21.5 20.5 12.5"/></svg>
|
||||
</span>
|
||||
<span class="mapsMirMapMenuText">
|
||||
<span class="mapsMirMapMenuLabel" data-i18n="maps.menu.uploadOverwrite">Upload and overwrite</span>
|
||||
<span class="mapsMirMapMenuDesc" data-i18n="maps.menu.uploadOverwriteDesc">Replace existing map with uploaded map.</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="mapsMirMapMenuAction" id="mapMenuRecordOverwrite" disabled>
|
||||
<span class="mapsMirMapMenuIcon mapsMirMapMenuIcon--record" aria-hidden="true">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="10" fill="#d9534f"/><path stroke="#fff" stroke-width="2.5" stroke-linecap="round" d="M10.5 21.5 21.5 10.5"/></svg>
|
||||
</span>
|
||||
<span class="mapsMirMapMenuText">
|
||||
<span class="mapsMirMapMenuLabel" data-i18n="maps.menu.recordOverwrite">Record and overwrite</span>
|
||||
<span class="mapsMirMapMenuDesc" data-i18n="maps.menu.recordOverwriteDesc">Replace existing map with new recording of map.</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="mapsMirMapMenuAction" id="mapMenuUploadAppend" disabled>
|
||||
<span class="mapsMirMapMenuIcon mapsMirMapMenuIcon--file" aria-hidden="true">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32"><path fill="#5cae4c" d="M22.5 24H8.5a5.5 5.5 0 0 1-.9-10.9A6.5 6.5 0 0 1 23.5 14a4.5 4.5 0 0 1 .5 9.2V24z"/><path fill="#fff" d="M16 12v7.5M13.5 16.5 16 19l2.5-2.5"/></svg>
|
||||
</span>
|
||||
<span class="mapsMirMapMenuText">
|
||||
<span class="mapsMirMapMenuLabel" data-i18n="maps.menu.uploadAppend">Upload and append</span>
|
||||
<span class="mapsMirMapMenuDesc" data-i18n="maps.menu.uploadAppendDesc">Upload a new map and append it to current map.</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="mapsMirMapMenuAction" id="mapMenuRecordAppend" disabled>
|
||||
<span class="mapsMirMapMenuIcon mapsMirMapMenuIcon--record" aria-hidden="true">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="10" fill="#d9534f"/></svg>
|
||||
</span>
|
||||
<span class="mapsMirMapMenuText">
|
||||
<span class="mapsMirMapMenuLabel" data-i18n="maps.menu.recordAppend">Record and append</span>
|
||||
<span class="mapsMirMapMenuDesc" data-i18n="maps.menu.recordAppendDesc">Record a new map and append it to current map.</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="mapsMirMapMenuAction" id="mapMenuDownload" disabled>
|
||||
<span class="mapsMirMapMenuIcon mapsMirMapMenuIcon--file" aria-hidden="true">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32"><path fill="#5cae4c" d="M22.5 24H8.5a5.5 5.5 0 0 1-.9-10.9A6.5 6.5 0 0 1 23.5 14a4.5 4.5 0 0 1 .5 9.2V24z"/><path fill="#fff" d="M16 19V11.5M13.5 16.5 16 14l2.5 2.5"/></svg>
|
||||
</span>
|
||||
<span class="mapsMirMapMenuText">
|
||||
<span class="mapsMirMapMenuLabel" data-i18n="maps.menu.download">Download map</span>
|
||||
<span class="mapsMirMapMenuDesc" data-i18n="maps.menu.downloadDesc">Download the current map.</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mapsMirDialogFooter mapsMirMapMenuFooter">
|
||||
<button type="button" class="mapsMirBtn mapsMirMapMenuCancelBtn" id="mapMenuCancelBtn" data-i18n="common.cancel">Cancel</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="mapEditorSettingsDialog" class="mapsMirDialog">
|
||||
<form id="mapEditorSettingsForm" method="dialog">
|
||||
<h2 class="mapsMirDialogTitle" data-i18n="maps.settings.title">Map settings</h2>
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="maps.settings.name">Name</span>
|
||||
<input type="text" id="mapSettingsName" required autocomplete="off" />
|
||||
</label>
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="maps.settings.description">Description</span>
|
||||
<textarea id="mapSettingsDesc" rows="2"></textarea>
|
||||
</label>
|
||||
<div class="mapsMirFieldRow">
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="maps.settings.resolution">Resolution (m/px)</span>
|
||||
<input type="number" id="mapSettingsResolution" step="0.001" min="0.001" />
|
||||
</label>
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="maps.settings.originX">Origin X</span>
|
||||
<input type="number" id="mapSettingsOriginX" step="0.01" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mapsMirFieldRow">
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="maps.settings.originY">Origin Y</span>
|
||||
<input type="number" id="mapSettingsOriginY" step="0.01" />
|
||||
</label>
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="maps.settings.originYaw">Origin yaw</span>
|
||||
<input type="number" id="mapSettingsOriginYaw" step="0.01" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mapsMirDialogFooter">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" data-close-dialog="mapEditorSettingsDialog" data-i18n="common.cancel">Cancel</button>
|
||||
<button type="submit" class="mapsMirBtn mapsMirBtn--green" data-i18n="common.apply">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="mapActivateDialog" class="mapsMirDialog">
|
||||
<h2 class="mapsMirDialogTitle" data-i18n="maps.activateDialog.title">Activate map?</h2>
|
||||
<p id="mapActivateDialogText" class="mapsMirDialogText"></p>
|
||||
<div class="mapsMirDialogFooter">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="mapActivateNoBtn" data-i18n="common.no">No</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="mapActivateYesBtn" data-i18n="common.yes">Yes</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<input type="file" id="mapEditorUploadInput" accept="image/png,.png" hidden />
|
||||
</div>
|
||||
|
||||
<div class="page" id="pageIntegrations" data-page-content="integrations" hidden>
|
||||
<div class="integrationsPage">
|
||||
<section class="card">
|
||||
@@ -1123,6 +1484,8 @@ GET /api/v2.0.0/status</pre>
|
||||
<option value="mission_group">Mission group</option>
|
||||
<option value="mission_queue">Mission queue</option>
|
||||
<option value="pause_continue">Pause / Continue</option>
|
||||
<option value="mission_action_log">Mission action log</option>
|
||||
<option value="logout_button">Log-out button</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="dashboardAddWidgetFields" class="missionConfigGrid"></div>
|
||||
@@ -1282,6 +1645,8 @@ GET /api/v2.0.0/status</pre>
|
||||
<script src="/auth.js"></script>
|
||||
<script src="/nav.js"></script>
|
||||
<script src="/missions.js"></script>
|
||||
<script src="/maps.js"></script>
|
||||
<script src="/map-editor.js"></script>
|
||||
<script src="/topbar.js"></script>
|
||||
<script src="/dashboard.js"></script>
|
||||
<script src="/integrations.js"></script>
|
||||
|
||||
457
www/map-editor.js
Normal file
457
www/map-editor.js
Normal file
@@ -0,0 +1,457 @@
|
||||
(() => {
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
|
||||
const state = {
|
||||
mapId: null,
|
||||
map: null,
|
||||
callbacks: {},
|
||||
readOnly: false,
|
||||
dirty: false,
|
||||
activeTool: "pan",
|
||||
view: { scale: 1, panX: 0, panY: 0 },
|
||||
panning: null,
|
||||
tipVisible: true,
|
||||
};
|
||||
|
||||
const titleEl = el("mapEditorTitle");
|
||||
const dirtyEl = el("mapEditorDirty");
|
||||
const canvasWrapEl = el("mapEditorCanvasWrap");
|
||||
const canvasInnerEl = el("mapEditorCanvasInner");
|
||||
const sheetEl = el("mapEditorSheet");
|
||||
const imageEl = el("mapEditorImage");
|
||||
const emptyEl = el("mapEditorEmpty");
|
||||
const tipEl = el("mapEditorCanvasTip");
|
||||
const uploadInputEl = el("mapEditorUploadInput");
|
||||
const menuDialogEl = el("mapEditorMenuDialog");
|
||||
const settingsDialogEl = el("mapEditorSettingsDialog");
|
||||
const activateDialogEl = el("mapActivateDialog");
|
||||
|
||||
const toolBtnEls = () => document.querySelectorAll(".mapEditorMapTool[data-tool]");
|
||||
|
||||
const settingsFields = {
|
||||
name: el("mapSettingsName"),
|
||||
desc: el("mapSettingsDesc"),
|
||||
resolution: el("mapSettingsResolution"),
|
||||
originX: el("mapSettingsOriginX"),
|
||||
originY: el("mapSettingsOriginY"),
|
||||
originYaw: el("mapSettingsOriginYaw"),
|
||||
};
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(path, { credentials: "include", ...opts });
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
const err = await res.json();
|
||||
if (err.error) msg = err.error;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function mapImageUrl(map) {
|
||||
if (!map?.id || !map.image_file) return null;
|
||||
return `/api/maps/${encodeURIComponent(map.id)}/image?t=${encodeURIComponent(map.updated_at || "")}`;
|
||||
}
|
||||
|
||||
function setDirty(flag) {
|
||||
state.dirty = !!flag;
|
||||
if (dirtyEl) dirtyEl.hidden = !state.dirty;
|
||||
el("mapEditorSaveBtn")?.toggleAttribute("disabled", !state.dirty || state.readOnly);
|
||||
}
|
||||
|
||||
function dismissCanvasTip() {
|
||||
if (!state.tipVisible) return;
|
||||
state.tipVisible = false;
|
||||
if (tipEl) tipEl.hidden = true;
|
||||
}
|
||||
|
||||
function updateCanvasCursor() {
|
||||
if (!canvasWrapEl) return;
|
||||
canvasWrapEl.classList.toggle("is-pan-tool", state.activeTool === "pan" && !state.panning);
|
||||
canvasWrapEl.classList.toggle("is-panning", !!state.panning);
|
||||
}
|
||||
|
||||
function setActiveTool(tool) {
|
||||
if (tool !== "pan") return;
|
||||
state.activeTool = tool;
|
||||
toolBtnEls().forEach((btn) => {
|
||||
btn.classList.toggle("is-active", btn.dataset.tool === tool);
|
||||
});
|
||||
updateCanvasCursor();
|
||||
}
|
||||
|
||||
function centerSheetInView() {
|
||||
if (!canvasWrapEl || !sheetEl) return;
|
||||
const wrap = canvasWrapEl.getBoundingClientRect();
|
||||
const sw = sheetEl.offsetWidth || 480;
|
||||
const sh = sheetEl.offsetHeight || 360;
|
||||
state.view.panX = Math.max(40, (wrap.width - sw * state.view.scale) / 2);
|
||||
state.view.panY = Math.max(40, (wrap.height - sh * state.view.scale) / 2);
|
||||
}
|
||||
|
||||
function applyViewTransform() {
|
||||
if (!canvasInnerEl) return;
|
||||
const { scale, panX, panY } = state.view;
|
||||
canvasInnerEl.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
|
||||
}
|
||||
|
||||
function fitToView() {
|
||||
dismissCanvasTip();
|
||||
if (!canvasWrapEl || !sheetEl) return;
|
||||
const wrap = canvasWrapEl.getBoundingClientRect();
|
||||
const sw = imageEl && !imageEl.hidden ? imageEl.naturalWidth || sheetEl.offsetWidth : sheetEl.offsetWidth;
|
||||
const sh = imageEl && !imageEl.hidden ? imageEl.naturalHeight || sheetEl.offsetHeight : sheetEl.offsetHeight;
|
||||
const pad = 48;
|
||||
const scale = Math.min((wrap.width - pad) / sw, (wrap.height - pad) / sh, 4);
|
||||
state.view.scale = Math.max(0.1, scale);
|
||||
state.view.panX = (wrap.width - sw * state.view.scale) / 2;
|
||||
state.view.panY = (wrap.height - sh * state.view.scale) / 2;
|
||||
applyViewTransform();
|
||||
updateCanvasCursor();
|
||||
}
|
||||
|
||||
function zoomBy(factor) {
|
||||
dismissCanvasTip();
|
||||
state.view.scale = Math.min(8, Math.max(0.1, state.view.scale * factor));
|
||||
applyViewTransform();
|
||||
}
|
||||
|
||||
function updateSheetSize() {
|
||||
if (!sheetEl || !imageEl) return;
|
||||
if (!imageEl.hidden && imageEl.naturalWidth) {
|
||||
sheetEl.style.width = `${imageEl.naturalWidth}px`;
|
||||
sheetEl.style.minHeight = `${imageEl.naturalHeight}px`;
|
||||
} else {
|
||||
sheetEl.style.width = "";
|
||||
sheetEl.style.minWidth = "480px";
|
||||
sheetEl.style.minHeight = "360px";
|
||||
}
|
||||
}
|
||||
|
||||
function renderMapImage() {
|
||||
const url = mapImageUrl(state.map);
|
||||
if (url && imageEl) {
|
||||
imageEl.src = url;
|
||||
imageEl.hidden = false;
|
||||
if (emptyEl) emptyEl.hidden = true;
|
||||
} else {
|
||||
if (imageEl) {
|
||||
imageEl.hidden = true;
|
||||
imageEl.removeAttribute("src");
|
||||
}
|
||||
if (emptyEl) emptyEl.hidden = false;
|
||||
}
|
||||
updateMenuActionsUi();
|
||||
updateSheetSize();
|
||||
imageEl?.addEventListener(
|
||||
"load",
|
||||
() => {
|
||||
updateSheetSize();
|
||||
fitToView();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
if (!url) {
|
||||
centerSheetInView();
|
||||
applyViewTransform();
|
||||
}
|
||||
}
|
||||
|
||||
function fillSettingsForm() {
|
||||
const map = state.map;
|
||||
if (!map) return;
|
||||
if (settingsFields.name) settingsFields.name.value = map.name || "";
|
||||
if (settingsFields.desc) settingsFields.desc.value = map.description || "";
|
||||
if (settingsFields.resolution) settingsFields.resolution.value = map.resolution != null ? map.resolution : 0.05;
|
||||
if (settingsFields.originX) settingsFields.originX.value = map.origin_x != null ? map.origin_x : 0;
|
||||
if (settingsFields.originY) settingsFields.originY.value = map.origin_y != null ? map.origin_y : 0;
|
||||
if (settingsFields.originYaw) settingsFields.originYaw.value = map.origin_yaw != null ? map.origin_yaw : 0;
|
||||
}
|
||||
|
||||
function readSettingsPayload() {
|
||||
return {
|
||||
name: settingsFields.name?.value.trim() || "",
|
||||
description: settingsFields.desc?.value.trim() || "",
|
||||
resolution: Number(settingsFields.resolution?.value) || 0.05,
|
||||
origin_x: Number(settingsFields.originX?.value) || 0,
|
||||
origin_y: Number(settingsFields.originY?.value) || 0,
|
||||
origin_yaw: Number(settingsFields.originYaw?.value) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function updateHeader() {
|
||||
if (titleEl) titleEl.textContent = state.map?.name || "—";
|
||||
}
|
||||
|
||||
function updateMenuActionsUi() {
|
||||
const hasImage = !!(state.map?.image_file);
|
||||
const ro = state.readOnly;
|
||||
el("mapMenuUploadOverwrite")?.toggleAttribute("disabled", ro);
|
||||
el("mapMenuUploadAppend")?.toggleAttribute("disabled", true);
|
||||
el("mapMenuDownload")?.toggleAttribute("disabled", !hasImage);
|
||||
el("mapMenuRecordOverwrite")?.toggleAttribute("disabled", true);
|
||||
el("mapMenuRecordAppend")?.toggleAttribute("disabled", true);
|
||||
}
|
||||
|
||||
function applyReadOnlyUi() {
|
||||
const ro = state.readOnly;
|
||||
el("mapEditorMenuBtn")?.toggleAttribute("disabled", ro);
|
||||
el("mapEditorSaveBtn")?.toggleAttribute("disabled", ro || !state.dirty);
|
||||
el("mapEditorSettingsBtn")?.toggleAttribute("disabled", ro);
|
||||
updateMenuActionsUi();
|
||||
}
|
||||
|
||||
async function reloadMap() {
|
||||
if (!state.mapId) return;
|
||||
state.map = await api(`/api/maps/${encodeURIComponent(state.mapId)}`);
|
||||
updateHeader();
|
||||
renderMapImage();
|
||||
fillSettingsForm();
|
||||
}
|
||||
|
||||
function open(mapId, callbacks = {}) {
|
||||
state.mapId = mapId;
|
||||
state.callbacks = callbacks;
|
||||
state.readOnly = !!callbacks.readOnly || !callbacks.canWrite;
|
||||
state.dirty = false;
|
||||
state.tipVisible = true;
|
||||
state.activeTool = "pan";
|
||||
if (tipEl) {
|
||||
tipEl.hidden = false;
|
||||
tipEl.textContent = t("maps.editor.canvasTip");
|
||||
}
|
||||
setActiveTool("pan");
|
||||
setDirty(false);
|
||||
applyReadOnlyUi();
|
||||
reloadMap().catch((e) => alert(e.message));
|
||||
}
|
||||
|
||||
function close() {
|
||||
state.mapId = null;
|
||||
state.map = null;
|
||||
state.callbacks = {};
|
||||
menuDialogEl?.close();
|
||||
settingsDialogEl?.close();
|
||||
activateDialogEl?.close();
|
||||
}
|
||||
|
||||
function loadImageDimensions(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("invalid image"));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadImage(file) {
|
||||
if (!state.map || !file || state.readOnly) return;
|
||||
if (!/\.png$/i.test(file.name)) {
|
||||
alert(t("maps.error.pngOnly"));
|
||||
return;
|
||||
}
|
||||
const dims = await loadImageDimensions(file);
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name.endsWith(".png") ? file.name : `${file.name}.png`);
|
||||
const res = await fetch(`/api/maps/${encodeURIComponent(state.map.id)}/image`, {
|
||||
credentials: "include",
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
const err = await res.json();
|
||||
if (err.error) msg = err.error;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
let updated = await res.json();
|
||||
updated = await api(`/api/maps/${encodeURIComponent(state.map.id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...readSettingsPayload(),
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
}),
|
||||
});
|
||||
state.map = updated;
|
||||
state.callbacks.onMapUpdated?.(updated);
|
||||
setDirty(false);
|
||||
renderMapImage();
|
||||
menuDialogEl?.close();
|
||||
promptActivate();
|
||||
}
|
||||
|
||||
async function saveMap() {
|
||||
if (!state.map || state.readOnly) return;
|
||||
const payload = readSettingsPayload();
|
||||
if (!payload.name) {
|
||||
alert(t("maps.error.nameEmpty"));
|
||||
return;
|
||||
}
|
||||
const updated = await api(`/api/maps/${encodeURIComponent(state.map.id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
state.map = updated;
|
||||
state.callbacks.onMapUpdated?.(updated);
|
||||
setDirty(false);
|
||||
updateHeader();
|
||||
menuDialogEl?.close();
|
||||
promptActivate();
|
||||
}
|
||||
|
||||
function promptActivate() {
|
||||
if (!state.map?.image_file) return;
|
||||
if (state.callbacks.getActiveMapId?.() === state.map.id) return;
|
||||
const textEl = el("mapActivateDialogText");
|
||||
if (textEl) {
|
||||
textEl.textContent = t("maps.activateDialog.text", { name: state.map.name });
|
||||
}
|
||||
activateDialogEl?.showModal();
|
||||
}
|
||||
|
||||
async function activateCurrentMap() {
|
||||
if (!state.map) return;
|
||||
await api("/api/robot/active_map", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ map_id: state.map.id }),
|
||||
});
|
||||
state.callbacks.onActivated?.(state.map.id);
|
||||
activateDialogEl?.close();
|
||||
}
|
||||
|
||||
function bindCanvasPanZoom() {
|
||||
canvasWrapEl?.addEventListener("wheel", (evt) => {
|
||||
evt.preventDefault();
|
||||
dismissCanvasTip();
|
||||
const factor = evt.deltaY < 0 ? 1.1 : 0.9;
|
||||
zoomBy(factor);
|
||||
}, { passive: false });
|
||||
|
||||
canvasWrapEl?.addEventListener("mousedown", (evt) => {
|
||||
if (evt.button !== 0 || state.activeTool !== "pan") return;
|
||||
dismissCanvasTip();
|
||||
state.panning = {
|
||||
startX: evt.clientX,
|
||||
startY: evt.clientY,
|
||||
startPanX: state.view.panX,
|
||||
startPanY: state.view.panY,
|
||||
};
|
||||
updateCanvasCursor();
|
||||
});
|
||||
|
||||
window.addEventListener("mousemove", (evt) => {
|
||||
if (!state.panning) return;
|
||||
state.view.panX = state.panning.startPanX + (evt.clientX - state.panning.startX);
|
||||
state.view.panY = state.panning.startPanY + (evt.clientY - state.panning.startY);
|
||||
applyViewTransform();
|
||||
});
|
||||
|
||||
window.addEventListener("mouseup", () => {
|
||||
if (!state.panning) return;
|
||||
state.panning = null;
|
||||
updateCanvasCursor();
|
||||
});
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
el("mapEditorBackBtn")?.addEventListener("click", () => {
|
||||
if (state.dirty && !confirm(t("maps.editor.unsavedLeave"))) return;
|
||||
state.callbacks.onClose?.();
|
||||
close();
|
||||
});
|
||||
|
||||
el("mapEditorHelpBtn")?.addEventListener("click", () => alert(t("maps.editor.helpText")));
|
||||
el("mapEditorMenuBtn")?.addEventListener("click", () => {
|
||||
updateMenuActionsUi();
|
||||
menuDialogEl?.showModal();
|
||||
});
|
||||
el("mapMenuCancelBtn")?.addEventListener("click", () => menuDialogEl?.close());
|
||||
menuDialogEl?.addEventListener("cancel", (evt) => {
|
||||
evt.preventDefault();
|
||||
menuDialogEl?.close();
|
||||
});
|
||||
el("mapEditorSettingsBtn")?.addEventListener("click", () => {
|
||||
fillSettingsForm();
|
||||
settingsDialogEl?.showModal();
|
||||
});
|
||||
el("mapEditorSaveBtn")?.addEventListener("click", () => {
|
||||
saveMap().catch((e) => alert(e.message));
|
||||
});
|
||||
el("mapEditorPanBtn")?.addEventListener("click", () => setActiveTool("pan"));
|
||||
el("mapEditorFitBtn")?.addEventListener("click", fitToView);
|
||||
el("mapEditorCenterBtn")?.addEventListener("click", () => {
|
||||
dismissCanvasTip();
|
||||
centerSheetInView();
|
||||
applyViewTransform();
|
||||
});
|
||||
el("mapEditorZoomInBtn")?.addEventListener("click", () => zoomBy(1.2));
|
||||
el("mapEditorZoomOutBtn")?.addEventListener("click", () => zoomBy(1 / 1.2));
|
||||
|
||||
el("mapMenuUploadOverwrite")?.addEventListener("click", () => uploadInputEl?.click());
|
||||
el("mapMenuDownload")?.addEventListener("click", () => {
|
||||
const url = mapImageUrl(state.map);
|
||||
if (!url) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${state.map.name || "map"}.png`;
|
||||
a.click();
|
||||
menuDialogEl?.close();
|
||||
});
|
||||
|
||||
uploadInputEl?.addEventListener("change", () => {
|
||||
const file = uploadInputEl.files?.[0];
|
||||
uploadInputEl.value = "";
|
||||
if (!file) return;
|
||||
uploadImage(file).catch((e) => alert(e.message));
|
||||
});
|
||||
|
||||
el("mapEditorSettingsForm")?.addEventListener("submit", (evt) => {
|
||||
evt.preventDefault();
|
||||
if (!state.map) return;
|
||||
Object.assign(state.map, readSettingsPayload());
|
||||
setDirty(true);
|
||||
updateHeader();
|
||||
settingsDialogEl?.close();
|
||||
});
|
||||
|
||||
el("mapActivateYesBtn")?.addEventListener("click", () => {
|
||||
activateCurrentMap().catch((e) => alert(e.message));
|
||||
});
|
||||
el("mapActivateNoBtn")?.addEventListener("click", () => activateDialogEl?.close());
|
||||
|
||||
Object.values(settingsFields).forEach((node) => {
|
||||
node?.addEventListener("input", () => setDirty(true));
|
||||
});
|
||||
|
||||
window.addEventListener("lm:locale-change", () => {
|
||||
if (state.tipVisible && tipEl) tipEl.textContent = t("maps.editor.canvasTip");
|
||||
updateHeader();
|
||||
});
|
||||
}
|
||||
|
||||
bindCanvasPanZoom();
|
||||
bindEvents();
|
||||
|
||||
window.MapEditorApp = { open, close, reloadMap };
|
||||
})();
|
||||
587
www/maps.js
Normal file
587
www/maps.js
Normal file
@@ -0,0 +1,587 @@
|
||||
(() => {
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const ICONS = {
|
||||
map: `<svg class="mapsMirMapIcon" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><rect x="2" y="2" width="14" height="14" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M2 6h14M6 2v14M12 2v14" stroke="currentColor" stroke-width=".8" opacity=".5"/></svg>`,
|
||||
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
|
||||
view: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M1 7s2.5-4 6-4 6 4 6 4-2.5 4-6 4-6-4-6-4z" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="7" cy="7" r="1.8" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>`,
|
||||
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 4h8M5 4V2.5h4V4M5.5 6v4M8.5 6v4M4.5 4l.5 7.5h4L9.5 4" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
|
||||
active: `<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 5l2.2 2.2L8 3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
|
||||
};
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
|
||||
const listViewEl = el("mapsListView");
|
||||
const createViewEl = el("mapsCreateView");
|
||||
const editorViewEl = el("mapEditorView");
|
||||
const listEl = el("mapsList");
|
||||
const listEmptyEl = el("mapsListEmpty");
|
||||
const tableEl = el("mapsTable");
|
||||
const activeHintEl = el("mapsActiveHint");
|
||||
const filterInputEl = el("mapsFilterInput");
|
||||
const filterCountEl = el("mapsFilterCount");
|
||||
const pageLabelEl = el("mapsPageLabel");
|
||||
const sitesDialogEl = el("mapsSitesDialog");
|
||||
const sitesListEl = el("mapsSitesList");
|
||||
const siteFormDialogEl = el("mapsSiteFormDialog");
|
||||
const createSiteSelectEl = el("mapsCreateSite");
|
||||
|
||||
const SITE_ICONS = {
|
||||
chevron: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 3l3 4-3 4M8 3l3 4-3 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
|
||||
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
|
||||
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
|
||||
};
|
||||
|
||||
const store = {
|
||||
maps: [],
|
||||
sites: [],
|
||||
activeMapId: null,
|
||||
filter: "",
|
||||
page: 1,
|
||||
editingSiteId: null,
|
||||
sitesDialogSelectedId: null,
|
||||
sitesDialogSnapshotId: null,
|
||||
};
|
||||
|
||||
function canWrite() {
|
||||
return window.AuthApp?.canWrite?.("maps") ?? true;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(path, { credentials: "include", ...opts });
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
const err = await res.json();
|
||||
if (err.error) msg = err.error;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function findMap(id) {
|
||||
return store.maps.find((m) => m.id === id) || null;
|
||||
}
|
||||
|
||||
function findSite(id) {
|
||||
return store.sites.find((s) => s.id === id) || null;
|
||||
}
|
||||
|
||||
function siteName(siteId) {
|
||||
return findSite(siteId)?.name || siteId || "—";
|
||||
}
|
||||
|
||||
function mapImageUrl(map) {
|
||||
if (!map?.id || !map.image_file) return null;
|
||||
return `/api/maps/${encodeURIComponent(map.id)}/image?t=${encodeURIComponent(map.updated_at || "")}`;
|
||||
}
|
||||
|
||||
function filteredMaps() {
|
||||
const q = store.filter.trim().toLowerCase();
|
||||
let items = [...store.maps].sort((a, b) => {
|
||||
const sa = siteName(a.site_id).localeCompare(siteName(b.site_id));
|
||||
if (sa !== 0) return sa;
|
||||
return (a.name || "").localeCompare(b.name || "");
|
||||
});
|
||||
if (q) {
|
||||
items = items.filter((m) => {
|
||||
const name = (m.name || "").toLowerCase();
|
||||
const site = siteName(m.site_id).toLowerCase();
|
||||
return name.includes(q) || site.includes(q);
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function pageCount(total) {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
}
|
||||
|
||||
function pagedMaps(items) {
|
||||
const totalPages = pageCount(items.length);
|
||||
if (store.page > totalPages) store.page = totalPages;
|
||||
if (store.page < 1) store.page = 1;
|
||||
const start = (store.page - 1) * PAGE_SIZE;
|
||||
return items.slice(start, start + PAGE_SIZE);
|
||||
}
|
||||
|
||||
function updatePagerUi(totalItems) {
|
||||
const totalPages = pageCount(totalItems);
|
||||
if (filterCountEl) {
|
||||
filterCountEl.textContent = t("maps.itemsFound", { n: totalItems });
|
||||
}
|
||||
if (pageLabelEl) {
|
||||
pageLabelEl.textContent = t("maps.pageOf", { page: store.page, total: totalPages });
|
||||
}
|
||||
const atStart = store.page <= 1;
|
||||
const atEnd = store.page >= totalPages;
|
||||
el("mapsPageFirst")?.toggleAttribute("disabled", atStart);
|
||||
el("mapsPagePrev")?.toggleAttribute("disabled", atStart);
|
||||
el("mapsPageNext")?.toggleAttribute("disabled", atEnd);
|
||||
el("mapsPageLast")?.toggleAttribute("disabled", atEnd);
|
||||
}
|
||||
|
||||
async function loadSites() {
|
||||
const data = await api("/api/sites");
|
||||
store.sites = Array.isArray(data.sites) ? data.sites : [];
|
||||
}
|
||||
|
||||
async function loadMaps() {
|
||||
const data = await api("/api/maps");
|
||||
store.maps = Array.isArray(data.maps) ? data.maps : [];
|
||||
}
|
||||
|
||||
async function loadActiveMap() {
|
||||
try {
|
||||
const status = await api("/api/robot/status");
|
||||
store.activeMapId = status.active_map_id || null;
|
||||
} catch {
|
||||
store.activeMapId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderActiveHint() {
|
||||
if (!activeHintEl) return;
|
||||
const active = findMap(store.activeMapId);
|
||||
if (active) {
|
||||
activeHintEl.hidden = false;
|
||||
activeHintEl.textContent = t("maps.activeHint", { name: active.name });
|
||||
} else {
|
||||
activeHintEl.hidden = true;
|
||||
activeHintEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
function renderSiteSelect(selectedId) {
|
||||
if (!createSiteSelectEl) return;
|
||||
createSiteSelectEl.innerHTML = "";
|
||||
store.sites.forEach((site) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = site.id;
|
||||
opt.textContent = site.name || site.id;
|
||||
createSiteSelectEl.appendChild(opt);
|
||||
});
|
||||
if (selectedId) createSiteSelectEl.value = selectedId;
|
||||
else if (store.sites[0]) createSiteSelectEl.value = store.sites[0].id;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!listEl) return;
|
||||
const items = filteredMaps();
|
||||
const pageItems = pagedMaps(items);
|
||||
updatePagerUi(items.length);
|
||||
|
||||
listEl.innerHTML = "";
|
||||
const showEmpty = items.length === 0;
|
||||
if (tableEl) tableEl.hidden = showEmpty;
|
||||
if (listEmptyEl) {
|
||||
listEmptyEl.hidden = !showEmpty;
|
||||
listEmptyEl.textContent = store.filter.trim() ? t("maps.emptyFilter") : t("maps.empty");
|
||||
}
|
||||
|
||||
let lastSiteId = null;
|
||||
pageItems.forEach((map) => {
|
||||
const siteId = map.site_id || "";
|
||||
if (siteId !== lastSiteId) {
|
||||
lastSiteId = siteId;
|
||||
const siteTr = document.createElement("tr");
|
||||
siteTr.className = "mapsMirSiteRow";
|
||||
siteTr.innerHTML = `<td colspan="3">${escapeHtml(siteName(siteId))}</td>`;
|
||||
listEl.appendChild(siteTr);
|
||||
}
|
||||
|
||||
const isActive = map.id === store.activeMapId;
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "mapsMirRow";
|
||||
tr.dataset.mapId = map.id;
|
||||
|
||||
const activeBadge = isActive
|
||||
? `<span class="mapsActiveBadge">${ICONS.active}<span>${escapeHtml(t("maps.activeBadge"))}</span></span>`
|
||||
: "";
|
||||
|
||||
const actions = canWrite()
|
||||
? `<div class="mapsMirRowActions">
|
||||
<button type="button" class="mapsMirIconBtn" data-edit="${map.id}" data-i18n-title="common.edit" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
|
||||
<button type="button" class="mapsMirIconBtn" data-view="${map.id}" data-i18n-title="maps.view" title="${escapeHtml(t("maps.view"))}">${ICONS.view}</button>
|
||||
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${map.id}" data-i18n-title="common.delete" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>
|
||||
</div>`
|
||||
: `<div class="mapsMirRowActions">
|
||||
<button type="button" class="mapsMirIconBtn" data-view="${map.id}" data-i18n-title="maps.view" title="${escapeHtml(t("maps.view"))}">${ICONS.view}</button>
|
||||
</div>`;
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="mapsMirCellName">
|
||||
<div class="mapsMirNameCell">
|
||||
${ICONS.map}
|
||||
<button type="button" class="mapsMirNameLink" data-open="${map.id}">${escapeHtml(map.name || map.id)}</button>
|
||||
${activeBadge}
|
||||
</div>
|
||||
</td>
|
||||
<td class="mapsMirCellCreatedBy">${escapeHtml(map.created_by || "—")}</td>
|
||||
<td class="mapsMirCellActions">${actions}</td>`;
|
||||
|
||||
tr.querySelector("[data-open]")?.addEventListener("click", () => openEditor(map.id));
|
||||
tr.querySelector("[data-edit]")?.addEventListener("click", () => openEditor(map.id));
|
||||
tr.querySelector("[data-view]")?.addEventListener("click", () => openEditor(map.id, { readOnly: !canWrite() }));
|
||||
tr.querySelector("[data-delete]")?.addEventListener("click", () => {
|
||||
void deleteMapFromList(map.id);
|
||||
});
|
||||
tr.addEventListener("dblclick", () => openEditor(map.id));
|
||||
listEl.appendChild(tr);
|
||||
});
|
||||
renderActiveHint();
|
||||
}
|
||||
|
||||
function hideAllViews() {
|
||||
[listViewEl, createViewEl, editorViewEl].forEach((view) => {
|
||||
if (!view) return;
|
||||
view.hidden = true;
|
||||
view.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
}
|
||||
|
||||
function showList() {
|
||||
hideAllViews();
|
||||
if (listViewEl) {
|
||||
listViewEl.hidden = false;
|
||||
listViewEl.removeAttribute("aria-hidden");
|
||||
}
|
||||
window.MapEditorApp?.close?.();
|
||||
}
|
||||
|
||||
function showCreate() {
|
||||
if (!canWrite()) return;
|
||||
hideAllViews();
|
||||
renderSiteSelect();
|
||||
const nameEl = el("mapsCreateName");
|
||||
if (nameEl) nameEl.value = "";
|
||||
if (createViewEl) {
|
||||
createViewEl.hidden = false;
|
||||
createViewEl.removeAttribute("aria-hidden");
|
||||
}
|
||||
nameEl?.focus();
|
||||
}
|
||||
|
||||
function openEditor(mapId, opts = {}) {
|
||||
const map = findMap(mapId);
|
||||
if (!map) return;
|
||||
hideAllViews();
|
||||
if (editorViewEl) {
|
||||
editorViewEl.hidden = false;
|
||||
editorViewEl.removeAttribute("aria-hidden");
|
||||
}
|
||||
window.MapEditorApp?.open?.(mapId, {
|
||||
readOnly: opts.readOnly,
|
||||
onMapUpdated: (updated) => {
|
||||
const idx = store.maps.findIndex((m) => m.id === updated.id);
|
||||
if (idx >= 0) store.maps[idx] = updated;
|
||||
else store.maps.push(updated);
|
||||
},
|
||||
onMapDeleted: (id) => {
|
||||
store.maps = store.maps.filter((m) => m.id !== id);
|
||||
if (store.activeMapId === id) store.activeMapId = null;
|
||||
showList();
|
||||
renderList();
|
||||
},
|
||||
onActivated: (id) => {
|
||||
store.activeMapId = id;
|
||||
renderList();
|
||||
},
|
||||
onClose: () => {
|
||||
showList();
|
||||
renderList();
|
||||
},
|
||||
getSiteName: siteName,
|
||||
getActiveMapId: () => store.activeMapId,
|
||||
canWrite: canWrite(),
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteMapFromList(mapId) {
|
||||
const map = findMap(mapId);
|
||||
if (!map) return;
|
||||
if (!confirm(t("maps.deleteConfirm", { name: map.name }))) return;
|
||||
await api(`/api/maps/${encodeURIComponent(map.id)}`, { method: "DELETE" });
|
||||
store.maps = store.maps.filter((m) => m.id !== map.id);
|
||||
if (store.activeMapId === map.id) store.activeMapId = null;
|
||||
renderList();
|
||||
}
|
||||
|
||||
async function activateMap(mapId) {
|
||||
const map = findMap(mapId);
|
||||
if (!map) return;
|
||||
if (!map.image_file) {
|
||||
alert(t("maps.error.noImage"));
|
||||
return;
|
||||
}
|
||||
await api("/api/robot/active_map", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ map_id: mapId }),
|
||||
});
|
||||
store.activeMapId = mapId;
|
||||
renderList();
|
||||
}
|
||||
|
||||
function openCreatePage() {
|
||||
showCreate();
|
||||
}
|
||||
|
||||
function renderSitesDialogList() {
|
||||
if (!sitesListEl) return;
|
||||
sitesListEl.innerHTML = "";
|
||||
if (store.sites.length === 0) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "mapsMirSitesEmpty";
|
||||
empty.textContent = t("maps.sitesDialog.empty");
|
||||
sitesListEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
store.sites.forEach((site) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "mapsMirSitesItem";
|
||||
if (site.id === store.sitesDialogSelectedId) li.classList.add("is-selected");
|
||||
li.dataset.siteId = site.id;
|
||||
li.innerHTML = `
|
||||
<button type="button" class="mapsMirSitesItemMain" data-select-site="${site.id}">
|
||||
<span class="mapsMirSitesChevron">${SITE_ICONS.chevron}</span>
|
||||
<span class="mapsMirSitesItemName">${escapeHtml(site.name || site.id)}</span>
|
||||
</button>
|
||||
<div class="mapsMirSitesItemActions">
|
||||
<button type="button" class="mapsMirSitesIconBtn" data-edit-site="${site.id}" data-i18n-title="common.edit" title="${escapeHtml(t("common.edit"))}">${SITE_ICONS.edit}</button>
|
||||
<button type="button" class="mapsMirSitesIconBtn mapsMirSitesIconBtn--danger" data-delete-site="${site.id}" data-i18n-title="common.delete" title="${escapeHtml(t("common.delete"))}">${SITE_ICONS.delete}</button>
|
||||
</div>`;
|
||||
li.querySelector("[data-select-site]")?.addEventListener("click", () => {
|
||||
store.sitesDialogSelectedId = site.id;
|
||||
renderSitesDialogList();
|
||||
});
|
||||
li.querySelector("[data-edit-site]")?.addEventListener("click", (evt) => {
|
||||
evt.stopPropagation();
|
||||
openSiteFormDialog(site.id);
|
||||
});
|
||||
li.querySelector("[data-delete-site]")?.addEventListener("click", (evt) => {
|
||||
evt.stopPropagation();
|
||||
void deleteSite(site.id);
|
||||
});
|
||||
sitesListEl.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
async function openSitesDialog() {
|
||||
await loadSites();
|
||||
store.sitesDialogSnapshotId = createSiteSelectEl?.value || store.sites[0]?.id || null;
|
||||
store.sitesDialogSelectedId = store.sitesDialogSnapshotId;
|
||||
renderSitesDialogList();
|
||||
sitesDialogEl?.showModal();
|
||||
}
|
||||
|
||||
function closeSitesDialog(apply) {
|
||||
if (apply && store.sitesDialogSelectedId) {
|
||||
renderSiteSelect(store.sitesDialogSelectedId);
|
||||
}
|
||||
sitesDialogEl?.close();
|
||||
}
|
||||
|
||||
function openSiteFormDialog(siteId) {
|
||||
store.editingSiteId = siteId || null;
|
||||
const site = siteId ? findSite(siteId) : null;
|
||||
const titleEl = el("mapsSiteFormTitle");
|
||||
const nameEl = el("mapsSiteName");
|
||||
if (titleEl) {
|
||||
titleEl.textContent = site ? t("maps.siteForm.edit") : t("maps.siteForm.create");
|
||||
}
|
||||
if (nameEl) nameEl.value = site?.name || "";
|
||||
siteFormDialogEl?.showModal();
|
||||
nameEl?.focus();
|
||||
}
|
||||
|
||||
async function saveSite(evt) {
|
||||
evt.preventDefault();
|
||||
const name = el("mapsSiteName")?.value.trim();
|
||||
if (!name) return;
|
||||
if (store.editingSiteId) {
|
||||
const updated = await api(`/api/sites/${encodeURIComponent(store.editingSiteId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const idx = store.sites.findIndex((s) => s.id === store.editingSiteId);
|
||||
if (idx >= 0) store.sites[idx] = updated;
|
||||
} else {
|
||||
const created = await api("/api/sites", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
store.sites.push(created);
|
||||
store.sitesDialogSelectedId = created.id;
|
||||
}
|
||||
siteFormDialogEl?.close();
|
||||
renderSitesDialogList();
|
||||
renderList();
|
||||
}
|
||||
|
||||
async function deleteSite(siteId) {
|
||||
const site = findSite(siteId);
|
||||
if (!site) return;
|
||||
if (!confirm(t("maps.sitesDialog.deleteConfirm", { name: site.name }))) return;
|
||||
try {
|
||||
await api(`/api/sites/${encodeURIComponent(siteId)}`, { method: "DELETE" });
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
return;
|
||||
}
|
||||
store.sites = store.sites.filter((s) => s.id !== siteId);
|
||||
if (store.sitesDialogSelectedId === siteId) {
|
||||
store.sitesDialogSelectedId = store.sites[0]?.id || null;
|
||||
}
|
||||
renderSitesDialogList();
|
||||
renderList();
|
||||
}
|
||||
|
||||
async function createMap(evt) {
|
||||
evt.preventDefault();
|
||||
const name = el("mapsCreateName")?.value.trim();
|
||||
const site_id = createSiteSelectEl?.value;
|
||||
if (!name || !site_id) return;
|
||||
const user = window.AuthApp?.getUser?.();
|
||||
const created = await api("/api/maps", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
site_id,
|
||||
created_by: user?.display_name || user?.username || "",
|
||||
resolution: 0.05,
|
||||
origin_x: 0,
|
||||
origin_y: 0,
|
||||
origin_yaw: 0,
|
||||
}),
|
||||
});
|
||||
store.maps.push(created);
|
||||
store.filter = "";
|
||||
if (filterInputEl) filterInputEl.value = "";
|
||||
store.page = 1;
|
||||
renderList();
|
||||
openEditor(created.id);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
store.filter = "";
|
||||
store.page = 1;
|
||||
if (filterInputEl) filterInputEl.value = "";
|
||||
renderList();
|
||||
}
|
||||
|
||||
function goToPage(page) {
|
||||
const total = pageCount(filteredMaps().length);
|
||||
store.page = Math.min(Math.max(1, page), total);
|
||||
renderList();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
el("mapsCreateOpenBtn")?.addEventListener("click", openCreatePage);
|
||||
el("mapsCreateGoBackBtn")?.addEventListener("click", showList);
|
||||
el("mapsCreateCancelBtn")?.addEventListener("click", showList);
|
||||
el("mapsCreateHelpBtn")?.addEventListener("click", () => alert(t("maps.createPage.helpText")));
|
||||
el("mapsImportSiteBtn")?.addEventListener("click", () => alert(t("maps.importComingSoon")));
|
||||
el("mapsClearFiltersBtn")?.addEventListener("click", clearFilters);
|
||||
el("mapsHelpBtn")?.addEventListener("click", () => alert(t("maps.helpText")));
|
||||
|
||||
filterInputEl?.addEventListener("input", () => {
|
||||
store.filter = filterInputEl.value;
|
||||
store.page = 1;
|
||||
renderList();
|
||||
});
|
||||
|
||||
el("mapsPageFirst")?.addEventListener("click", () => goToPage(1));
|
||||
el("mapsPagePrev")?.addEventListener("click", () => goToPage(store.page - 1));
|
||||
el("mapsPageNext")?.addEventListener("click", () => goToPage(store.page + 1));
|
||||
el("mapsPageLast")?.addEventListener("click", () => goToPage(pageCount(filteredMaps().length)));
|
||||
|
||||
el("mapsCreateForm")?.addEventListener("submit", (evt) => {
|
||||
createMap(evt).catch((e) => alert(e.message));
|
||||
});
|
||||
el("mapsSiteForm")?.addEventListener("submit", (evt) => {
|
||||
saveSite(evt).catch((e) => alert(e.message));
|
||||
});
|
||||
el("mapsCreateSiteBtn")?.addEventListener("click", () => {
|
||||
openSitesDialog().catch((e) => alert(e.message));
|
||||
});
|
||||
el("mapsSitesCreateBtn")?.addEventListener("click", () => openSiteFormDialog(null));
|
||||
el("mapsSitesOkBtn")?.addEventListener("click", () => closeSitesDialog(true));
|
||||
el("mapsSitesCancelBtn")?.addEventListener("click", () => closeSitesDialog(false));
|
||||
sitesDialogEl?.addEventListener("cancel", (evt) => {
|
||||
evt.preventDefault();
|
||||
closeSitesDialog(false);
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-close-dialog]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const id = btn.getAttribute("data-close-dialog");
|
||||
el(id)?.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function applyReadOnly() {
|
||||
document.body.classList.toggle("auth-readonly-maps-page", !canWrite());
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([loadSites(), loadMaps(), loadActiveMap()]);
|
||||
renderList();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
applyReadOnly();
|
||||
showList();
|
||||
bindEvents();
|
||||
try {
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
if (listEmptyEl) {
|
||||
listEmptyEl.hidden = false;
|
||||
listEmptyEl.textContent = t("common.error", { msg: e.message });
|
||||
}
|
||||
if (tableEl) tableEl.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
window.MapsApp = {
|
||||
init,
|
||||
refresh,
|
||||
onPageShow() {
|
||||
applyReadOnly();
|
||||
showList();
|
||||
refresh().catch(() => {});
|
||||
},
|
||||
getMaps: () => [...store.maps],
|
||||
getMapById: findMap,
|
||||
activateMap,
|
||||
};
|
||||
|
||||
function boot() {
|
||||
init();
|
||||
}
|
||||
|
||||
if (window.AuthApp?.isReady()) boot();
|
||||
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||
window.addEventListener("lm:locale-change", () => {
|
||||
renderList();
|
||||
renderActiveHint();
|
||||
});
|
||||
})();
|
||||
10
www/nav.js
10
www/nav.js
@@ -14,7 +14,8 @@
|
||||
setup: {
|
||||
items: [
|
||||
{ section: "missions", page: "missions" },
|
||||
{ section: "maps", page: "config" },
|
||||
{ section: "maps", page: "maps" },
|
||||
{ section: "build-robot", page: "config" },
|
||||
],
|
||||
},
|
||||
monitoring: {
|
||||
@@ -30,7 +31,8 @@
|
||||
|
||||
const PAGE_NAV = {
|
||||
dashboard: { module: "dashboards", section: "dashboard-list" },
|
||||
config: { module: "setup", section: "maps" },
|
||||
config: { module: "setup", section: "build-robot" },
|
||||
maps: { module: "setup", section: "maps" },
|
||||
missions: { module: "setup", section: "missions" },
|
||||
integrations: { module: "system", section: "integrations" },
|
||||
monitoring: { module: "monitoring", section: "monitoring-log" },
|
||||
@@ -38,7 +40,7 @@
|
||||
};
|
||||
|
||||
let activeModule = "setup";
|
||||
let activeSection = "maps";
|
||||
let activeSection = "missions";
|
||||
let flyoutOpen = true;
|
||||
|
||||
const shellEl = () => document.getElementById("mirNavShell");
|
||||
@@ -219,7 +221,7 @@
|
||||
}
|
||||
|
||||
function restoreInitialPage() {
|
||||
let page = "config";
|
||||
let page = "missions";
|
||||
try {
|
||||
const saved = localStorage.getItem("activePage");
|
||||
if (saved && PAGE_NAV[saved]) page = saved;
|
||||
|
||||
1887
www/style.css
1887
www/style.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user