update full objects type
Some checks are pending
Test / test (push) Waiting to run

This commit is contained in:
2026-06-20 11:43:48 +02:00
parent 90e8e9d252
commit 365a15c32a
16 changed files with 4253 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.11.5 m/s).",
"maps.editor.speed.invalid": "Nhập tốc độ hợp lệ (0.11.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.11.5 m/s).",
"maps.editor.speed.invalid": "Enter a valid speed (0.11.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",

View File

@@ -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.11.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
View 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
View File

@@ -0,0 +1,89 @@
(() => {
/**
* Runtime hooks for Speed / Sound map zones (MiR §4.2.6.89).
* 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,
};
})();

File diff suppressed because it is too large Load Diff

698
www/map-objects.js Normal file
View 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
View 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
View 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,
};
})();

View File

@@ -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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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,
};
})();

View File

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