This commit is contained in:
@@ -138,6 +138,44 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
|||||||
res.body = FileUtil::readBinary(*path);
|
res.body = FileUtil::readBinary(*path);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
svr.Get(R"(/api/maps/([^/]+)/image/base$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
const auto path = map_store_.baseImagePath(id);
|
||||||
|
if (!path)
|
||||||
|
return HttpUtil::jsonError(res, 404, "map base image not found");
|
||||||
|
res.set_header("Content-Type", HttpUtil::contentTypeForPath(*path));
|
||||||
|
res.body = FileUtil::readBinary(*path);
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post(R"(/api/maps/([^/]+)/image/composite$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
if (!req.form.has_file("file"))
|
||||||
|
return HttpUtil::jsonError(res, 400, "file is required");
|
||||||
|
const auto& file = req.form.get_file("file");
|
||||||
|
std::string err;
|
||||||
|
if (!map_store_.saveCompositeImageFile(id, file.content, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
const auto updated = map_store_.find(id);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post(R"(/api/maps/([^/]+)/image/base$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
if (!req.form.has_file("file"))
|
||||||
|
return HttpUtil::jsonError(res, 400, "file is required");
|
||||||
|
const auto& file = req.form.get_file("file");
|
||||||
|
std::string err;
|
||||||
|
if (!map_store_.saveBaseImageFile(id, file.content, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
const auto updated = map_store_.find(id);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||||
|
});
|
||||||
|
|
||||||
svr.Post(R"(/api/maps/([^/]+)/image$)", [this](const httplib::Request& req, httplib::Response& res) {
|
svr.Post(R"(/api/maps/([^/]+)/image$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
HttpUtil::addCors(res);
|
HttpUtil::addCors(res);
|
||||||
const std::string id = req.matches[1];
|
const std::string id = req.matches[1];
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ namespace lm {
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
constexpr const char* kBaseImageName = "map_base.png";
|
||||||
|
|
||||||
constexpr const char* kMapSelect =
|
constexpr const char* kMapSelect =
|
||||||
"SELECT id, name, description, site_id, created_by, width, height, resolution, "
|
"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 "
|
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, created_at, updated_at "
|
||||||
@@ -220,7 +222,14 @@ bool MapStore::update(const std::string& id, const nlohmann::json& payload, std:
|
|||||||
merged[key] = payload[key];
|
merged[key] = payload[key];
|
||||||
}
|
}
|
||||||
if (payload.contains("zones"))
|
if (payload.contains("zones"))
|
||||||
|
{
|
||||||
|
if (!payload["zones"].is_array())
|
||||||
|
{
|
||||||
|
err = "zones must be an array";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
merged["zones"] = payload["zones"];
|
merged["zones"] = payload["zones"];
|
||||||
|
}
|
||||||
|
|
||||||
const std::string now = IdUtil::nowIso8601();
|
const std::string now = IdUtil::nowIso8601();
|
||||||
const std::string zones_str = merged.value("zones", nlohmann::json::array()).dump();
|
const std::string zones_str = merged.value("zones", nlohmann::json::array()).dump();
|
||||||
@@ -307,6 +316,14 @@ std::optional<std::filesystem::path> MapStore::imagePath(const std::string& id)
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::optional<std::filesystem::path> MapStore::baseImagePath(const std::string& id) const
|
||||||
|
{
|
||||||
|
const auto base = mapDir(id) / kBaseImageName;
|
||||||
|
if (std::filesystem::exists(base))
|
||||||
|
return base;
|
||||||
|
return imagePath(id);
|
||||||
|
}
|
||||||
|
|
||||||
std::optional<std::filesystem::path> MapStore::yamlPath(const std::string& id) const
|
std::optional<std::filesystem::path> MapStore::yamlPath(const std::string& id) const
|
||||||
{
|
{
|
||||||
const auto map = find(id);
|
const auto map = find(id);
|
||||||
@@ -338,6 +355,13 @@ bool MapStore::saveImageFile(const std::string& id,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auto base_path = mapDir(id) / kBaseImageName;
|
||||||
|
if (!FileUtil::writeBinaryAtomic(base_path, bytes))
|
||||||
|
{
|
||||||
|
err = "failed to write base image file";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const std::string now = IdUtil::nowIso8601();
|
const std::string now = IdUtil::nowIso8601();
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
@@ -360,6 +384,99 @@ bool MapStore::saveImageFile(const std::string& id,
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool MapStore::saveCompositeImageFile(const std::string& id, const std::string& bytes, std::string& err)
|
||||||
|
{
|
||||||
|
const auto map = find(id);
|
||||||
|
if (!map)
|
||||||
|
{
|
||||||
|
err = "map not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const std::string filename = map->value("image_file", "map.png");
|
||||||
|
if (filename.empty())
|
||||||
|
{
|
||||||
|
err = "map has no image file";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::create_directories(mapDir(id), ec);
|
||||||
|
const auto path = mapDir(id) / filename;
|
||||||
|
if (!FileUtil::writeBinaryAtomic(path, bytes))
|
||||||
|
{
|
||||||
|
err = "failed to write composite image file";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto base_path = mapDir(id) / kBaseImageName;
|
||||||
|
if (!std::filesystem::exists(base_path))
|
||||||
|
{
|
||||||
|
if (!FileUtil::writeBinaryAtomic(base_path, bytes))
|
||||||
|
{
|
||||||
|
err = "failed to initialize base image file";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string now = IdUtil::nowIso8601();
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"UPDATE maps SET updated_at = ?2 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, 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 MapStore::saveBaseImageFile(const std::string& id, const std::string& bytes, std::string& err)
|
||||||
|
{
|
||||||
|
if (!find(id))
|
||||||
|
{
|
||||||
|
err = "map not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::create_directories(mapDir(id), ec);
|
||||||
|
const auto path = mapDir(id) / kBaseImageName;
|
||||||
|
if (!FileUtil::writeBinaryAtomic(path, bytes))
|
||||||
|
{
|
||||||
|
err = "failed to write base image file";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string now = IdUtil::nowIso8601();
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"UPDATE maps SET updated_at = ?2 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, 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 MapStore::saveYamlFile(const std::string& id, const std::string& yaml_text, std::string& err)
|
bool MapStore::saveYamlFile(const std::string& id, const std::string& yaml_text, std::string& err)
|
||||||
{
|
{
|
||||||
if (!find(id))
|
if (!find(id))
|
||||||
|
|||||||
@@ -24,8 +24,14 @@ public:
|
|||||||
|
|
||||||
std::filesystem::path mapDir(const std::string& id) const;
|
std::filesystem::path mapDir(const std::string& id) const;
|
||||||
std::optional<std::filesystem::path> imagePath(const std::string& id) const;
|
std::optional<std::filesystem::path> imagePath(const std::string& id) const;
|
||||||
|
/** Scan/original floor plan (map_base.png); falls back to composite image if missing. */
|
||||||
|
std::optional<std::filesystem::path> baseImagePath(const std::string& id) const;
|
||||||
std::optional<std::filesystem::path> yamlPath(const std::string& id) const;
|
std::optional<std::filesystem::path> yamlPath(const std::string& id) const;
|
||||||
bool saveImageFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
|
bool saveImageFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
|
||||||
|
/** Save flattened composite (map.png) without touching map_base.png. */
|
||||||
|
bool saveCompositeImageFile(const std::string& id, const std::string& bytes, std::string& err);
|
||||||
|
/** Save base scan layer (map_base.png) only. */
|
||||||
|
bool saveBaseImageFile(const std::string& id, const std::string& bytes, std::string& err);
|
||||||
bool saveYamlFile(const std::string& id, const std::string& yaml_text, std::string& err);
|
bool saveYamlFile(const std::string& id, const std::string& yaml_text, std::string& err);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const pageConfigEl = el("pageConfig");
|
|||||||
const pageMapsEl = el("pageMaps");
|
const pageMapsEl = el("pageMaps");
|
||||||
const pageMissionsEl = el("pageMissions");
|
const pageMissionsEl = el("pageMissions");
|
||||||
const pageIntegrationsEl = el("pageIntegrations");
|
const pageIntegrationsEl = el("pageIntegrations");
|
||||||
|
const pageSoundsEl = el("pageSounds");
|
||||||
const pageMonitoringEl = el("pageMonitoring");
|
const pageMonitoringEl = el("pageMonitoring");
|
||||||
const pageHelpEl = el("pageHelp");
|
const pageHelpEl = el("pageHelp");
|
||||||
const contentEl = document.querySelector(".content");
|
const contentEl = document.querySelector(".content");
|
||||||
@@ -124,7 +125,7 @@ const state = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function setActivePage(page) {
|
function setActivePage(page) {
|
||||||
const valid = ["dashboard", "config", "maps", "missions", "integrations", "monitoring", "help"];
|
const valid = ["dashboard", "config", "maps", "missions", "sounds", "integrations", "monitoring", "help"];
|
||||||
let p = valid.includes(page) ? page : "missions";
|
let p = valid.includes(page) ? page : "missions";
|
||||||
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
|
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
|
||||||
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
|
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
|
||||||
@@ -135,6 +136,7 @@ function setActivePage(page) {
|
|||||||
if (pageConfigEl) pageConfigEl.hidden = p !== "config";
|
if (pageConfigEl) pageConfigEl.hidden = p !== "config";
|
||||||
if (pageMapsEl) pageMapsEl.hidden = p !== "maps";
|
if (pageMapsEl) pageMapsEl.hidden = p !== "maps";
|
||||||
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
|
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
|
||||||
|
if (pageSoundsEl) pageSoundsEl.hidden = p !== "sounds";
|
||||||
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
||||||
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
||||||
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
|
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
|
||||||
@@ -145,6 +147,7 @@ function setActivePage(page) {
|
|||||||
contentEl.classList.toggle("content--config", p === "config");
|
contentEl.classList.toggle("content--config", p === "config");
|
||||||
contentEl.classList.toggle("content--maps", p === "maps");
|
contentEl.classList.toggle("content--maps", p === "maps");
|
||||||
contentEl.classList.toggle("content--missions", p === "missions");
|
contentEl.classList.toggle("content--missions", p === "missions");
|
||||||
|
contentEl.classList.toggle("content--sounds", p === "sounds");
|
||||||
contentEl.classList.toggle("content--integrations", p === "integrations");
|
contentEl.classList.toggle("content--integrations", p === "integrations");
|
||||||
contentEl.classList.toggle("content--monitoring", p === "monitoring");
|
contentEl.classList.toggle("content--monitoring", p === "monitoring");
|
||||||
contentEl.classList.toggle("content--help", p === "help");
|
contentEl.classList.toggle("content--help", p === "help");
|
||||||
@@ -152,6 +155,8 @@ function setActivePage(page) {
|
|||||||
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
|
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
|
||||||
else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide();
|
else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide();
|
||||||
if (p === "maps" && window.MapsApp) window.MapsApp.onPageShow();
|
if (p === "maps" && window.MapsApp) window.MapsApp.onPageShow();
|
||||||
|
if (p === "sounds" && window.SoundsApp) window.SoundsApp.onPageShow();
|
||||||
|
else if (window.SoundsApp?.onPageHide) window.SoundsApp.onPageHide();
|
||||||
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
||||||
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
|
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
|
||||||
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
|
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
dashboard: "dashboard",
|
dashboard: "dashboard",
|
||||||
maps: "maps",
|
maps: "maps",
|
||||||
missions: "missions",
|
missions: "missions",
|
||||||
|
sounds: "integrations",
|
||||||
integrations: "integrations",
|
integrations: "integrations",
|
||||||
};
|
};
|
||||||
const resource = map[page];
|
const resource = map[page];
|
||||||
|
|||||||
174
www/i18n.js
174
www/i18n.js
@@ -72,6 +72,7 @@
|
|||||||
"nav.dashboardsList": "Dashboards",
|
"nav.dashboardsList": "Dashboards",
|
||||||
"nav.missions": "Missions",
|
"nav.missions": "Missions",
|
||||||
"nav.maps": "Maps",
|
"nav.maps": "Maps",
|
||||||
|
"nav.sounds": "Sounds",
|
||||||
"nav.build-robot": "Build Robot",
|
"nav.build-robot": "Build Robot",
|
||||||
"nav.monitoring-log": "System log",
|
"nav.monitoring-log": "System log",
|
||||||
"nav.integrations": "Tích hợp",
|
"nav.integrations": "Tích hợp",
|
||||||
@@ -403,8 +404,94 @@
|
|||||||
"maps.editor.statusWorldIdle": "— m",
|
"maps.editor.statusWorldIdle": "— m",
|
||||||
"maps.editor.statusWorld": "X {x}, Y {y} m",
|
"maps.editor.statusWorld": "X {x}, Y {y} m",
|
||||||
"maps.editor.objectTypesNone": "Chưa chọn object-type",
|
"maps.editor.objectTypesNone": "Chưa chọn object-type",
|
||||||
|
"maps.editor.objectType.wall": "Walls",
|
||||||
|
"maps.editor.objectType.floor": "Floors",
|
||||||
|
"maps.editor.objectType.position": "Positions",
|
||||||
|
"maps.editor.objectType.forbidden": "Forbidden zones",
|
||||||
|
"maps.editor.objectType.preferred": "Preferred zones",
|
||||||
|
"maps.editor.objectType.unpreferred": "Unpreferred zones",
|
||||||
|
"maps.editor.objectType.speed": "Speed zones",
|
||||||
|
"maps.editor.objectType.sound": "Sound zones",
|
||||||
|
"maps.editor.objectType.directional": "Directional zones",
|
||||||
|
"maps.editor.objectType.directionalLine": "Directional lines",
|
||||||
|
"maps.editor.objectType.planner": "Planner zones",
|
||||||
|
"maps.editor.objectType.io": "I/O zones",
|
||||||
|
"maps.editor.filter.walls": "Walls",
|
||||||
|
"maps.editor.filter.floors": "Floors",
|
||||||
|
"maps.editor.filter.positions": "Positions",
|
||||||
|
"maps.editor.filter.forbidden": "Forbidden",
|
||||||
|
"maps.editor.filter.preferred": "Preferred",
|
||||||
|
"maps.editor.filter.unpreferred": "Unpreferred",
|
||||||
|
"maps.editor.filter.speed": "Speed",
|
||||||
|
"maps.editor.filter.sound": "Sound",
|
||||||
|
"maps.editor.tool.draw": "Vẽ — line hoặc polygon (walls, zones…)",
|
||||||
|
"maps.editor.tool.select": "Chọn object",
|
||||||
|
"maps.editor.tool.erase": "Tẩy object",
|
||||||
|
"maps.editor.tool.eraser": "Tẩy pixel — xóa noise trên floor plan (Walls/Floors layer)",
|
||||||
|
"maps.editor.tool.eraseShape": "Xóa line/shape đã vẽ",
|
||||||
|
"maps.editor.tool.eraseSelection": "Xóa vùng chọn — tẩy pixel trong khung",
|
||||||
|
"maps.editor.tool.confirmDraw": "Xác nhận hình vẽ",
|
||||||
|
"maps.editor.drawNeedMorePoints": "Cần thêm điểm trước khi xác nhận (wall ≥ 2, polygon ≥ 3).",
|
||||||
|
"maps.editor.position.title": "Position",
|
||||||
|
"maps.editor.position.name": "Tên",
|
||||||
|
"maps.editor.position.x": "X (m)",
|
||||||
|
"maps.editor.position.y": "Y (m)",
|
||||||
|
"maps.editor.position.yaw": "Hướng (°)",
|
||||||
|
"maps.editor.position.hint": "Click map và kéo để đặt hướng, rồi xác nhận.",
|
||||||
|
"maps.editor.position.invalid": "Nhập X, Y và hướng hợp lệ.",
|
||||||
|
"maps.editor.speed.title": "Speed zone",
|
||||||
|
"maps.editor.speed.limit": "Giới hạn tốc độ (m/s)",
|
||||||
|
"maps.editor.speed.hint": "Robot giảm tốc trong vùng này (0.1–1.5 m/s).",
|
||||||
|
"maps.editor.speed.invalid": "Nhập tốc độ hợp lệ (0.1–1.5 m/s).",
|
||||||
|
"maps.editor.sound.title": "Sound zone",
|
||||||
|
"maps.editor.sound.select": "Sound",
|
||||||
|
"maps.editor.sound.noSound": "— Chọn sound —",
|
||||||
|
"maps.editor.sound.manage": "Quản lý sounds tại Setup → Sounds",
|
||||||
|
"maps.editor.sound.invalid": "Chọn một sound.",
|
||||||
|
"maps.editor.directional.title": "Directional zone",
|
||||||
|
"maps.editor.directional.direction": "Hướng",
|
||||||
|
"maps.editor.directional.degOption": "{deg}°",
|
||||||
|
"maps.editor.directional.shapeHint": "Robot không được di chuyển ngược hướng mũi tên (bước 45°).",
|
||||||
|
"maps.editor.directional.lineWidth": "Độ rộng line (px)",
|
||||||
|
"maps.editor.directional.reversed": "Đảo hướng",
|
||||||
|
"maps.editor.directional.lineHint": "Hướng theo line từ điểm đầu đến điểm cuối.",
|
||||||
|
"maps.editor.directional.lineWidthInvalid": "Nhập độ rộng line hợp lệ (≥ 2 px).",
|
||||||
|
"maps.editor.planner.title": "Planner zone",
|
||||||
|
"maps.editor.planner.noLocalization": "No localization (chỉ encoder)",
|
||||||
|
"maps.editor.planner.lookAhead": "Look-ahead (thu hẹp field of view)",
|
||||||
|
"maps.editor.planner.ignoreObstacles": "Ignore obstacles",
|
||||||
|
"maps.editor.planner.pathDeviation": "Path deviation (m)",
|
||||||
|
"maps.editor.planner.pathTimeout": "Path timeout (s)",
|
||||||
|
"maps.editor.io.title": "I/O zone",
|
||||||
|
"maps.editor.io.module": "I/O module",
|
||||||
|
"maps.editor.io.plcRegister": "PLC register",
|
||||||
|
"maps.editor.io.plcValue": "Giá trị",
|
||||||
|
"maps.editor.io.plcMode": "PLC mode",
|
||||||
|
"maps.editor.io.plcModeSet": "Set",
|
||||||
|
"maps.editor.io.plcModeAdd": "Add",
|
||||||
|
"maps.editor.io.plcModeSubtract": "Subtract",
|
||||||
|
"maps.editor.io.hint": "Robot kích hoạt I/O khi vào vùng này.",
|
||||||
|
"maps.editor.io.moduleRequired": "Nhập tên I/O module.",
|
||||||
"maps.menu.save": "Lưu map",
|
"maps.menu.save": "Lưu map",
|
||||||
|
|
||||||
|
"sounds.title": "Sounds",
|
||||||
|
"sounds.subtitle": "Setup → Sounds — upload và quản lý âm thanh robot cho sound zones.",
|
||||||
|
"sounds.create": "Tạo sound",
|
||||||
|
"sounds.createTitle": "Tạo sound",
|
||||||
|
"sounds.editTitle": "Sửa sound",
|
||||||
|
"sounds.empty": "Chưa có sound. Tạo mới để dùng trong sound zones.",
|
||||||
|
"sounds.name": "Tên",
|
||||||
|
"sounds.description": "Mô tả",
|
||||||
|
"sounds.enabled": "Bật",
|
||||||
|
"sounds.file": "File âm thanh",
|
||||||
|
"sounds.noFile": "Chưa có file",
|
||||||
|
"sounds.upload": "Upload file…",
|
||||||
|
"sounds.play": "Phát",
|
||||||
|
"sounds.playFailed": "Không phát được file.",
|
||||||
|
"sounds.fileMeta": "{name} · {duration}",
|
||||||
|
"sounds.nameRequired": "Nhập tên sound.",
|
||||||
|
"sounds.deleteConfirm": "Xóa sound này?",
|
||||||
|
|
||||||
"missions.title": "Missions",
|
"missions.title": "Missions",
|
||||||
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
|
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
|
||||||
"missions.create": "Tạo mission",
|
"missions.create": "Tạo mission",
|
||||||
@@ -592,6 +679,7 @@
|
|||||||
"nav.dashboardsList": "Dashboards",
|
"nav.dashboardsList": "Dashboards",
|
||||||
"nav.missions": "Missions",
|
"nav.missions": "Missions",
|
||||||
"nav.maps": "Maps",
|
"nav.maps": "Maps",
|
||||||
|
"nav.sounds": "Sounds",
|
||||||
"nav.build-robot": "Build Robot",
|
"nav.build-robot": "Build Robot",
|
||||||
"nav.monitoring-log": "System log",
|
"nav.monitoring-log": "System log",
|
||||||
"nav.integrations": "Integrations",
|
"nav.integrations": "Integrations",
|
||||||
@@ -923,8 +1011,94 @@
|
|||||||
"maps.editor.statusWorldIdle": "— m",
|
"maps.editor.statusWorldIdle": "— m",
|
||||||
"maps.editor.statusWorld": "X {x}, Y {y} m",
|
"maps.editor.statusWorld": "X {x}, Y {y} m",
|
||||||
"maps.editor.objectTypesNone": "No object-type selected",
|
"maps.editor.objectTypesNone": "No object-type selected",
|
||||||
|
"maps.editor.objectType.wall": "Walls",
|
||||||
|
"maps.editor.objectType.floor": "Floors",
|
||||||
|
"maps.editor.objectType.position": "Positions",
|
||||||
|
"maps.editor.objectType.forbidden": "Forbidden zones",
|
||||||
|
"maps.editor.objectType.preferred": "Preferred zones",
|
||||||
|
"maps.editor.objectType.unpreferred": "Unpreferred zones",
|
||||||
|
"maps.editor.objectType.speed": "Speed zones",
|
||||||
|
"maps.editor.objectType.sound": "Sound zones",
|
||||||
|
"maps.editor.objectType.directional": "Directional zones",
|
||||||
|
"maps.editor.objectType.directionalLine": "Directional lines",
|
||||||
|
"maps.editor.objectType.planner": "Planner zones",
|
||||||
|
"maps.editor.objectType.io": "I/O zones",
|
||||||
|
"maps.editor.filter.walls": "Walls",
|
||||||
|
"maps.editor.filter.floors": "Floors",
|
||||||
|
"maps.editor.filter.positions": "Positions",
|
||||||
|
"maps.editor.filter.forbidden": "Forbidden",
|
||||||
|
"maps.editor.filter.preferred": "Preferred",
|
||||||
|
"maps.editor.filter.unpreferred": "Unpreferred",
|
||||||
|
"maps.editor.filter.speed": "Speed",
|
||||||
|
"maps.editor.filter.sound": "Sound",
|
||||||
|
"maps.editor.tool.draw": "Draw — line or polygon (walls, zones…)",
|
||||||
|
"maps.editor.tool.select": "Select object",
|
||||||
|
"maps.editor.tool.erase": "Eraser",
|
||||||
|
"maps.editor.tool.eraser": "Pixel eraser — remove noise on floor plan (Walls/Floors layer)",
|
||||||
|
"maps.editor.tool.eraseShape": "Erase shape or line",
|
||||||
|
"maps.editor.tool.eraseSelection": "Erase by selection — clear pixels in rectangle",
|
||||||
|
"maps.editor.tool.confirmDraw": "Confirm shape",
|
||||||
|
"maps.editor.drawNeedMorePoints": "Add more points before confirming (wall ≥ 2, polygon ≥ 3).",
|
||||||
|
"maps.editor.position.title": "Position",
|
||||||
|
"maps.editor.position.name": "Name",
|
||||||
|
"maps.editor.position.x": "X (m)",
|
||||||
|
"maps.editor.position.y": "Y (m)",
|
||||||
|
"maps.editor.position.yaw": "Orientation (°)",
|
||||||
|
"maps.editor.position.hint": "Click map and drag to set orientation, then confirm.",
|
||||||
|
"maps.editor.position.invalid": "Enter valid X, Y and orientation.",
|
||||||
|
"maps.editor.speed.title": "Speed zone",
|
||||||
|
"maps.editor.speed.limit": "Speed limit (m/s)",
|
||||||
|
"maps.editor.speed.hint": "Robot slows to this speed inside the zone (0.1–1.5 m/s).",
|
||||||
|
"maps.editor.speed.invalid": "Enter a valid speed (0.1–1.5 m/s).",
|
||||||
|
"maps.editor.sound.title": "Sound zone",
|
||||||
|
"maps.editor.sound.select": "Sound",
|
||||||
|
"maps.editor.sound.noSound": "— Select sound —",
|
||||||
|
"maps.editor.sound.manage": "Manage sounds in Setup → Sounds",
|
||||||
|
"maps.editor.sound.invalid": "Select a sound.",
|
||||||
|
"maps.editor.directional.title": "Directional zone",
|
||||||
|
"maps.editor.directional.direction": "Direction",
|
||||||
|
"maps.editor.directional.degOption": "{deg}°",
|
||||||
|
"maps.editor.directional.shapeHint": "Robot cannot move opposite to the arrow (45° steps).",
|
||||||
|
"maps.editor.directional.lineWidth": "Line width (px)",
|
||||||
|
"maps.editor.directional.reversed": "Reverse direction",
|
||||||
|
"maps.editor.directional.lineHint": "Direction follows the line from first to last point.",
|
||||||
|
"maps.editor.directional.lineWidthInvalid": "Enter a valid line width (≥ 2 px).",
|
||||||
|
"maps.editor.planner.title": "Planner zone",
|
||||||
|
"maps.editor.planner.noLocalization": "No localization (encoders only)",
|
||||||
|
"maps.editor.planner.lookAhead": "Look-ahead (narrow field of view)",
|
||||||
|
"maps.editor.planner.ignoreObstacles": "Ignore obstacles",
|
||||||
|
"maps.editor.planner.pathDeviation": "Path deviation (m)",
|
||||||
|
"maps.editor.planner.pathTimeout": "Path timeout (s)",
|
||||||
|
"maps.editor.io.title": "I/O zone",
|
||||||
|
"maps.editor.io.module": "I/O module",
|
||||||
|
"maps.editor.io.plcRegister": "PLC register",
|
||||||
|
"maps.editor.io.plcValue": "Value",
|
||||||
|
"maps.editor.io.plcMode": "PLC mode",
|
||||||
|
"maps.editor.io.plcModeSet": "Set",
|
||||||
|
"maps.editor.io.plcModeAdd": "Add",
|
||||||
|
"maps.editor.io.plcModeSubtract": "Subtract",
|
||||||
|
"maps.editor.io.hint": "Robot activates I/O when entering the zone.",
|
||||||
|
"maps.editor.io.moduleRequired": "Enter an I/O module name.",
|
||||||
"maps.menu.save": "Save map",
|
"maps.menu.save": "Save map",
|
||||||
|
|
||||||
|
"sounds.title": "Sounds",
|
||||||
|
"sounds.subtitle": "Setup → Sounds — upload and manage robot sounds for sound zones.",
|
||||||
|
"sounds.create": "Create sound",
|
||||||
|
"sounds.createTitle": "Create sound",
|
||||||
|
"sounds.editTitle": "Edit sound",
|
||||||
|
"sounds.empty": "No sounds yet. Create one to use in sound zones.",
|
||||||
|
"sounds.name": "Name",
|
||||||
|
"sounds.description": "Description",
|
||||||
|
"sounds.enabled": "Enabled",
|
||||||
|
"sounds.file": "Audio file",
|
||||||
|
"sounds.noFile": "No file uploaded",
|
||||||
|
"sounds.upload": "Upload file…",
|
||||||
|
"sounds.play": "Play",
|
||||||
|
"sounds.playFailed": "Could not play file.",
|
||||||
|
"sounds.fileMeta": "{name} · {duration}",
|
||||||
|
"sounds.nameRequired": "Enter a sound name.",
|
||||||
|
"sounds.deleteConfirm": "Delete this sound?",
|
||||||
|
|
||||||
"missions.title": "Missions",
|
"missions.title": "Missions",
|
||||||
"missions.subtitle": "Setup → Missions — robot task list.",
|
"missions.subtitle": "Setup → Missions — robot task list.",
|
||||||
"missions.create": "Create mission",
|
"missions.create": "Create mission",
|
||||||
|
|||||||
333
www/index.html
333
www/index.html
@@ -863,6 +863,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="page" id="pageSounds" data-page-content="sounds" hidden>
|
||||||
|
<div class="soundsPage">
|
||||||
|
<section class="card">
|
||||||
|
<div class="cardHeader">
|
||||||
|
<div>
|
||||||
|
<div class="cardTitle" data-i18n="sounds.title">Sounds</div>
|
||||||
|
<div class="cardSub" data-i18n="sounds.subtitle">Setup → Sounds — upload and manage robot sounds for sound zones.</div>
|
||||||
|
</div>
|
||||||
|
<button id="soundCreateBtn" type="button" class="btn primary" data-i18n="sounds.create">Create sound</button>
|
||||||
|
</div>
|
||||||
|
<div class="cardBody">
|
||||||
|
<div id="soundListEmpty" class="mutedNote" hidden data-i18n="sounds.empty">No sounds yet. Create one to use in sound zones.</div>
|
||||||
|
<div id="soundList" class="missionList"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="soundEditDialog" class="mapsMirDialog">
|
||||||
|
<form id="soundEditForm" method="dialog">
|
||||||
|
<h2 class="mapsMirDialogTitle" id="soundEditTitle" data-i18n="sounds.createTitle">Create sound</h2>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="sounds.name">Name</span>
|
||||||
|
<input type="text" id="soundEditName" autocomplete="off" required />
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="sounds.description">Description</span>
|
||||||
|
<textarea id="soundEditDescription" rows="2"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField mapsMirField--checkbox">
|
||||||
|
<input type="checkbox" id="soundEditEnabled" checked />
|
||||||
|
<span data-i18n="sounds.enabled">Enabled</span>
|
||||||
|
</label>
|
||||||
|
<div class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="sounds.file">Audio file</span>
|
||||||
|
<p id="soundEditFileMeta" class="mutedNote">—</p>
|
||||||
|
<div class="mapsMirDialogFooter mapsMirDialogFooter--inline">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundEditUploadBtn" data-i18n="sounds.upload">Upload file…</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundEditPlayBtn" disabled data-i18n="sounds.play">Play</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="soundEditUploadInput" accept="audio/*,.wav,.mp3,.ogg" hidden />
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirDialogFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundEditCancelBtn" data-i18n="common.cancel">Cancel</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="soundEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
|
||||||
|
<button type="submit" class="mapsMirBtn mapsMirBtn--primary" data-i18n="common.save">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="page" id="pageMaps" data-page-content="maps" hidden>
|
<div class="page" id="pageMaps" data-page-content="maps" hidden>
|
||||||
<div id="mapsListView" class="mapsMirPage">
|
<div id="mapsListView" class="mapsMirPage">
|
||||||
<header class="mapsMirHeader">
|
<header class="mapsMirHeader">
|
||||||
@@ -993,7 +1043,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="mapEditorMappingBar" role="toolbar" data-i18n-aria="maps.editor.toolbarAria">
|
<div class="mapEditorMappingBar" id="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">
|
<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>
|
<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>
|
||||||
@@ -1010,9 +1060,119 @@
|
|||||||
<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>
|
<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>
|
</button>
|
||||||
<div class="mapEditorMappingBarSpacer" aria-hidden="true"></div>
|
<div class="mapEditorMappingBarSpacer" aria-hidden="true"></div>
|
||||||
<select class="mapEditorObjectSelect" id="mapEditorObjectSelect" disabled>
|
<div class="mapEditorObjectTypePicker" id="mapEditorObjectTypePicker">
|
||||||
<option value="" data-i18n="maps.editor.objectTypesNone">No object-type selected</option>
|
<button
|
||||||
</select>
|
type="button"
|
||||||
|
class="mapEditorObjectTypeBtn"
|
||||||
|
id="mapEditorObjectTypeBtn"
|
||||||
|
disabled
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="mapEditorObjectTypeMenu"
|
||||||
|
>
|
||||||
|
<span class="mapEditorObjectTypeIcon" id="mapEditorObjectTypeIcon" aria-hidden="true"></span>
|
||||||
|
<span class="mapEditorObjectTypeLabel" id="mapEditorObjectTypeLabel" data-i18n="maps.editor.objectTypesNone">No object-type selected</span>
|
||||||
|
<svg class="mapEditorObjectTypeChevron" width="10" height="6" viewBox="0 0 10 6" aria-hidden="true"><path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="mapEditorObjectTypeMenu" id="mapEditorObjectTypeMenu" role="listbox" hidden>
|
||||||
|
<li class="mapEditorObjectTypeOption" role="option" data-value="" tabindex="-1">
|
||||||
|
<span class="mapEditorObjectTypeOptionIcon" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="3" y="3" width="12" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-dasharray="2.5 2"/></svg>
|
||||||
|
</span>
|
||||||
|
<span data-i18n="maps.editor.objectTypesNone">No object-type selected</span>
|
||||||
|
</li>
|
||||||
|
<li class="mapEditorObjectTypeOption" role="option" data-value="wall" tabindex="-1">
|
||||||
|
<span class="mapEditorObjectTypeOptionIcon mapEditorObjectTypeOptionIcon--wall" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><path d="M3 14L15 4" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
|
</span>
|
||||||
|
<span data-i18n="maps.editor.objectType.wall">Walls</span>
|
||||||
|
</li>
|
||||||
|
<li class="mapEditorObjectTypeOption" role="option" data-value="floor" tabindex="-1">
|
||||||
|
<span class="mapEditorObjectTypeOptionIcon mapEditorObjectTypeOptionIcon--floor" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.18" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>
|
||||||
|
</span>
|
||||||
|
<span data-i18n="maps.editor.objectType.floor">Floors</span>
|
||||||
|
</li>
|
||||||
|
<li class="mapEditorObjectTypeOption" role="option" data-value="position" tabindex="-1">
|
||||||
|
<span class="mapEditorObjectTypeOptionIcon mapEditorObjectTypeOptionIcon--position" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><circle cx="4.5" cy="9" r="2.2" fill="currentColor"/><path d="M7 9h8M13 9l-2.5-2.2M13 9l-2.5 2.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</span>
|
||||||
|
<span data-i18n="maps.editor.objectType.position">Positions</span>
|
||||||
|
</li>
|
||||||
|
<li class="mapEditorObjectTypeOption" role="option" data-value="forbidden" tabindex="-1">
|
||||||
|
<span class="mapEditorObjectTypeOptionIcon mapEditorObjectTypeOptionIcon--forbidden" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.22" stroke="currentColor" stroke-width="1.5"/><path d="M5 13L13 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
|
||||||
|
</span>
|
||||||
|
<span data-i18n="maps.editor.objectType.forbidden">Forbidden zones</span>
|
||||||
|
</li>
|
||||||
|
<li class="mapEditorObjectTypeOption" role="option" data-value="preferred" tabindex="-1">
|
||||||
|
<span class="mapEditorObjectTypeOptionIcon mapEditorObjectTypeOptionIcon--preferred" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.28" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>
|
||||||
|
</span>
|
||||||
|
<span data-i18n="maps.editor.objectType.preferred">Preferred zones</span>
|
||||||
|
</li>
|
||||||
|
<li class="mapEditorObjectTypeOption" role="option" data-value="unpreferred" tabindex="-1">
|
||||||
|
<span class="mapEditorObjectTypeOptionIcon mapEditorObjectTypeOptionIcon--unpreferred" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.18" stroke="currentColor" stroke-width="1.5" stroke-dasharray="3 2" stroke-linejoin="round"/></svg>
|
||||||
|
</span>
|
||||||
|
<span data-i18n="maps.editor.objectType.unpreferred">Unpreferred zones</span>
|
||||||
|
</li>
|
||||||
|
<li class="mapEditorObjectTypeOption" role="option" data-value="speed" tabindex="-1">
|
||||||
|
<span class="mapEditorObjectTypeOptionIcon mapEditorObjectTypeOptionIcon--speed" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.2" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M6 11h6M9 8v3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||||
|
</span>
|
||||||
|
<span data-i18n="maps.editor.objectType.speed">Speed zones</span>
|
||||||
|
</li>
|
||||||
|
<li class="mapEditorObjectTypeOption" role="option" data-value="sound" tabindex="-1">
|
||||||
|
<span class="mapEditorObjectTypeOptionIcon mapEditorObjectTypeOptionIcon--sound" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.18" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M6 10.5v-3h2l2.5-1.5v7L8 11.5H6z" fill="currentColor" fill-opacity="0.5"/></svg>
|
||||||
|
</span>
|
||||||
|
<span data-i18n="maps.editor.objectType.sound">Sound zones</span>
|
||||||
|
</li>
|
||||||
|
<li class="mapEditorObjectTypeOption" role="option" data-value="directional" tabindex="-1">
|
||||||
|
<span class="mapEditorObjectTypeOptionIcon mapEditorObjectTypeOptionIcon--directional" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.16" stroke="currentColor" stroke-width="1.5"/><path d="M6 9h6M10 9l-2-2M10 9l-2 2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
|
</span>
|
||||||
|
<span data-i18n="maps.editor.objectType.directional">Directional zones</span>
|
||||||
|
</li>
|
||||||
|
<li class="mapEditorObjectTypeOption" role="option" data-value="directional_line" tabindex="-1">
|
||||||
|
<span class="mapEditorObjectTypeOptionIcon mapEditorObjectTypeOptionIcon--directionalLine" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><path d="M3 14L15 4" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><path d="M11 4h4v4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
|
</span>
|
||||||
|
<span data-i18n="maps.editor.objectType.directionalLine">Directional lines</span>
|
||||||
|
</li>
|
||||||
|
<li class="mapEditorObjectTypeOption" role="option" data-value="planner" tabindex="-1">
|
||||||
|
<span class="mapEditorObjectTypeOptionIcon mapEditorObjectTypeOptionIcon--planner" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.14" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 2"/><circle cx="9" cy="9" r="2" fill="currentColor"/></svg>
|
||||||
|
</span>
|
||||||
|
<span data-i18n="maps.editor.objectType.planner">Planner zones</span>
|
||||||
|
</li>
|
||||||
|
<li class="mapEditorObjectTypeOption" role="option" data-value="io" tabindex="-1">
|
||||||
|
<span class="mapEditorObjectTypeOptionIcon mapEditorObjectTypeOptionIcon--io" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.12" stroke="currentColor" stroke-width="1.5"/><path d="M6.5 10h5M8 8v4M10 8v4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||||
|
</span>
|
||||||
|
<span data-i18n="maps.editor.objectType.io">I/O zones</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="mapEditorMapTool" id="mapEditorDrawBtn" data-tool="draw" disabled data-i18n-title="maps.editor.tool.draw" title="Draw">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M14.5 3.5l2 2-9 9H5.5v-2l9-9z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M12.5 5.5l2 2" stroke="currentColor" stroke-width="1.4"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mapEditorMapTool" id="mapEditorSelectBtn" data-tool="select" disabled data-i18n-title="maps.editor.tool.select" title="Select">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M4 3l12 7-5.5 1.5L8 17 6.5 11.5 4 3z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mapEditorMapTool" id="mapEditorEraserBtn" data-tool="eraser" disabled data-i18n-title="maps.editor.tool.eraser" title="Eraser">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M4 14h12M6.5 14L14 6.5l2.5 2.5L9 16.5H6.5V14z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mapEditorMapTool" id="mapEditorEraseSelectionBtn" data-tool="eraseSelection" disabled data-i18n-title="maps.editor.tool.eraseSelection" title="Erase by selection">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><rect x="4" y="4" width="12" height="12" rx="1" fill="none" stroke="currentColor" stroke-width="1.4" stroke-dasharray="3 2"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mapEditorMapTool" id="mapEditorEraseShapeBtn" data-tool="eraseShape" disabled data-i18n-title="maps.editor.tool.eraseShape" title="Erase shape">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5L5 15" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mapEditorMapTool" id="mapEditorConfirmDrawBtn" hidden disabled data-i18n-title="maps.editor.tool.confirmDraw" title="Confirm shape">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M5 10l3.5 3.5L15 7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
<button type="button" class="mapEditorMapTool" id="mapEditorCrosshairBtn" disabled data-i18n-title="maps.editor.tool.crosshair" title="Crosshair">
|
<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>
|
<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>
|
||||||
@@ -1050,6 +1210,7 @@
|
|||||||
<div class="mapEditorSheetGrid" id="mapEditorSheetGrid" aria-hidden="true"></div>
|
<div class="mapEditorSheetGrid" id="mapEditorSheetGrid" aria-hidden="true"></div>
|
||||||
<img id="mapEditorImage" class="mapEditorImageLoader" alt="" draggable="false" hidden />
|
<img id="mapEditorImage" class="mapEditorImageLoader" alt="" draggable="false" hidden />
|
||||||
<canvas id="mapEditorOccupancyCanvas" class="mapEditorOccupancyCanvas" hidden aria-hidden="true"></canvas>
|
<canvas id="mapEditorOccupancyCanvas" class="mapEditorOccupancyCanvas" hidden aria-hidden="true"></canvas>
|
||||||
|
<svg id="mapEditorObjectsSvg" class="mapEditorObjectsSvg" xmlns="http://www.w3.org/2000/svg" hidden aria-hidden="true"></svg>
|
||||||
<div id="mapEditorOrigin" class="mapEditorOrigin" hidden aria-hidden="true">
|
<div id="mapEditorOrigin" class="mapEditorOrigin" hidden aria-hidden="true">
|
||||||
<span class="mapEditorOriginAxis mapEditorOriginAxis--x" aria-hidden="true"></span>
|
<span class="mapEditorOriginAxis mapEditorOriginAxis--x" aria-hidden="true"></span>
|
||||||
<span class="mapEditorOriginAxis mapEditorOriginAxis--z" aria-hidden="true"></span>
|
<span class="mapEditorOriginAxis mapEditorOriginAxis--z" aria-hidden="true"></span>
|
||||||
@@ -1198,6 +1359,164 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="mapEditorPositionDialog" class="mapsMirDialog">
|
||||||
|
<form id="mapEditorPositionForm" method="dialog">
|
||||||
|
<h2 class="mapsMirDialogTitle" data-i18n="maps.editor.position.title">Position</h2>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.editor.position.name">Name</span>
|
||||||
|
<input type="text" id="mapPositionName" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<div class="mapsMirFieldRow">
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.editor.position.x">X (m)</span>
|
||||||
|
<input type="number" id="mapPositionX" step="any" required />
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.editor.position.y">Y (m)</span>
|
||||||
|
<input type="number" id="mapPositionY" step="any" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.editor.position.yaw">Orientation (°)</span>
|
||||||
|
<input type="number" id="mapPositionYaw" step="any" required />
|
||||||
|
</label>
|
||||||
|
<p class="mapsMirDialogHint" data-i18n="maps.editor.position.hint">Click map and drag to set orientation, then confirm.</p>
|
||||||
|
<div class="mapsMirDialogFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="mapPositionCancelBtn" data-i18n="common.cancel">Cancel</button>
|
||||||
|
<button type="submit" class="mapsMirBtn mapsMirBtn--primary" data-i18n="common.save">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="mapEditorSpeedDialog" class="mapsMirDialog">
|
||||||
|
<form id="mapEditorSpeedForm" method="dialog">
|
||||||
|
<h2 class="mapsMirDialogTitle" data-i18n="maps.editor.speed.title">Speed zone</h2>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.editor.speed.limit">Speed limit (m/s)</span>
|
||||||
|
<input type="number" id="mapSpeedMps" min="0.1" max="1.5" step="0.05" value="0.8" required />
|
||||||
|
</label>
|
||||||
|
<p class="mapsMirDialogHint" data-i18n="maps.editor.speed.hint">Robot slows to this speed while inside the zone (0.1–1.5 m/s).</p>
|
||||||
|
<div class="mapsMirDialogFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="mapSpeedCancelBtn" data-i18n="common.cancel">Cancel</button>
|
||||||
|
<button type="submit" class="mapsMirBtn mapsMirBtn--primary" data-i18n="common.save">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="mapEditorSoundDialog" class="mapsMirDialog">
|
||||||
|
<form id="mapEditorSoundForm" method="dialog">
|
||||||
|
<h2 class="mapsMirDialogTitle" data-i18n="maps.editor.sound.title">Sound zone</h2>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.editor.sound.select">Sound</span>
|
||||||
|
<select id="mapSoundZoneSelect" required></select>
|
||||||
|
</label>
|
||||||
|
<p class="mapsMirDialogHint">
|
||||||
|
<button type="button" class="mapsMirLinkBtn" id="mapSoundManageLink" data-i18n="maps.editor.sound.manage">Manage sounds in Setup → Sounds</button>
|
||||||
|
</p>
|
||||||
|
<div class="mapsMirDialogFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="mapSoundCancelBtn" data-i18n="common.cancel">Cancel</button>
|
||||||
|
<button type="submit" class="mapsMirBtn mapsMirBtn--primary" data-i18n="common.save">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="mapEditorDirectionalDialog" class="mapsMirDialog">
|
||||||
|
<form id="mapEditorDirectionalForm" method="dialog">
|
||||||
|
<h2 class="mapsMirDialogTitle" data-i18n="maps.editor.directional.title">Directional zone</h2>
|
||||||
|
<div id="mapDirectionalShapePanel">
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.editor.directional.direction">Direction</span>
|
||||||
|
<select id="mapDirectionalDeg"></select>
|
||||||
|
</label>
|
||||||
|
<p class="mapsMirDialogHint" data-i18n="maps.editor.directional.shapeHint">Robot cannot move opposite to the arrow (45° steps).</p>
|
||||||
|
</div>
|
||||||
|
<div id="mapDirectionalLinePanel" hidden>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.editor.directional.lineWidth">Line width (px)</span>
|
||||||
|
<input type="number" id="mapDirectionalLineWidth" min="2" max="48" step="1" value="8" />
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField mapsMirField--checkbox">
|
||||||
|
<input type="checkbox" id="mapDirectionalReversed" />
|
||||||
|
<span data-i18n="maps.editor.directional.reversed">Reverse direction</span>
|
||||||
|
</label>
|
||||||
|
<p class="mapsMirDialogHint" data-i18n="maps.editor.directional.lineHint">Direction follows line from first to last point.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirDialogFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="mapDirectionalCancelBtn" data-i18n="common.cancel">Cancel</button>
|
||||||
|
<button type="submit" class="mapsMirBtn mapsMirBtn--primary" data-i18n="common.save">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="mapEditorPlannerDialog" class="mapsMirDialog">
|
||||||
|
<form id="mapEditorPlannerForm" method="dialog">
|
||||||
|
<h2 class="mapsMirDialogTitle" data-i18n="maps.editor.planner.title">Planner zone</h2>
|
||||||
|
<label class="mapsMirField mapsMirField--checkbox">
|
||||||
|
<input type="checkbox" id="mapPlannerNoLocalization" />
|
||||||
|
<span data-i18n="maps.editor.planner.noLocalization">No localization (encoders only)</span>
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField mapsMirField--checkbox">
|
||||||
|
<input type="checkbox" id="mapPlannerLookAhead" />
|
||||||
|
<span data-i18n="maps.editor.planner.lookAhead">Look-ahead (narrow field of view)</span>
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField mapsMirField--checkbox">
|
||||||
|
<input type="checkbox" id="mapPlannerIgnoreObstacles" />
|
||||||
|
<span data-i18n="maps.editor.planner.ignoreObstacles">Ignore obstacles</span>
|
||||||
|
</label>
|
||||||
|
<div class="mapsMirFieldRow">
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.editor.planner.pathDeviation">Path deviation (m)</span>
|
||||||
|
<input type="number" id="mapPlannerPathDeviation" min="0" max="3" step="0.1" value="0.5" />
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.editor.planner.pathTimeout">Path timeout (s)</span>
|
||||||
|
<input type="number" id="mapPlannerPathTimeout" min="0" step="1" value="30" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirDialogFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="mapPlannerCancelBtn" data-i18n="common.cancel">Cancel</button>
|
||||||
|
<button type="submit" class="mapsMirBtn mapsMirBtn--primary" data-i18n="common.save">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="mapEditorIoDialog" class="mapsMirDialog">
|
||||||
|
<form id="mapEditorIoForm" method="dialog">
|
||||||
|
<h2 class="mapsMirDialogTitle" data-i18n="maps.editor.io.title">I/O zone</h2>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.editor.io.module">I/O module</span>
|
||||||
|
<input type="text" id="mapIoModule" list="mapIoModuleList" autocomplete="off" required />
|
||||||
|
<datalist id="mapIoModuleList">
|
||||||
|
<option value="GPIO module 1"></option>
|
||||||
|
<option value="PLC I/O 1"></option>
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
<div class="mapsMirFieldRow">
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.editor.io.plcRegister">PLC register</span>
|
||||||
|
<input type="number" id="mapIoPlcRegister" step="1" />
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.editor.io.plcValue">Value</span>
|
||||||
|
<input type="number" id="mapIoPlcValue" step="1" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.editor.io.plcMode">PLC mode</span>
|
||||||
|
<select id="mapIoPlcMode">
|
||||||
|
<option value="set" data-i18n="maps.editor.io.plcModeSet">Set</option>
|
||||||
|
<option value="add" data-i18n="maps.editor.io.plcModeAdd">Add</option>
|
||||||
|
<option value="subtract" data-i18n="maps.editor.io.plcModeSubtract">Subtract</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p class="mapsMirDialogHint" data-i18n="maps.editor.io.hint">Robot activates I/O when entering the zone.</p>
|
||||||
|
<div class="mapsMirDialogFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="mapIoCancelBtn" data-i18n="common.cancel">Cancel</button>
|
||||||
|
<button type="submit" class="mapsMirBtn mapsMirBtn--primary" data-i18n="common.save">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="mapUploadConfirmDialog" class="mapsMirDialog">
|
<dialog id="mapUploadConfirmDialog" class="mapsMirDialog">
|
||||||
<div class="mapsMirDialogPanel">
|
<div class="mapsMirDialogPanel">
|
||||||
<h2 class="mapsMirDialogTitle" data-i18n="maps.uploadConfirm.title">Overwrite map?</h2>
|
<h2 class="mapsMirDialogTitle" data-i18n="maps.uploadConfirm.title">Overwrite map?</h2>
|
||||||
@@ -1729,8 +2048,14 @@ GET /api/v2.0.0/status</pre>
|
|||||||
<script src="/missions.js"></script>
|
<script src="/missions.js"></script>
|
||||||
<script src="/map-geo.js"></script>
|
<script src="/map-geo.js"></script>
|
||||||
<script src="/map-occupancy-canvas.js"></script>
|
<script src="/map-occupancy-canvas.js"></script>
|
||||||
|
<script src="/map-occupancy-edit.js"></script>
|
||||||
|
<script src="/map-planner-zones.js"></script>
|
||||||
|
<script src="/map-behavior-zones.js"></script>
|
||||||
|
<script src="/map-advanced-zones.js"></script>
|
||||||
|
<script src="/map-objects.js"></script>
|
||||||
<script src="/map-yaml.js"></script>
|
<script src="/map-yaml.js"></script>
|
||||||
<script src="/maps.js"></script>
|
<script src="/maps.js"></script>
|
||||||
|
<script src="/sounds.js"></script>
|
||||||
<script src="/map-editor.js"></script>
|
<script src="/map-editor.js"></script>
|
||||||
<script src="/topbar.js"></script>
|
<script src="/topbar.js"></script>
|
||||||
<script src="/dashboard.js"></script>
|
<script src="/dashboard.js"></script>
|
||||||
|
|||||||
233
www/map-advanced-zones.js
Normal file
233
www/map-advanced-zones.js
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
(() => {
|
||||||
|
/**
|
||||||
|
* Runtime hooks for Directional / Planner settings / I/O map zones (MiR §4.2.6.5, .10, .11).
|
||||||
|
*/
|
||||||
|
const TYPES = {
|
||||||
|
directional: "directional",
|
||||||
|
directional_line: "directional_line",
|
||||||
|
planner: "planner",
|
||||||
|
io: "io",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADVANCED_TYPES = new Set([
|
||||||
|
TYPES.directional,
|
||||||
|
TYPES.directional_line,
|
||||||
|
TYPES.planner,
|
||||||
|
TYPES.io,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DIRECTION_DEGREES = [0, 45, 90, 135, 180, 225, 270, 315];
|
||||||
|
|
||||||
|
const DEFAULT_PLANNER = {
|
||||||
|
no_localization: false,
|
||||||
|
look_ahead: false,
|
||||||
|
path_deviation: 0.5,
|
||||||
|
path_timeout: 30,
|
||||||
|
ignore_obstacles: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_IO = {
|
||||||
|
io_module: "",
|
||||||
|
plc_register: null,
|
||||||
|
plc_value: null,
|
||||||
|
plc_mode: "set",
|
||||||
|
};
|
||||||
|
|
||||||
|
function pointInPolygon(px, py, points) {
|
||||||
|
return window.MapPlannerZones?.pointInPolygon(px, py, points) || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDirectionDeg(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return 0;
|
||||||
|
let deg = ((Math.round(n / 45) * 45) % 360 + 360) % 360;
|
||||||
|
if (!DIRECTION_DEGREES.includes(deg)) {
|
||||||
|
deg = DIRECTION_DEGREES.reduce((best, d) =>
|
||||||
|
Math.abs(d - deg) < Math.abs(best - deg) ? d : best,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPathDeviation(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return DEFAULT_PLANNER.path_deviation;
|
||||||
|
return Math.min(3, Math.max(0, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPathTimeout(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n) || n < 0) return DEFAULT_PLANNER.path_timeout;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlannerSettings(raw = {}) {
|
||||||
|
return {
|
||||||
|
no_localization: !!raw.no_localization,
|
||||||
|
look_ahead: !!raw.look_ahead,
|
||||||
|
path_deviation: clampPathDeviation(raw.path_deviation),
|
||||||
|
path_timeout: clampPathTimeout(raw.path_timeout),
|
||||||
|
ignore_obstacles: !!raw.ignore_obstacles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIoSettings(raw = {}) {
|
||||||
|
const mode = raw.plc_mode;
|
||||||
|
return {
|
||||||
|
io_module: typeof raw.io_module === "string" ? raw.io_module : "",
|
||||||
|
plc_register:
|
||||||
|
raw.plc_register == null || raw.plc_register === ""
|
||||||
|
? null
|
||||||
|
: Number(raw.plc_register),
|
||||||
|
plc_value:
|
||||||
|
raw.plc_value == null || raw.plc_value === "" ? null : Number(raw.plc_value),
|
||||||
|
plc_mode: mode === "add" || mode === "subtract" ? mode : "set",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdvancedZoneType(type) {
|
||||||
|
return ADVANCED_TYPES.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectionalType(type) {
|
||||||
|
return type === TYPES.directional || type === TYPES.directional_line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAdvancedZones(zones) {
|
||||||
|
return (Array.isArray(zones) ? zones : []).filter((z) => isAdvancedZoneType(z?.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointNearPolyline(px, py, points, halfWidth = 8) {
|
||||||
|
if (!points?.length || points.length < 2) return false;
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const p1 = points[i];
|
||||||
|
const p2 = points[i + 1];
|
||||||
|
const dx = p2.x - p1.x;
|
||||||
|
const dy = p2.y - p1.y;
|
||||||
|
const lenSq = dx * dx + dy * dy;
|
||||||
|
let dist;
|
||||||
|
if (lenSq === 0) dist = Math.hypot(px - p1.x, py - p1.y);
|
||||||
|
else {
|
||||||
|
let t = ((px - p1.x) * dx + (py - p1.y) * dy) / lenSq;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
dist = Math.hypot(px - (p1.x + t * dx), py - (p1.y + t * dy));
|
||||||
|
}
|
||||||
|
if (dist <= halfWidth) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zonesAtPoint(zones, px, py) {
|
||||||
|
const list = Array.isArray(zones) ? zones : [];
|
||||||
|
const hits = [];
|
||||||
|
for (let i = list.length - 1; i >= 0; i--) {
|
||||||
|
const z = list[i];
|
||||||
|
if (!isAdvancedZoneType(z?.type) || !z.points?.length) continue;
|
||||||
|
if (z.type === TYPES.directional_line) {
|
||||||
|
const width = Number(z.line_width) || 8;
|
||||||
|
if (pointNearPolyline(px, py, z.points, width / 2 + 4)) hits.push(z);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pointInPolygon(px, py, z.points)) hits.push(z);
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineDirectionDeg(zone) {
|
||||||
|
if (!zone?.points?.length || zone.points.length < 2) return 0;
|
||||||
|
const p0 = zone.points[0];
|
||||||
|
const p1 = zone.points[zone.points.length - 1];
|
||||||
|
let deg = (Math.atan2(-(p1.y - p0.y), p1.x - p0.x) * 180) / Math.PI;
|
||||||
|
if (zone.reversed) deg = (deg + 180) % 360;
|
||||||
|
return normalizeDirectionDeg(deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoneDirectionDeg(zone) {
|
||||||
|
if (!zone) return 0;
|
||||||
|
if (zone.type === TYPES.directional_line) return lineDirectionDeg(zone);
|
||||||
|
if (zone.type === TYPES.directional) return normalizeDirectionDeg(zone.direction_deg);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heading radians (map image coords) allowed if dot >= 0 with zone arrow. */
|
||||||
|
function isHeadingAllowed(zone, headingRad) {
|
||||||
|
const arrowRad = (-zoneDirectionDeg(zone) * Math.PI) / 180;
|
||||||
|
const hx = Math.cos(headingRad);
|
||||||
|
const hy = Math.sin(headingRad);
|
||||||
|
const ax = Math.cos(arrowRad);
|
||||||
|
const ay = Math.sin(arrowRad);
|
||||||
|
return hx * ax + hy * ay >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectionalConstraint(zones, px, py, headingRad) {
|
||||||
|
const hits = zonesAtPoint(zones, px, py).filter(isDirectionalType);
|
||||||
|
const zone = hits[0];
|
||||||
|
if (!zone) return null;
|
||||||
|
return {
|
||||||
|
zone,
|
||||||
|
direction_deg: zoneDirectionDeg(zone),
|
||||||
|
allowed: isHeadingAllowed(zone, headingRad),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlannerSettings(zones, px, py) {
|
||||||
|
const zone = zonesAtPoint(zones, px, py).find((z) => z.type === TYPES.planner);
|
||||||
|
if (!zone) return null;
|
||||||
|
return {
|
||||||
|
zone,
|
||||||
|
settings: normalizePlannerSettings(zone),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIoActivation(zones, px, py) {
|
||||||
|
const zone = zonesAtPoint(zones, px, py).find((z) => z.type === TYPES.io);
|
||||||
|
if (!zone) return null;
|
||||||
|
return {
|
||||||
|
zone,
|
||||||
|
settings: normalizeIoSettings(zone),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyPoint(zones, px, py, headingRad = null) {
|
||||||
|
const hits = zonesAtPoint(zones, px, py);
|
||||||
|
const directional = hits.find(isDirectionalType);
|
||||||
|
const planner = hits.find((z) => z.type === TYPES.planner);
|
||||||
|
const io = hits.find((z) => z.type === TYPES.io);
|
||||||
|
const out = {
|
||||||
|
directional: directional || null,
|
||||||
|
direction_deg: directional ? zoneDirectionDeg(directional) : null,
|
||||||
|
planner: planner || null,
|
||||||
|
planner_settings: planner ? normalizePlannerSettings(planner) : null,
|
||||||
|
io: io || null,
|
||||||
|
io_settings: io ? normalizeIoSettings(io) : null,
|
||||||
|
zones: hits,
|
||||||
|
};
|
||||||
|
if (directional && headingRad != null) {
|
||||||
|
out.heading_allowed = isHeadingAllowed(directional, headingRad);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.MapAdvancedZones = {
|
||||||
|
TYPES,
|
||||||
|
ADVANCED_TYPES,
|
||||||
|
DIRECTION_DEGREES,
|
||||||
|
DEFAULT_PLANNER,
|
||||||
|
DEFAULT_IO,
|
||||||
|
normalizeDirectionDeg,
|
||||||
|
normalizePlannerSettings,
|
||||||
|
normalizeIoSettings,
|
||||||
|
clampPathDeviation,
|
||||||
|
clampPathTimeout,
|
||||||
|
isAdvancedZoneType,
|
||||||
|
isDirectionalType,
|
||||||
|
filterAdvancedZones,
|
||||||
|
zonesAtPoint,
|
||||||
|
zoneDirectionDeg,
|
||||||
|
isHeadingAllowed,
|
||||||
|
getDirectionalConstraint,
|
||||||
|
getPlannerSettings,
|
||||||
|
getIoActivation,
|
||||||
|
classifyPoint,
|
||||||
|
};
|
||||||
|
})();
|
||||||
89
www/map-behavior-zones.js
Normal file
89
www/map-behavior-zones.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
(() => {
|
||||||
|
/**
|
||||||
|
* Runtime hooks for Speed / Sound map zones (MiR §4.2.6.8–9).
|
||||||
|
* Vector overlay only — consumed by motion controller / mission runner.
|
||||||
|
*/
|
||||||
|
const TYPES = {
|
||||||
|
speed: "speed",
|
||||||
|
sound: "sound",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BEHAVIOR_TYPES = new Set([TYPES.speed, TYPES.sound]);
|
||||||
|
|
||||||
|
const SPEED_MIN = 0.1;
|
||||||
|
const SPEED_MAX = 1.5;
|
||||||
|
const DEFAULT_SPEED_MPS = 0.8;
|
||||||
|
|
||||||
|
function pointInPolygon(px, py, points) {
|
||||||
|
return window.MapPlannerZones?.pointInPolygon(px, py, points) || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampSpeed(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return DEFAULT_SPEED_MPS;
|
||||||
|
return Math.min(SPEED_MAX, Math.max(SPEED_MIN, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBehaviorZoneType(type) {
|
||||||
|
return BEHAVIOR_TYPES.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterBehaviorZones(zones) {
|
||||||
|
return (Array.isArray(zones) ? zones : []).filter((z) => isBehaviorZoneType(z?.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Topmost behavior zones containing image pixel (newest wins for overlaps). */
|
||||||
|
function zonesAtPoint(zones, px, py) {
|
||||||
|
const list = Array.isArray(zones) ? zones : [];
|
||||||
|
const hits = [];
|
||||||
|
for (let i = list.length - 1; i >= 0; i--) {
|
||||||
|
const z = list[i];
|
||||||
|
if (!isBehaviorZoneType(z?.type) || !z.points?.length) continue;
|
||||||
|
if (pointInPolygon(px, py, z.points)) hits.push(z);
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpeedLimit(zones, px, py) {
|
||||||
|
const hit = zonesAtPoint(zones, px, py).find((z) => z.type === TYPES.speed);
|
||||||
|
if (!hit) return null;
|
||||||
|
return clampSpeed(hit.speed_mps);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSoundAtPoint(zones, px, py) {
|
||||||
|
const hit = zonesAtPoint(zones, px, py).find((z) => z.type === TYPES.sound);
|
||||||
|
if (!hit?.sound_id) return null;
|
||||||
|
return {
|
||||||
|
sound_id: hit.sound_id,
|
||||||
|
zone_id: hit.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyPoint(zones, px, py) {
|
||||||
|
const hits = zonesAtPoint(zones, px, py);
|
||||||
|
const speedZone = hits.find((z) => z.type === TYPES.speed);
|
||||||
|
const soundZone = hits.find((z) => z.type === TYPES.sound);
|
||||||
|
return {
|
||||||
|
speed_mps: speedZone ? clampSpeed(speedZone.speed_mps) : null,
|
||||||
|
sound_id: soundZone?.sound_id || null,
|
||||||
|
speed_zone: speedZone || null,
|
||||||
|
sound_zone: soundZone || null,
|
||||||
|
zones: hits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.MapBehaviorZones = {
|
||||||
|
TYPES,
|
||||||
|
BEHAVIOR_TYPES,
|
||||||
|
SPEED_MIN,
|
||||||
|
SPEED_MAX,
|
||||||
|
DEFAULT_SPEED_MPS,
|
||||||
|
clampSpeed,
|
||||||
|
isBehaviorZoneType,
|
||||||
|
filterBehaviorZones,
|
||||||
|
zonesAtPoint,
|
||||||
|
getSpeedLimit,
|
||||||
|
getSoundAtPoint,
|
||||||
|
classifyPoint,
|
||||||
|
};
|
||||||
|
})();
|
||||||
1434
www/map-editor.js
1434
www/map-editor.js
File diff suppressed because it is too large
Load Diff
698
www/map-objects.js
Normal file
698
www/map-objects.js
Normal file
@@ -0,0 +1,698 @@
|
|||||||
|
(() => {
|
||||||
|
const TYPES = {
|
||||||
|
wall: "wall",
|
||||||
|
floor: "floor",
|
||||||
|
position: "position",
|
||||||
|
forbidden: "forbidden",
|
||||||
|
preferred: "preferred",
|
||||||
|
unpreferred: "unpreferred",
|
||||||
|
speed: "speed",
|
||||||
|
sound: "sound",
|
||||||
|
directional: "directional",
|
||||||
|
directional_line: "directional_line",
|
||||||
|
planner: "planner",
|
||||||
|
io: "io",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FLOOR_PLAN_TYPES = new Set([TYPES.wall, TYPES.floor]);
|
||||||
|
const POLYGON_TYPES = new Set([
|
||||||
|
TYPES.floor,
|
||||||
|
TYPES.forbidden,
|
||||||
|
TYPES.preferred,
|
||||||
|
TYPES.unpreferred,
|
||||||
|
TYPES.speed,
|
||||||
|
TYPES.sound,
|
||||||
|
TYPES.directional,
|
||||||
|
TYPES.planner,
|
||||||
|
TYPES.io,
|
||||||
|
]);
|
||||||
|
const POINT_SHAPE_TYPES = new Set([TYPES.wall, TYPES.directional_line, ...POLYGON_TYPES]);
|
||||||
|
|
||||||
|
function newId() {
|
||||||
|
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePoint(p) {
|
||||||
|
const x = Number(p?.x);
|
||||||
|
const y = Number(p?.y);
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function minPoints(type) {
|
||||||
|
if (type === TYPES.wall || type === TYPES.directional_line) return 2;
|
||||||
|
if (POLYGON_TYPES.has(type)) return 3;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFloorPlanType(type) {
|
||||||
|
return FLOOR_PLAN_TYPES.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlannerZoneType(type) {
|
||||||
|
return window.MapPlannerZones?.isPlannerZoneType(type) || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBehaviorZoneType(type) {
|
||||||
|
return window.MapBehaviorZones?.isBehaviorZoneType(type) || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdvancedZoneType(type) {
|
||||||
|
return window.MapAdvancedZones?.isAdvancedZoneType(type) || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectionalLineType(type) {
|
||||||
|
return type === TYPES.directional_line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampSpeedMps(value) {
|
||||||
|
return window.MapBehaviorZones?.clampSpeed(value) ?? 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPolygonType(type) {
|
||||||
|
return POLYGON_TYPES.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPolylineType(type) {
|
||||||
|
return type === TYPES.wall || type === TYPES.directional_line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPointShapeType(type) {
|
||||||
|
return POINT_SHAPE_TYPES.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverlayObjectType(type) {
|
||||||
|
return (
|
||||||
|
type === TYPES.position ||
|
||||||
|
isPlannerZoneType(type) ||
|
||||||
|
isBehaviorZoneType(type) ||
|
||||||
|
isAdvancedZoneType(type)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPolygon(z) {
|
||||||
|
if (!z || !isPolygonType(z.type)) return false;
|
||||||
|
const points = Array.isArray(z.points) ? z.points.map(normalizePoint).filter(Boolean) : [];
|
||||||
|
return points.length >= minPoints(z.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidWall(z) {
|
||||||
|
if (!z || z.type !== TYPES.wall) return false;
|
||||||
|
const points = Array.isArray(z.points) ? z.points.map(normalizePoint).filter(Boolean) : [];
|
||||||
|
return points.length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPosition(z) {
|
||||||
|
return (
|
||||||
|
z &&
|
||||||
|
z.type === TYPES.position &&
|
||||||
|
Number.isFinite(Number(z.x)) &&
|
||||||
|
Number.isFinite(Number(z.y)) &&
|
||||||
|
Number.isFinite(Number(z.yaw))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidSpeedZone(z) {
|
||||||
|
if (!isValidPolygon(z) || z.type !== TYPES.speed) return false;
|
||||||
|
return Number.isFinite(Number(z.speed_mps));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidSoundZone(z) {
|
||||||
|
return isValidPolygon(z) && z.type === TYPES.sound && typeof z.sound_id === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidDirectionalShape(z) {
|
||||||
|
return isValidPolygon(z) && z.type === TYPES.directional && Number.isFinite(Number(z.direction_deg));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidDirectionalLine(z) {
|
||||||
|
if (!z || z.type !== TYPES.directional_line) return false;
|
||||||
|
const points = Array.isArray(z.points) ? z.points.map(normalizePoint).filter(Boolean) : [];
|
||||||
|
return points.length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPlannerZone(z) {
|
||||||
|
return isValidPolygon(z) && z.type === TYPES.planner;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidIoZone(z) {
|
||||||
|
return isValidPolygon(z) && z.type === TYPES.io && typeof z.io_module === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidZone(z) {
|
||||||
|
if (z?.type === TYPES.position) return isValidPosition(z);
|
||||||
|
if (z?.type === TYPES.wall) return isValidWall(z);
|
||||||
|
if (z?.type === TYPES.speed) return isValidSpeedZone(z);
|
||||||
|
if (z?.type === TYPES.sound) return isValidSoundZone(z);
|
||||||
|
if (z?.type === TYPES.directional) return isValidDirectionalShape(z);
|
||||||
|
if (z?.type === TYPES.directional_line) return isValidDirectionalLine(z);
|
||||||
|
if (z?.type === TYPES.planner) return isValidPlannerZone(z);
|
||||||
|
if (z?.type === TYPES.io) return isValidIoZone(z);
|
||||||
|
if (isPolygonType(z?.type)) return isValidPolygon(z);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse all map objects from API / database payload. */
|
||||||
|
function parseZones(raw) {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw
|
||||||
|
.map((z) => {
|
||||||
|
if (!z) return null;
|
||||||
|
if (z.type === TYPES.position) {
|
||||||
|
return {
|
||||||
|
id: typeof z.id === "string" && z.id ? z.id : newId(),
|
||||||
|
type: TYPES.position,
|
||||||
|
name: typeof z.name === "string" ? z.name : "",
|
||||||
|
x: Number(z.x),
|
||||||
|
y: Number(z.y),
|
||||||
|
yaw: Number(z.yaw),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isPointShapeType(z.type)) {
|
||||||
|
const base = {
|
||||||
|
id: typeof z.id === "string" && z.id ? z.id : newId(),
|
||||||
|
type: z.type,
|
||||||
|
points: (Array.isArray(z.points) ? z.points : []).map(normalizePoint).filter(Boolean),
|
||||||
|
};
|
||||||
|
if (z.type === TYPES.speed) {
|
||||||
|
base.speed_mps = clampSpeedMps(z.speed_mps);
|
||||||
|
}
|
||||||
|
if (z.type === TYPES.sound) {
|
||||||
|
base.sound_id = typeof z.sound_id === "string" ? z.sound_id : "";
|
||||||
|
}
|
||||||
|
if (z.type === TYPES.directional) {
|
||||||
|
base.direction_deg = window.MapAdvancedZones?.normalizeDirectionDeg(z.direction_deg) ?? 0;
|
||||||
|
}
|
||||||
|
if (z.type === TYPES.directional_line) {
|
||||||
|
base.reversed = !!z.reversed;
|
||||||
|
base.line_width = Number.isFinite(Number(z.line_width)) ? Number(z.line_width) : 8;
|
||||||
|
}
|
||||||
|
if (z.type === TYPES.planner) {
|
||||||
|
Object.assign(
|
||||||
|
base,
|
||||||
|
window.MapAdvancedZones?.normalizePlannerSettings(z) || {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (z.type === TYPES.io) {
|
||||||
|
const io = window.MapAdvancedZones?.normalizeIoSettings(z) || {};
|
||||||
|
base.io_module = io.io_module || "";
|
||||||
|
base.plc_register = io.plc_register;
|
||||||
|
base.plc_value = io.plc_value;
|
||||||
|
base.plc_mode = io.plc_mode;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(isValidZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createZone(type, points, extra = {}) {
|
||||||
|
const pts = points.map(normalizePoint).filter(Boolean);
|
||||||
|
if (pts.length < minPoints(type)) return null;
|
||||||
|
const zone = { id: newId(), type, points: pts };
|
||||||
|
if (type === TYPES.speed) zone.speed_mps = clampSpeedMps(extra.speed_mps);
|
||||||
|
if (type === TYPES.sound) zone.sound_id = typeof extra.sound_id === "string" ? extra.sound_id : "";
|
||||||
|
if (type === TYPES.directional) {
|
||||||
|
zone.direction_deg = window.MapAdvancedZones?.normalizeDirectionDeg(extra.direction_deg) ?? 0;
|
||||||
|
}
|
||||||
|
if (type === TYPES.directional_line) {
|
||||||
|
zone.reversed = !!extra.reversed;
|
||||||
|
zone.line_width = Number.isFinite(Number(extra.line_width)) ? Number(extra.line_width) : 8;
|
||||||
|
}
|
||||||
|
if (type === TYPES.planner) {
|
||||||
|
Object.assign(zone, window.MapAdvancedZones?.normalizePlannerSettings(extra) || {});
|
||||||
|
}
|
||||||
|
if (type === TYPES.io) {
|
||||||
|
const io = window.MapAdvancedZones?.normalizeIoSettings(extra) || {};
|
||||||
|
zone.io_module = io.io_module || "";
|
||||||
|
zone.plc_register = io.plc_register;
|
||||||
|
zone.plc_value = io.plc_value;
|
||||||
|
zone.plc_mode = io.plc_mode;
|
||||||
|
}
|
||||||
|
return zone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPosition(worldX, worldY, yaw, name = "") {
|
||||||
|
if (!Number.isFinite(worldX) || !Number.isFinite(worldY) || !Number.isFinite(yaw)) return null;
|
||||||
|
return {
|
||||||
|
id: newId(),
|
||||||
|
type: TYPES.position,
|
||||||
|
name: name || "",
|
||||||
|
x: worldX,
|
||||||
|
y: worldY,
|
||||||
|
yaw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function distPointToSegment(px, py, x1, y1, x2, y2) {
|
||||||
|
const dx = x2 - x1;
|
||||||
|
const dy = y2 - y1;
|
||||||
|
const lenSq = dx * dx + dy * dy;
|
||||||
|
if (lenSq === 0) return Math.hypot(px - x1, py - y1);
|
||||||
|
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointInPolygon(px, py, points) {
|
||||||
|
return window.MapPlannerZones?.pointInPolygon(px, py, points) || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dist(a, b) {
|
||||||
|
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionPixel(z, mapMeta, imgW, imgH) {
|
||||||
|
const geo = window.MapGeo;
|
||||||
|
if (!geo || !z) return null;
|
||||||
|
return geo.worldToPixel(mapMeta, imgW, imgH, z.x, z.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitTestPlannerZone(zones, px, py) {
|
||||||
|
for (let i = zones.length - 1; i >= 0; i--) {
|
||||||
|
const z = zones[i];
|
||||||
|
if (isPlannerZoneType(z.type) && pointInPolygon(px, py, z.points)) return z;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitTestBehaviorZone(zones, px, py) {
|
||||||
|
for (let i = zones.length - 1; i >= 0; i--) {
|
||||||
|
const z = zones[i];
|
||||||
|
if (isBehaviorZoneType(z.type) && pointInPolygon(px, py, z.points)) return z;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitTestAdvancedZone(zones, px, py, tolerance = 8) {
|
||||||
|
for (let i = zones.length - 1; i >= 0; i--) {
|
||||||
|
const z = zones[i];
|
||||||
|
if (!isAdvancedZoneType(z.type)) continue;
|
||||||
|
if (z.type === TYPES.directional_line) {
|
||||||
|
for (let j = 0; j < z.points.length - 1; j++) {
|
||||||
|
const p1 = z.points[j];
|
||||||
|
const p2 = z.points[j + 1];
|
||||||
|
const width = Number(z.line_width) || 8;
|
||||||
|
if (distPointToSegment(px, py, p1.x, p1.y, p2.x, p2.y) <= width / 2 + tolerance) return z;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (z.points?.length && pointInPolygon(px, py, z.points)) return z;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find topmost floor-plan shape at image pixel. */
|
||||||
|
function hitTest(zones, px, py, tolerance = 8) {
|
||||||
|
for (let i = zones.length - 1; i >= 0; i--) {
|
||||||
|
const z = zones[i];
|
||||||
|
if (z.type === TYPES.floor && pointInPolygon(px, py, z.points)) return z;
|
||||||
|
if (z.type === TYPES.wall) {
|
||||||
|
for (let j = 0; j < z.points.length - 1; j++) {
|
||||||
|
const p1 = z.points[j];
|
||||||
|
const p2 = z.points[j + 1];
|
||||||
|
if (distPointToSegment(px, py, p1.x, p1.y, p2.x, p2.y) <= tolerance) return z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (z.type === TYPES.directional_line) {
|
||||||
|
for (let j = 0; j < z.points.length - 1; j++) {
|
||||||
|
const p1 = z.points[j];
|
||||||
|
const p2 = z.points[j + 1];
|
||||||
|
const width = Number(z.line_width) || 8;
|
||||||
|
if (distPointToSegment(px, py, p1.x, p1.y, p2.x, p2.y) <= width / 2 + tolerance) return z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitTestPosition(zones, px, py, mapMeta, imgW, imgH, tolerance = 14) {
|
||||||
|
for (let i = zones.length - 1; i >= 0; i--) {
|
||||||
|
const z = zones[i];
|
||||||
|
if (z.type !== TYPES.position) continue;
|
||||||
|
const pt = positionPixel(z, mapMeta, imgW, imgH);
|
||||||
|
if (pt && dist({ x: px, y: py }, pt) <= tolerance) return z;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitTestAny(zones, px, py, mapMeta, imgW, imgH, tolerance = 8) {
|
||||||
|
const pos = hitTestPosition(zones, px, py, mapMeta, imgW, imgH, tolerance + 4);
|
||||||
|
if (pos) return pos;
|
||||||
|
const behavior = hitTestBehaviorZone(zones, px, py);
|
||||||
|
if (behavior) return behavior;
|
||||||
|
const advanced = hitTestAdvancedZone(zones, px, py, tolerance);
|
||||||
|
if (advanced) return advanced;
|
||||||
|
const planner = hitTestPlannerZone(zones, px, py);
|
||||||
|
if (planner) return planner;
|
||||||
|
return hitTest(zones, px, py, tolerance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hit vertex handle on selected or any point-based shape. */
|
||||||
|
function hitTestVertex(zones, px, py, selectedId, tolerance = 10) {
|
||||||
|
const tryZone = (z) => {
|
||||||
|
if (!isPointShapeType(z.type)) return null;
|
||||||
|
for (let i = 0; i < z.points.length; i++) {
|
||||||
|
if (dist({ x: px, y: py }, z.points[i]) <= tolerance) {
|
||||||
|
return { zoneId: z.id, pointIndex: i };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
if (selectedId) {
|
||||||
|
const sel = zones.find((z) => z.id === selectedId);
|
||||||
|
const hit = sel ? tryZone(sel) : null;
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
for (let i = zones.length - 1; i >= 0; i--) {
|
||||||
|
const hit = tryZone(zones[i]);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointsAttr(points) {
|
||||||
|
return points.map((p) => `${p.x},${p.y}`).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSvg(svgEl) {
|
||||||
|
while (svgEl.firstChild) svgEl.removeChild(svgEl.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPolyline(parent, points, className) {
|
||||||
|
if (points.length < 2) return null;
|
||||||
|
const el = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
|
||||||
|
el.setAttribute("class", className);
|
||||||
|
el.setAttribute("points", pointsAttr(points));
|
||||||
|
el.setAttribute("fill", "none");
|
||||||
|
parent.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPolygon(parent, points, className) {
|
||||||
|
if (points.length < 3) return null;
|
||||||
|
const el = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
|
||||||
|
el.setAttribute("class", className);
|
||||||
|
el.setAttribute("points", pointsAttr(points));
|
||||||
|
parent.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLine(parent, p1, p2, className) {
|
||||||
|
const el = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
||||||
|
el.setAttribute("class", className);
|
||||||
|
el.setAttribute("x1", String(p1.x));
|
||||||
|
el.setAttribute("y1", String(p1.y));
|
||||||
|
el.setAttribute("x2", String(p2.x));
|
||||||
|
el.setAttribute("y2", String(p2.y));
|
||||||
|
parent.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendVertex(parent, p, className, r = 5, dataAttrs = {}) {
|
||||||
|
const el = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||||
|
el.setAttribute("class", className);
|
||||||
|
el.setAttribute("cx", String(p.x));
|
||||||
|
el.setAttribute("cy", String(p.y));
|
||||||
|
el.setAttribute("r", String(r));
|
||||||
|
Object.entries(dataAttrs).forEach(([k, v]) => el.setAttribute(k, v));
|
||||||
|
parent.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPositionMarker(parent, px, py, yawDeg, selected) {
|
||||||
|
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||||
|
g.setAttribute("class", selected ? "mapObjPosition mapObjPosition--selected" : "mapObjPosition");
|
||||||
|
g.setAttribute("transform", `translate(${px},${py}) rotate(${yawDeg})`);
|
||||||
|
|
||||||
|
const shaft = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
||||||
|
shaft.setAttribute("class", "mapObjPositionShaft");
|
||||||
|
shaft.setAttribute("x1", "0");
|
||||||
|
shaft.setAttribute("y1", "0");
|
||||||
|
shaft.setAttribute("x2", "22");
|
||||||
|
shaft.setAttribute("y2", "0");
|
||||||
|
g.appendChild(shaft);
|
||||||
|
|
||||||
|
const head = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
|
||||||
|
head.setAttribute("class", "mapObjPositionHead");
|
||||||
|
head.setAttribute("points", "22,0 14,-5 14,5");
|
||||||
|
g.appendChild(head);
|
||||||
|
|
||||||
|
const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||||
|
dot.setAttribute("class", "mapObjPositionDot");
|
||||||
|
dot.setAttribute("cx", "0");
|
||||||
|
dot.setAttribute("cy", "0");
|
||||||
|
dot.setAttribute("r", "4");
|
||||||
|
g.appendChild(dot);
|
||||||
|
|
||||||
|
parent.appendChild(g);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSelectionRect(parent, rect) {
|
||||||
|
if (!rect) return null;
|
||||||
|
const x = Math.min(rect.x0, rect.x1);
|
||||||
|
const y = Math.min(rect.y0, rect.y1);
|
||||||
|
const w = Math.abs(rect.x1 - rect.x0);
|
||||||
|
const h = Math.abs(rect.y1 - rect.y0);
|
||||||
|
if (w < 1 && h < 1) return null;
|
||||||
|
const el = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||||
|
el.setAttribute("class", "mapObjSelectionRect");
|
||||||
|
el.setAttribute("x", String(x));
|
||||||
|
el.setAttribute("y", String(y));
|
||||||
|
el.setAttribute("width", String(w));
|
||||||
|
el.setAttribute("height", String(h));
|
||||||
|
parent.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonCentroid(points) {
|
||||||
|
if (!points?.length) return null;
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
for (const p of points) {
|
||||||
|
x += p.x;
|
||||||
|
y += p.y;
|
||||||
|
}
|
||||||
|
return { x: x / points.length, y: y / points.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineMidpoint(points) {
|
||||||
|
if (!points?.length) return null;
|
||||||
|
if (points.length === 1) return { ...points[0] };
|
||||||
|
const p0 = points[0];
|
||||||
|
const p1 = points[points.length - 1];
|
||||||
|
return { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendDirectionArrow(parent, cx, cy, directionDeg, selected = false) {
|
||||||
|
const adv = window.MapAdvancedZones;
|
||||||
|
const deg = adv?.normalizeDirectionDeg(directionDeg) ?? 0;
|
||||||
|
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||||
|
g.setAttribute(
|
||||||
|
"class",
|
||||||
|
selected ? "mapObjDirectionArrow mapObjDirectionArrow--selected" : "mapObjDirectionArrow",
|
||||||
|
);
|
||||||
|
g.setAttribute("transform", `translate(${cx},${cy}) rotate(${-deg})`);
|
||||||
|
|
||||||
|
const shaft = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
||||||
|
shaft.setAttribute("class", "mapObjDirectionShaft");
|
||||||
|
shaft.setAttribute("x1", "-14");
|
||||||
|
shaft.setAttribute("y1", "0");
|
||||||
|
shaft.setAttribute("x2", "14");
|
||||||
|
shaft.setAttribute("y2", "0");
|
||||||
|
g.appendChild(shaft);
|
||||||
|
|
||||||
|
const head = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
|
||||||
|
head.setAttribute("class", "mapObjDirectionHead");
|
||||||
|
head.setAttribute("points", "14,0 6,-5 6,5");
|
||||||
|
g.appendChild(head);
|
||||||
|
|
||||||
|
parent.appendChild(g);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPolylineStyled(parent, points, className, strokeWidth = null) {
|
||||||
|
const el = appendPolyline(parent, points, className);
|
||||||
|
if (el && strokeWidth != null) el.setAttribute("stroke-width", String(strokeWidth));
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonClass(type, selected, draft = false) {
|
||||||
|
const base = {
|
||||||
|
[TYPES.floor]: "mapObjFloor",
|
||||||
|
[TYPES.forbidden]: "mapObjForbidden",
|
||||||
|
[TYPES.preferred]: "mapObjPreferred",
|
||||||
|
[TYPES.unpreferred]: "mapObjUnpreferred",
|
||||||
|
[TYPES.speed]: "mapObjSpeed",
|
||||||
|
[TYPES.sound]: "mapObjSound",
|
||||||
|
[TYPES.directional]: "mapObjDirectional",
|
||||||
|
[TYPES.planner]: "mapObjPlannerSettings",
|
||||||
|
[TYPES.io]: "mapObjIo",
|
||||||
|
}[type];
|
||||||
|
if (!base) return "";
|
||||||
|
if (draft) return `${base} ${base}--draft`;
|
||||||
|
if (selected) return `${base} ${base}--selected`;
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterVisible(zones, visibility) {
|
||||||
|
const vis = visibility || {};
|
||||||
|
return zones.filter((z) => {
|
||||||
|
if (z.type === TYPES.wall) return vis.walls !== false;
|
||||||
|
if (z.type === TYPES.floor) return vis.floors !== false;
|
||||||
|
if (z.type === TYPES.position) return vis.positions !== false;
|
||||||
|
if (z.type === TYPES.forbidden) return vis.forbidden !== false;
|
||||||
|
if (z.type === TYPES.preferred) return vis.preferred !== false;
|
||||||
|
if (z.type === TYPES.unpreferred) return vis.unpreferred !== false;
|
||||||
|
if (z.type === TYPES.speed) return vis.speed !== false;
|
||||||
|
if (z.type === TYPES.sound) return vis.sound !== false;
|
||||||
|
if (z.type === TYPES.directional || z.type === TYPES.directional_line) {
|
||||||
|
return vis.directional !== false;
|
||||||
|
}
|
||||||
|
if (z.type === TYPES.planner) return vis.planner !== false;
|
||||||
|
if (z.type === TYPES.io) return vis.io !== false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render map objects on SVG overlay.
|
||||||
|
* @param {SVGSVGElement} svgEl
|
||||||
|
* @param {object[]} zones
|
||||||
|
* @param {object} opts
|
||||||
|
*/
|
||||||
|
function render(svgEl, zones, opts = {}) {
|
||||||
|
if (!svgEl) return;
|
||||||
|
clearSvg(svgEl);
|
||||||
|
|
||||||
|
const mapMeta = opts.mapMeta || {};
|
||||||
|
const imgW = opts.imageWidth || 0;
|
||||||
|
const imgH = opts.imageHeight || 0;
|
||||||
|
const list = filterVisible(zones, opts.visibility);
|
||||||
|
|
||||||
|
list.forEach((z) => {
|
||||||
|
const selected = z.id === opts.selectedId;
|
||||||
|
if (z.type === TYPES.wall) {
|
||||||
|
appendPolyline(svgEl, z.points, selected ? "mapObjWall mapObjWall--selected" : "mapObjWall");
|
||||||
|
} else if (z.type === TYPES.directional_line) {
|
||||||
|
const cls = selected
|
||||||
|
? "mapObjDirectionalLine mapObjDirectionalLine--selected"
|
||||||
|
: "mapObjDirectionalLine";
|
||||||
|
appendPolylineStyled(svgEl, z.points, cls, z.line_width || 8);
|
||||||
|
const mid = lineMidpoint(z.points);
|
||||||
|
if (mid) {
|
||||||
|
appendDirectionArrow(
|
||||||
|
svgEl,
|
||||||
|
mid.x,
|
||||||
|
mid.y,
|
||||||
|
window.MapAdvancedZones?.zoneDirectionDeg(z) ?? 0,
|
||||||
|
selected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (isPolygonType(z.type)) {
|
||||||
|
appendPolygon(svgEl, z.points, polygonClass(z.type, selected));
|
||||||
|
if (z.type === TYPES.directional) {
|
||||||
|
const c = polygonCentroid(z.points);
|
||||||
|
if (c) appendDirectionArrow(svgEl, c.x, c.y, z.direction_deg ?? 0, selected);
|
||||||
|
}
|
||||||
|
} else if (z.type === TYPES.position) {
|
||||||
|
const pt = positionPixel(z, mapMeta, imgW, imgH);
|
||||||
|
if (pt) {
|
||||||
|
const yawDeg = (-Number(z.yaw) * 180) / Math.PI;
|
||||||
|
appendPositionMarker(svgEl, pt.x, pt.y, yawDeg, selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (opts.selectedId && opts.showVertices !== false) {
|
||||||
|
const sel = list.find((z) => z.id === opts.selectedId);
|
||||||
|
if (sel && isPointShapeType(sel.type)) {
|
||||||
|
sel.points.forEach((p, i) => {
|
||||||
|
appendVertex(svgEl, p, "mapObjVertex mapObjVertex--handle", 6, {
|
||||||
|
"data-vertex-index": String(i),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = opts.draft;
|
||||||
|
if (draft?.kind === "shape" && draft.type && Array.isArray(draft.points) && draft.points.length) {
|
||||||
|
const pts = draft.points;
|
||||||
|
const hover = draft.hover;
|
||||||
|
if (draft.type === TYPES.wall || draft.type === TYPES.directional_line) {
|
||||||
|
const draftCls =
|
||||||
|
draft.type === TYPES.directional_line
|
||||||
|
? "mapObjDirectionalLine mapObjDirectionalLine--draft"
|
||||||
|
: "mapObjWall mapObjWall--draft";
|
||||||
|
appendPolylineStyled(svgEl, pts, draftCls, draft.type === TYPES.directional_line ? 8 : null);
|
||||||
|
if (hover && pts.length) appendLine(svgEl, pts[pts.length - 1], hover, "mapObjDraftLine");
|
||||||
|
} else if (isPolygonType(draft.type)) {
|
||||||
|
if (pts.length >= 3) appendPolygon(svgEl, pts, polygonClass(draft.type, false, true));
|
||||||
|
else if (pts.length === 2) appendPolyline(svgEl, pts, "mapObjDraftLine");
|
||||||
|
if (hover && pts.length) appendLine(svgEl, pts[pts.length - 1], hover, "mapObjDraftLine");
|
||||||
|
appendVertex(svgEl, pts[0], "mapObjCloseHint", 6);
|
||||||
|
}
|
||||||
|
pts.forEach((p) => appendVertex(svgEl, p, "mapObjVertex"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft?.kind === "position" && draft.px != null && draft.py != null) {
|
||||||
|
const yawDeg = (-Number(draft.yaw || 0) * 180) / Math.PI;
|
||||||
|
appendPositionMarker(svgEl, draft.px, draft.py, yawDeg, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendSelectionRect(svgEl, opts.selectionRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
function constrainAxis(from, to) {
|
||||||
|
const dx = Math.abs(to.x - from.x);
|
||||||
|
const dy = Math.abs(to.y - from.y);
|
||||||
|
if (dx >= dy) return { x: to.x, y: from.y };
|
||||||
|
return { x: from.x, y: to.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearPoint(a, b, tolerance = 12) {
|
||||||
|
return dist(a, b) <= tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yawFromPoints(origin, target) {
|
||||||
|
return Math.atan2(-(target.y - origin.y), target.x - origin.x);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.MapObjects = {
|
||||||
|
TYPES,
|
||||||
|
FLOOR_PLAN_TYPES,
|
||||||
|
POLYGON_TYPES,
|
||||||
|
POINT_SHAPE_TYPES,
|
||||||
|
newId,
|
||||||
|
parseZones,
|
||||||
|
createZone,
|
||||||
|
createPosition,
|
||||||
|
minPoints,
|
||||||
|
isFloorPlanType,
|
||||||
|
isPlannerZoneType,
|
||||||
|
isBehaviorZoneType,
|
||||||
|
isAdvancedZoneType,
|
||||||
|
isDirectionalLineType,
|
||||||
|
clampSpeedMps,
|
||||||
|
isPolygonType,
|
||||||
|
isPolylineType,
|
||||||
|
isPointShapeType,
|
||||||
|
isOverlayObjectType,
|
||||||
|
isValidZone,
|
||||||
|
hitTest,
|
||||||
|
hitTestPlannerZone,
|
||||||
|
hitTestBehaviorZone,
|
||||||
|
hitTestAdvancedZone,
|
||||||
|
hitTestPosition,
|
||||||
|
hitTestAny,
|
||||||
|
hitTestVertex,
|
||||||
|
positionPixel,
|
||||||
|
render,
|
||||||
|
filterVisible,
|
||||||
|
constrainAxis,
|
||||||
|
nearPoint,
|
||||||
|
yawFromPoints,
|
||||||
|
};
|
||||||
|
})();
|
||||||
234
www/map-occupancy-edit.js
Normal file
234
www/map-occupancy-edit.js
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
(() => {
|
||||||
|
/**
|
||||||
|
* map_server grayscale editing — bake walls/floors and brush-erase on floor-plan raster.
|
||||||
|
* Values match ROS map_server convention (see map-occupancy-canvas.js).
|
||||||
|
*/
|
||||||
|
const GRAY = {
|
||||||
|
free: 254,
|
||||||
|
occupied: 0,
|
||||||
|
unknown: 205,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_LINE_WIDTH = 3;
|
||||||
|
const DEFAULT_BRUSH_RADIUS = 10;
|
||||||
|
|
||||||
|
function ensureCanvas(canvas, width, height) {
|
||||||
|
if (!canvas) return null;
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
return canvas.getContext("2d");
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyCanvas(src, dst) {
|
||||||
|
if (!src?.width || !dst) return false;
|
||||||
|
const ctx = ensureCanvas(dst, src.width, src.height);
|
||||||
|
if (!ctx) return false;
|
||||||
|
ctx.drawImage(src, 0, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite = base scan + floor polygons (free) + wall polylines (occupied).
|
||||||
|
* @param {HTMLCanvasElement} baseCanvas
|
||||||
|
* @param {HTMLCanvasElement} outCanvas
|
||||||
|
* @param {object[]} zones
|
||||||
|
*/
|
||||||
|
function rebakeComposite(baseCanvas, outCanvas, zones) {
|
||||||
|
if (!baseCanvas?.width || !outCanvas) return false;
|
||||||
|
copyCanvas(baseCanvas, outCanvas);
|
||||||
|
const list = Array.isArray(zones) ? zones : [];
|
||||||
|
list.filter((z) => z?.type === "floor").forEach((z) => bakeFloor(outCanvas, z.points));
|
||||||
|
list.filter((z) => z?.type === "wall").forEach((z) => bakeWall(outCanvas, z.points));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load PNG pixels into editable grayscale source canvas. */
|
||||||
|
function initSourceFromImage(sourceCanvas, imageEl) {
|
||||||
|
if (!sourceCanvas || !imageEl?.naturalWidth) return false;
|
||||||
|
const w = imageEl.naturalWidth;
|
||||||
|
const h = imageEl.naturalHeight;
|
||||||
|
const ctx = ensureCanvas(sourceCanvas, w, h);
|
||||||
|
if (!ctx) return false;
|
||||||
|
ctx.drawImage(imageEl, 0, 0, w, h);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneRaster(sourceCanvas) {
|
||||||
|
if (!sourceCanvas?.width) return null;
|
||||||
|
const ctx = sourceCanvas.getContext("2d");
|
||||||
|
if (!ctx) return null;
|
||||||
|
return ctx.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreRaster(sourceCanvas, imageData) {
|
||||||
|
if (!sourceCanvas || !imageData) return false;
|
||||||
|
const ctx = ensureCanvas(sourceCanvas, imageData.width, imageData.height);
|
||||||
|
if (!ctx) return false;
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function grayRgb(gray) {
|
||||||
|
const g = Math.max(0, Math.min(255, gray | 0));
|
||||||
|
return `rgb(${g},${g},${g})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint display canvas from grayscale source using map_server thresholds. */
|
||||||
|
function renderDisplayFromSource(displayCanvas, sourceCanvas, meta, occModule) {
|
||||||
|
const occ = occModule || window.MapOccupancyCanvas;
|
||||||
|
if (!occ?.renderFromImage || !displayCanvas || !sourceCanvas?.width) return false;
|
||||||
|
return occ.renderFromImage(displayCanvas, sourceCanvas, meta || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function strokePolyline(ctx, points, gray, lineWidth = DEFAULT_LINE_WIDTH) {
|
||||||
|
if (!ctx || !points || points.length < 2) return;
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = grayRgb(gray);
|
||||||
|
ctx.lineWidth = lineWidth;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.beginPath();
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
if (i === 0) ctx.moveTo(p.x, p.y);
|
||||||
|
else ctx.lineTo(p.x, p.y);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillPolygon(ctx, points, gray) {
|
||||||
|
if (!ctx || !points || points.length < 3) return;
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = grayRgb(gray);
|
||||||
|
ctx.beginPath();
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
if (i === 0) ctx.moveTo(p.x, p.y);
|
||||||
|
else ctx.lineTo(p.x, p.y);
|
||||||
|
});
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bake wall polyline as occupied pixels. */
|
||||||
|
function bakeWall(sourceCanvas, points, lineWidth = DEFAULT_LINE_WIDTH) {
|
||||||
|
const ctx = sourceCanvas?.getContext("2d");
|
||||||
|
if (!ctx) return false;
|
||||||
|
strokePolyline(ctx, points, GRAY.occupied, lineWidth);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bake floor polygon as free walkable pixels. */
|
||||||
|
function bakeFloor(sourceCanvas, points) {
|
||||||
|
const ctx = sourceCanvas?.getContext("2d");
|
||||||
|
if (!ctx) return false;
|
||||||
|
fillPolygon(ctx, points, GRAY.free);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove baked wall by painting free along its path. */
|
||||||
|
function unbakeWall(sourceCanvas, points, lineWidth = DEFAULT_LINE_WIDTH + 2) {
|
||||||
|
const ctx = sourceCanvas?.getContext("2d");
|
||||||
|
if (!ctx) return false;
|
||||||
|
strokePolyline(ctx, points, GRAY.free, lineWidth);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bakeZone(sourceCanvas, zone) {
|
||||||
|
if (!zone?.points?.length) return false;
|
||||||
|
if (zone.type === "wall") return bakeWall(sourceCanvas, zone.points);
|
||||||
|
if (zone.type === "floor") return bakeFloor(sourceCanvas, zone.points);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbakeZone(sourceCanvas, zone) {
|
||||||
|
if (!zone?.points?.length) return false;
|
||||||
|
if (zone.type === "wall") return unbakeWall(sourceCanvas, zone.points);
|
||||||
|
if (zone.type === "floor") {
|
||||||
|
const ctx = sourceCanvas?.getContext("2d");
|
||||||
|
if (!ctx) return false;
|
||||||
|
fillPolygon(ctx, zone.points, GRAY.unknown);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintBrush(sourceCanvas, x, y, layer, radius = DEFAULT_BRUSH_RADIUS) {
|
||||||
|
const ctx = sourceCanvas?.getContext("2d");
|
||||||
|
if (!ctx) return false;
|
||||||
|
const gray = layer === "floor" ? GRAY.unknown : GRAY.free;
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = grayRgb(gray);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erase pixels inside axis-aligned rectangle on base layer. */
|
||||||
|
function eraseRect(sourceCanvas, x0, y0, x1, y1, layer) {
|
||||||
|
const ctx = sourceCanvas?.getContext("2d");
|
||||||
|
if (!ctx) return false;
|
||||||
|
const gray = layer === "floor" ? GRAY.unknown : GRAY.free;
|
||||||
|
const x = Math.min(x0, x1);
|
||||||
|
const y = Math.min(y0, y1);
|
||||||
|
const w = Math.abs(x1 - x0);
|
||||||
|
const h = Math.abs(y1 - y0);
|
||||||
|
if (w < 1 || h < 1) return false;
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = grayRgb(gray);
|
||||||
|
ctx.fillRect(x, y, w, h);
|
||||||
|
ctx.restore();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interpolated brush stroke between two image-space points. */
|
||||||
|
function paintBrushStroke(sourceCanvas, x0, y0, x1, y1, layer, radius = DEFAULT_BRUSH_RADIUS) {
|
||||||
|
const dist = Math.hypot(x1 - x0, y1 - y0);
|
||||||
|
const step = Math.max(1, radius * 0.5);
|
||||||
|
const n = Math.max(1, Math.ceil(dist / step));
|
||||||
|
for (let i = 0; i <= n; i++) {
|
||||||
|
const t = i / n;
|
||||||
|
paintBrush(sourceCanvas, x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, layer, radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPngBlob(sourceCanvas) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!sourceCanvas?.width) {
|
||||||
|
reject(new Error("no source canvas"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceCanvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (blob) resolve(blob);
|
||||||
|
else reject(new Error("export failed"));
|
||||||
|
},
|
||||||
|
"image/png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.MapOccupancyEdit = {
|
||||||
|
GRAY,
|
||||||
|
DEFAULT_LINE_WIDTH,
|
||||||
|
DEFAULT_BRUSH_RADIUS,
|
||||||
|
initSourceFromImage,
|
||||||
|
cloneRaster,
|
||||||
|
restoreRaster,
|
||||||
|
copyCanvas,
|
||||||
|
rebakeComposite,
|
||||||
|
renderDisplayFromSource,
|
||||||
|
bakeWall,
|
||||||
|
bakeFloor,
|
||||||
|
bakeZone,
|
||||||
|
unbakeZone,
|
||||||
|
paintBrush,
|
||||||
|
paintBrushStroke,
|
||||||
|
eraseRect,
|
||||||
|
exportPngBlob,
|
||||||
|
};
|
||||||
|
})();
|
||||||
104
www/map-planner-zones.js
Normal file
104
www/map-planner-zones.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
(() => {
|
||||||
|
/**
|
||||||
|
* Planner hooks for Forbidden / Preferred / Unpreferred drive zones (MiR §4.2.6.6).
|
||||||
|
* Vector overlay only — consumed by future global planner / path preview.
|
||||||
|
*/
|
||||||
|
const TYPES = {
|
||||||
|
forbidden: "forbidden",
|
||||||
|
preferred: "preferred",
|
||||||
|
unpreferred: "unpreferred",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLANNER_TYPES = new Set([TYPES.forbidden, TYPES.preferred, TYPES.unpreferred]);
|
||||||
|
|
||||||
|
/** Relative traversal cost (forbidden = impassable). */
|
||||||
|
const COST = {
|
||||||
|
forbidden: Infinity,
|
||||||
|
unpreferred: 4,
|
||||||
|
preferred: 0.35,
|
||||||
|
neutral: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function pointInPolygon(px, py, points) {
|
||||||
|
if (!points?.length) return false;
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||||
|
const xi = points[i].x;
|
||||||
|
const yi = points[i].y;
|
||||||
|
const xj = points[j].x;
|
||||||
|
const yj = points[j].y;
|
||||||
|
const intersect = yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi;
|
||||||
|
if (intersect) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlannerZoneType(type) {
|
||||||
|
return PLANNER_TYPES.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPlannerZones(zones) {
|
||||||
|
return (Array.isArray(zones) ? zones : []).filter((z) => isPlannerZoneType(z?.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Topmost planner zones containing image pixel (newest wins for overlaps). */
|
||||||
|
function zonesAtPoint(zones, px, py) {
|
||||||
|
const list = Array.isArray(zones) ? zones : [];
|
||||||
|
const hits = [];
|
||||||
|
for (let i = list.length - 1; i >= 0; i--) {
|
||||||
|
const z = list[i];
|
||||||
|
if (!isPlannerZoneType(z?.type) || !z.points?.length) continue;
|
||||||
|
if (pointInPolygon(px, py, z.points)) hits.push(z);
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pixelCost(zones, px, py) {
|
||||||
|
const hits = zonesAtPoint(zones, px, py);
|
||||||
|
if (hits.some((z) => z.type === TYPES.forbidden)) return COST.forbidden;
|
||||||
|
if (hits.some((z) => z.type === TYPES.unpreferred)) return COST.unpreferred;
|
||||||
|
if (hits.some((z) => z.type === TYPES.preferred)) return COST.preferred;
|
||||||
|
return COST.neutral;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyPoint(zones, px, py) {
|
||||||
|
const hits = zonesAtPoint(zones, px, py);
|
||||||
|
return {
|
||||||
|
forbidden: hits.some((z) => z.type === TYPES.forbidden),
|
||||||
|
preferred: hits.some((z) => z.type === TYPES.preferred),
|
||||||
|
unpreferred: hits.some((z) => z.type === TYPES.unpreferred),
|
||||||
|
zones: hits,
|
||||||
|
cost: pixelCost(zones, px, py),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathBlocked(zones, pathPoints) {
|
||||||
|
const pts = Array.isArray(pathPoints) ? pathPoints : [];
|
||||||
|
return pts.some((p) => pixelCost(zones, p.x, p.y) === COST.forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathCost(zones, pathPoints) {
|
||||||
|
const pts = Array.isArray(pathPoints) ? pathPoints : [];
|
||||||
|
let sum = 0;
|
||||||
|
for (const p of pts) {
|
||||||
|
const c = pixelCost(zones, p.x, p.y);
|
||||||
|
if (!Number.isFinite(c)) return Infinity;
|
||||||
|
sum += c;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.MapPlannerZones = {
|
||||||
|
TYPES,
|
||||||
|
PLANNER_TYPES,
|
||||||
|
COST,
|
||||||
|
isPlannerZoneType,
|
||||||
|
filterPlannerZones,
|
||||||
|
zonesAtPoint,
|
||||||
|
pixelCost,
|
||||||
|
classifyPoint,
|
||||||
|
isPathBlocked,
|
||||||
|
pathCost,
|
||||||
|
pointInPolygon,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
items: [
|
items: [
|
||||||
{ section: "missions", page: "missions" },
|
{ section: "missions", page: "missions" },
|
||||||
{ section: "maps", page: "maps" },
|
{ section: "maps", page: "maps" },
|
||||||
|
{ section: "sounds", page: "sounds" },
|
||||||
{ section: "build-robot", page: "config" },
|
{ section: "build-robot", page: "config" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
config: { module: "setup", section: "build-robot" },
|
config: { module: "setup", section: "build-robot" },
|
||||||
maps: { module: "setup", section: "maps" },
|
maps: { module: "setup", section: "maps" },
|
||||||
missions: { module: "setup", section: "missions" },
|
missions: { module: "setup", section: "missions" },
|
||||||
|
sounds: { module: "setup", section: "sounds" },
|
||||||
integrations: { module: "system", section: "integrations" },
|
integrations: { module: "system", section: "integrations" },
|
||||||
monitoring: { module: "monitoring", section: "monitoring-log" },
|
monitoring: { module: "monitoring", section: "monitoring-log" },
|
||||||
help: { module: "help", section: "help-api" },
|
help: { module: "help", section: "help-api" },
|
||||||
|
|||||||
280
www/sounds.js
Normal file
280
www/sounds.js
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
(() => {
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||||
|
|
||||||
|
const listEl = el("soundList");
|
||||||
|
const emptyEl = el("soundListEmpty");
|
||||||
|
const createBtnEl = el("soundCreateBtn");
|
||||||
|
const dialogEl = el("soundEditDialog");
|
||||||
|
const formEl = el("soundEditForm");
|
||||||
|
const titleEl = el("soundEditTitle");
|
||||||
|
const nameEl = el("soundEditName");
|
||||||
|
const descEl = el("soundEditDescription");
|
||||||
|
const enabledEl = el("soundEditEnabled");
|
||||||
|
const fileMetaEl = el("soundEditFileMeta");
|
||||||
|
const uploadInputEl = el("soundEditUploadInput");
|
||||||
|
const uploadBtnEl = el("soundEditUploadBtn");
|
||||||
|
const playBtnEl = el("soundEditPlayBtn");
|
||||||
|
const deleteBtnEl = el("soundEditDeleteBtn");
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
sounds: [],
|
||||||
|
editingId: null,
|
||||||
|
previewAudio: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function canWrite() {
|
||||||
|
if (!window.AuthApp?.canWrite) return true;
|
||||||
|
return window.AuthApp.canWrite("integrations");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiJson(url, opts = {}) {
|
||||||
|
if (window.AuthApp && !window.AuthApp.isReady()) {
|
||||||
|
throw new Error("not authenticated");
|
||||||
|
}
|
||||||
|
const res = await fetch(url, { credentials: "include", ...opts });
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = (data && data.error) || text || res.statusText;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSounds() {
|
||||||
|
const data = await apiJson("/api/sounds");
|
||||||
|
store.sounds = Array.isArray(data.sounds) ? data.sounds : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms) {
|
||||||
|
if (!Number.isFinite(Number(ms))) return "—";
|
||||||
|
const sec = Math.round(Number(ms) / 100) / 10;
|
||||||
|
return `${sec}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
if (!listEl) return;
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
if (emptyEl) emptyEl.hidden = store.sounds.length > 0;
|
||||||
|
|
||||||
|
store.sounds.forEach((sound) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "missionListItem soundListItem";
|
||||||
|
const hasFile = !!sound.file_name;
|
||||||
|
row.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<div class="missionListItemTitle">${escapeHtml(sound.name || sound.id)}</div>
|
||||||
|
<div class="missionListItemMeta">
|
||||||
|
${sound.enabled === false ? t("common.disabled") : t("common.enabled")}
|
||||||
|
· ${hasFile ? escapeHtml(sound.file_name) : t("sounds.noFile")}
|
||||||
|
${sound.duration_ms != null ? ` · ${formatDuration(sound.duration_ms)}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="missionListItemActions">
|
||||||
|
<button type="button" class="btn subtle soundPlayBtn" data-id="${escapeHtml(sound.id)}" ${hasFile ? "" : "disabled"}>${t("sounds.play")}</button>
|
||||||
|
<button type="button" class="btn subtle soundEditBtn" data-id="${escapeHtml(sound.id)}">${t("common.edit")}</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
listEl.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
listEl.querySelectorAll(".soundEditBtn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => openDialog(btn.dataset.id));
|
||||||
|
});
|
||||||
|
listEl.querySelectorAll(".soundPlayBtn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => playSound(btn.dataset.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPreview() {
|
||||||
|
if (store.previewAudio) {
|
||||||
|
store.previewAudio.pause();
|
||||||
|
store.previewAudio = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playSound(id) {
|
||||||
|
stopPreview();
|
||||||
|
const audio = new Audio(`/api/sounds/${encodeURIComponent(id)}/file`);
|
||||||
|
store.previewAudio = audio;
|
||||||
|
audio.play().catch(() => alert(t("sounds.playFailed")));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFileMeta(sound) {
|
||||||
|
if (!fileMetaEl) return;
|
||||||
|
if (sound?.file_name) {
|
||||||
|
fileMetaEl.textContent = t("sounds.fileMeta", {
|
||||||
|
name: sound.file_name,
|
||||||
|
duration: formatDuration(sound.duration_ms),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fileMetaEl.textContent = t("sounds.noFile");
|
||||||
|
}
|
||||||
|
if (playBtnEl) playBtnEl.disabled = !sound?.file_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialog(id = null) {
|
||||||
|
store.editingId = id;
|
||||||
|
const existing = id ? store.sounds.find((s) => s.id === id) : null;
|
||||||
|
if (titleEl) {
|
||||||
|
titleEl.textContent = existing ? t("sounds.editTitle") : t("sounds.createTitle");
|
||||||
|
}
|
||||||
|
if (nameEl) nameEl.value = existing?.name || "";
|
||||||
|
if (descEl) descEl.value = existing?.description || "";
|
||||||
|
if (enabledEl) enabledEl.checked = existing?.enabled !== false;
|
||||||
|
updateFileMeta(existing);
|
||||||
|
if (deleteBtnEl) deleteBtnEl.hidden = !existing || !canWrite();
|
||||||
|
if (uploadBtnEl) uploadBtnEl.disabled = !canWrite();
|
||||||
|
if (nameEl) nameEl.readOnly = !canWrite();
|
||||||
|
if (descEl) descEl.readOnly = !canWrite();
|
||||||
|
if (enabledEl) enabledEl.disabled = !canWrite();
|
||||||
|
dialogEl?.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDialog() {
|
||||||
|
if (!canWrite()) return;
|
||||||
|
const name = nameEl?.value.trim() || "";
|
||||||
|
if (!name) {
|
||||||
|
alert(t("sounds.nameRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
description: descEl?.value.trim() || "",
|
||||||
|
enabled: enabledEl?.checked !== false,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (store.editingId) {
|
||||||
|
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const created = await apiJson("/api/sounds", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
store.editingId = created.id;
|
||||||
|
}
|
||||||
|
await refreshSounds();
|
||||||
|
renderList();
|
||||||
|
const updated = store.sounds.find((s) => s.id === store.editingId);
|
||||||
|
updateFileMeta(updated);
|
||||||
|
if (!uploadInputEl?.files?.length) {
|
||||||
|
dialogEl?.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile() {
|
||||||
|
if (!canWrite() || !store.editingId) return;
|
||||||
|
const file = uploadInputEl?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
try {
|
||||||
|
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}/file`, {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
uploadInputEl.value = "";
|
||||||
|
await refreshSounds();
|
||||||
|
renderList();
|
||||||
|
updateFileMeta(store.sounds.find((s) => s.id === store.editingId));
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSound() {
|
||||||
|
if (!canWrite() || !store.editingId) return;
|
||||||
|
if (!confirm(t("sounds.deleteConfirm"))) return;
|
||||||
|
try {
|
||||||
|
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}`, { method: "DELETE" });
|
||||||
|
dialogEl?.close();
|
||||||
|
store.editingId = null;
|
||||||
|
await refreshSounds();
|
||||||
|
renderList();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
createBtnEl?.addEventListener("click", () => {
|
||||||
|
if (!canWrite()) return;
|
||||||
|
openDialog(null);
|
||||||
|
});
|
||||||
|
formEl?.addEventListener("submit", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
saveDialog();
|
||||||
|
});
|
||||||
|
el("soundEditCancelBtn")?.addEventListener("click", () => {
|
||||||
|
stopPreview();
|
||||||
|
dialogEl?.close();
|
||||||
|
});
|
||||||
|
dialogEl?.addEventListener("cancel", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
stopPreview();
|
||||||
|
dialogEl?.close();
|
||||||
|
});
|
||||||
|
uploadBtnEl?.addEventListener("click", () => uploadInputEl?.click());
|
||||||
|
uploadInputEl?.addEventListener("change", () => {
|
||||||
|
saveDialog().then(() => uploadFile());
|
||||||
|
});
|
||||||
|
playBtnEl?.addEventListener("click", () => {
|
||||||
|
if (store.editingId) playSound(store.editingId);
|
||||||
|
});
|
||||||
|
deleteBtnEl?.addEventListener("click", () => deleteSound());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPageShow() {
|
||||||
|
stopPreview();
|
||||||
|
if (createBtnEl) createBtnEl.hidden = !canWrite();
|
||||||
|
try {
|
||||||
|
await refreshSounds();
|
||||||
|
renderList();
|
||||||
|
} catch (e) {
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
emptyEl.textContent = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageHide() {
|
||||||
|
stopPreview();
|
||||||
|
dialogEl?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSounds() {
|
||||||
|
return JSON.parse(JSON.stringify(store.sounds));
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
|
|
||||||
|
window.SoundsApp = {
|
||||||
|
onPageShow,
|
||||||
|
onPageHide,
|
||||||
|
getSounds,
|
||||||
|
refreshSounds,
|
||||||
|
};
|
||||||
|
})();
|
||||||
524
www/style.css
524
www/style.css
@@ -3642,6 +3642,21 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
|||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapsMirDialogFooter--inline {
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapsMirField--checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapsMirField--checkbox input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.mapsMirMenuList {
|
.mapsMirMenuList {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -3795,6 +3810,12 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypePicker.is-open {
|
||||||
|
z-index: 210;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapEditorMappingBarSpacer {
|
.mapEditorMappingBarSpacer {
|
||||||
@@ -3839,20 +3860,161 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
|||||||
background: rgba(255, 255, 255, 0.5);
|
background: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapEditorObjectSelect {
|
.mapEditorObjectTypePicker {
|
||||||
|
position: relative;
|
||||||
flex: 1 1 220px;
|
flex: 1 1 220px;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
height: 40px;
|
|
||||||
padding: 0 28px 0 12px;
|
|
||||||
border: none;
|
|
||||||
border-right: 1px solid #c4c4c4;
|
border-right: 1px solid #c4c4c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 28px 0 10px;
|
||||||
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
font-size: 13px;
|
background: #fff;
|
||||||
color: #555;
|
color: #555;
|
||||||
background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23666' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 10px center;
|
font-size: 13px;
|
||||||
appearance: none;
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeBtn:hover:not(:disabled) {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeBtn:disabled {
|
||||||
|
color: #b0b0b0;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeBtn[aria-expanded="true"] {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeIcon,
|
||||||
|
.mapEditorObjectTypeOptionIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeIcon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOptionIcon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOptionIcon--wall {
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOptionIcon--floor {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOptionIcon--position {
|
||||||
|
color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOptionIcon--forbidden {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOptionIcon--preferred {
|
||||||
|
color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOptionIcon--unpreferred {
|
||||||
|
color: #8e44ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOptionIcon--speed {
|
||||||
|
color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOptionIcon--sound {
|
||||||
|
color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOptionIcon--directional {
|
||||||
|
color: #16a085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOptionIcon--directionalLine {
|
||||||
|
color: #1abc9c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOptionIcon--planner {
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOptionIcon--io {
|
||||||
|
color: #566573;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeLabel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeChevron {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #666;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeMenu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 200;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
list-style: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #c4c4c4;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeMenu[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOption {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #444;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOption:hover,
|
||||||
|
.mapEditorObjectTypeOption.is-selected {
|
||||||
|
background: rgba(92, 184, 92, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectTypeOption.is-selected {
|
||||||
|
color: var(--mir-green, #5cb85c);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapEditorCanvasWrap {
|
.mapEditorCanvasWrap {
|
||||||
@@ -4088,6 +4250,354 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjWall {
|
||||||
|
stroke: #1a1a1a;
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjWall--selected {
|
||||||
|
stroke: #e67e22;
|
||||||
|
stroke-width: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjWall--draft {
|
||||||
|
stroke: #3498db;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjFloor {
|
||||||
|
fill: rgba(255, 255, 255, 0.82);
|
||||||
|
stroke: #888;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjFloor--selected {
|
||||||
|
fill: rgba(230, 126, 34, 0.25);
|
||||||
|
stroke: #e67e22;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjFloor--draft {
|
||||||
|
fill: rgba(52, 152, 219, 0.2);
|
||||||
|
stroke: #3498db;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjDraftLine {
|
||||||
|
stroke: #3498db;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 4 3;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjVertex {
|
||||||
|
fill: #3498db;
|
||||||
|
stroke: #fff;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjCloseHint {
|
||||||
|
fill: none;
|
||||||
|
stroke: #27ae60;
|
||||||
|
stroke-width: 2;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjForbidden {
|
||||||
|
fill: rgba(231, 76, 60, 0.28);
|
||||||
|
stroke: #c0392b;
|
||||||
|
stroke-width: 2;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjForbidden--selected {
|
||||||
|
fill: rgba(231, 76, 60, 0.42);
|
||||||
|
stroke: #e74c3c;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjForbidden--draft {
|
||||||
|
fill: rgba(231, 76, 60, 0.18);
|
||||||
|
stroke: #e74c3c;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjPreferred {
|
||||||
|
fill: rgba(92, 184, 92, 0.28);
|
||||||
|
stroke: #27ae60;
|
||||||
|
stroke-width: 2;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjPreferred--selected {
|
||||||
|
fill: rgba(92, 184, 92, 0.42);
|
||||||
|
stroke: #2ecc71;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjPreferred--draft {
|
||||||
|
fill: rgba(92, 184, 92, 0.18);
|
||||||
|
stroke: #2ecc71;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjUnpreferred {
|
||||||
|
fill: rgba(155, 89, 182, 0.24);
|
||||||
|
stroke: #8e44ad;
|
||||||
|
stroke-width: 2;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjUnpreferred--selected {
|
||||||
|
fill: rgba(155, 89, 182, 0.38);
|
||||||
|
stroke: #9b59b6;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjUnpreferred--draft {
|
||||||
|
fill: rgba(155, 89, 182, 0.16);
|
||||||
|
stroke: #9b59b6;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjSpeed {
|
||||||
|
fill: rgba(243, 156, 18, 0.28);
|
||||||
|
stroke: #e67e22;
|
||||||
|
stroke-width: 2;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjSpeed--selected {
|
||||||
|
fill: rgba(243, 156, 18, 0.42);
|
||||||
|
stroke: #f39c12;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjSpeed--draft {
|
||||||
|
fill: rgba(243, 156, 18, 0.18);
|
||||||
|
stroke: #f39c12;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjSound {
|
||||||
|
fill: rgba(52, 152, 219, 0.24);
|
||||||
|
stroke: #2980b9;
|
||||||
|
stroke-width: 2;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjSound--selected {
|
||||||
|
fill: rgba(52, 152, 219, 0.38);
|
||||||
|
stroke: #3498db;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjSound--draft {
|
||||||
|
fill: rgba(52, 152, 219, 0.16);
|
||||||
|
stroke: #3498db;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjDirectional {
|
||||||
|
fill: rgba(26, 188, 156, 0.22);
|
||||||
|
stroke: #16a085;
|
||||||
|
stroke-width: 2;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjDirectional--selected {
|
||||||
|
fill: rgba(26, 188, 156, 0.36);
|
||||||
|
stroke: #1abc9c;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjDirectional--draft {
|
||||||
|
fill: rgba(26, 188, 156, 0.14);
|
||||||
|
stroke: #1abc9c;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjDirectionalLine {
|
||||||
|
fill: none;
|
||||||
|
stroke: #16a085;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjDirectionalLine--selected {
|
||||||
|
stroke: #1abc9c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjDirectionalLine--draft {
|
||||||
|
stroke: #48c9b0;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjDirectionArrow {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjDirectionShaft {
|
||||||
|
stroke: #117a65;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjDirectionHead {
|
||||||
|
fill: #117a65;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjDirectionArrow--selected .mapObjDirectionShaft {
|
||||||
|
stroke: #0e6655;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjDirectionArrow--selected .mapObjDirectionHead {
|
||||||
|
fill: #0e6655;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjPlannerSettings {
|
||||||
|
fill: rgba(127, 140, 141, 0.22);
|
||||||
|
stroke: #7f8c8d;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 5 3;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjPlannerSettings--selected {
|
||||||
|
fill: rgba(127, 140, 141, 0.34);
|
||||||
|
stroke: #95a5a6;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjPlannerSettings--draft {
|
||||||
|
fill: rgba(127, 140, 141, 0.14);
|
||||||
|
stroke: #95a5a6;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjIo {
|
||||||
|
fill: rgba(86, 101, 115, 0.24);
|
||||||
|
stroke: #566573;
|
||||||
|
stroke-width: 2;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjIo--selected {
|
||||||
|
fill: rgba(86, 101, 115, 0.38);
|
||||||
|
stroke: #5d6d7e;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjIo--draft {
|
||||||
|
fill: rgba(86, 101, 115, 0.16);
|
||||||
|
stroke: #5d6d7e;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjVertex--handle {
|
||||||
|
fill: #e67e22;
|
||||||
|
stroke: #fff;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjSelectionRect {
|
||||||
|
fill: rgba(231, 76, 60, 0.12);
|
||||||
|
stroke: #e74c3c;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-dasharray: 5 3;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjPositionShaft {
|
||||||
|
stroke: #2980b9;
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjPositionHead {
|
||||||
|
fill: #2980b9;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjPositionDot {
|
||||||
|
fill: #fff;
|
||||||
|
stroke: #2980b9;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjPosition--selected .mapObjPositionShaft {
|
||||||
|
stroke: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjPosition--selected .mapObjPositionHead {
|
||||||
|
fill: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorObjectsSvg .mapObjPosition--selected .mapObjPositionDot {
|
||||||
|
stroke: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorCanvasWrap.is-erase-selection-tool {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorCanvasWrap.is-erase-selection-tool .mapEditorViewport {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorCanvasWrap.is-draw-tool {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorCanvasWrap.is-draw-tool .mapEditorViewport {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorCanvasWrap.is-eraser-tool {
|
||||||
|
cursor: cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorCanvasWrap.is-eraser-tool .mapEditorViewport {
|
||||||
|
cursor: cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorSourceCanvas {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorBaseCanvas {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.mapEditorStatusBar {
|
.mapEditorStatusBar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in New Issue
Block a user