This commit is contained in:
@@ -49,6 +49,9 @@ add_executable(lidar_manager_web
|
|||||||
src/io/io_module_service.cpp
|
src/io/io_module_service.cpp
|
||||||
src/io/io_zone_runtime.cpp
|
src/io/io_zone_runtime.cpp
|
||||||
src/io/io_module_usage.cpp
|
src/io/io_module_usage.cpp
|
||||||
|
src/storage/path_store.cpp
|
||||||
|
src/path/path_planner.cpp
|
||||||
|
src/path/path_service.cpp
|
||||||
src/storage/dashboard_store.cpp
|
src/storage/dashboard_store.cpp
|
||||||
src/storage/state_repository.cpp
|
src/storage/state_repository.cpp
|
||||||
src/validation/sensor_validator.cpp
|
src/validation/sensor_validator.cpp
|
||||||
@@ -66,6 +69,7 @@ add_executable(lidar_manager_web
|
|||||||
src/server/api_media_routes.cpp
|
src/server/api_media_routes.cpp
|
||||||
src/server/api_transition_routes.cpp
|
src/server/api_transition_routes.cpp
|
||||||
src/server/api_io_module_routes.cpp
|
src/server/api_io_module_routes.cpp
|
||||||
|
src/server/api_path_routes.cpp
|
||||||
src/server/api_dashboard_routes.cpp
|
src/server/api_dashboard_routes.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
#include "auth/auth_service.hpp"
|
#include "auth/auth_service.hpp"
|
||||||
#include "io/io_module_service.hpp"
|
#include "io/io_module_service.hpp"
|
||||||
#include "io/io_zone_runtime.hpp"
|
#include "io/io_zone_runtime.hpp"
|
||||||
|
#include "path/path_service.hpp"
|
||||||
|
#include "storage/path_store.hpp"
|
||||||
#include "mission/mission_enqueue.hpp"
|
#include "mission/mission_enqueue.hpp"
|
||||||
#include "mission/mission_queue.hpp"
|
#include "mission/mission_queue.hpp"
|
||||||
#include "mission/mission_scheduler.hpp"
|
#include "mission/mission_scheduler.hpp"
|
||||||
@@ -70,8 +72,11 @@ int LidarManagerApp::run()
|
|||||||
IoModuleStore io_module_store(database);
|
IoModuleStore io_module_store(database);
|
||||||
IoModuleService io_module_service(io_module_store);
|
IoModuleService io_module_service(io_module_store);
|
||||||
IoZoneRuntime io_zone_runtime;
|
IoZoneRuntime io_zone_runtime;
|
||||||
|
PathStore path_store(database);
|
||||||
|
PathService path_service(path_store, map_store);
|
||||||
MissionStore mission_store(database);
|
MissionStore mission_store(database);
|
||||||
MissionQueue mission_queue(database, map_store, transition_store, mission_store, io_module_service, io_zone_runtime);
|
MissionQueue mission_queue(database, map_store, transition_store, mission_store, io_module_service, io_zone_runtime,
|
||||||
|
path_service);
|
||||||
RobotRuntime robot_runtime(database, mission_queue);
|
RobotRuntime robot_runtime(database, mission_queue);
|
||||||
SiteStore site_store(database);
|
SiteStore site_store(database);
|
||||||
site_store.ensureDefaultSiteId();
|
site_store.ensureDefaultSiteId();
|
||||||
@@ -112,6 +117,8 @@ int LidarManagerApp::run()
|
|||||||
transition_store,
|
transition_store,
|
||||||
io_module_store,
|
io_module_store,
|
||||||
io_module_service,
|
io_module_service,
|
||||||
|
path_store,
|
||||||
|
path_service,
|
||||||
dashboard_store);
|
dashboard_store);
|
||||||
api.registerRoutes(svr);
|
api.registerRoutes(svr);
|
||||||
auth.registerRoutes(svr);
|
auth.registerRoutes(svr);
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
|
|||||||
if (path == "/api/robot/active_map")
|
if (path == "/api/robot/active_map")
|
||||||
return "maps";
|
return "maps";
|
||||||
if (path.rfind("/api/maps", 0) == 0 || path.rfind("/api/sites", 0) == 0 ||
|
if (path.rfind("/api/maps", 0) == 0 || path.rfind("/api/sites", 0) == 0 ||
|
||||||
|
path.rfind("/api/paths", 0) == 0 || path.rfind("/api/transitions", 0) == 0 ||
|
||||||
path.rfind("/api/recordings", 0) == 0)
|
path.rfind("/api/recordings", 0) == 0)
|
||||||
return "maps";
|
return "maps";
|
||||||
if (path.rfind("/api/layout", 0) == 0 || path.rfind("/api/lidars", 0) == 0 ||
|
if (path.rfind("/api/layout", 0) == 0 || path.rfind("/api/lidars", 0) == 0 ||
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "io/io_module_service.hpp"
|
#include "io/io_module_service.hpp"
|
||||||
#include "io/io_zone_runtime.hpp"
|
#include "io/io_zone_runtime.hpp"
|
||||||
|
#include "path/path_service.hpp"
|
||||||
#include "mission/mission_store.hpp"
|
#include "mission/mission_store.hpp"
|
||||||
#include "mission/position_resolver.hpp"
|
#include "mission/position_resolver.hpp"
|
||||||
#include "storage/database.hpp"
|
#include "storage/database.hpp"
|
||||||
@@ -93,13 +94,15 @@ MissionQueue::MissionQueue(Database& db,
|
|||||||
TransitionStore& transitions,
|
TransitionStore& transitions,
|
||||||
MissionStore& missions,
|
MissionStore& missions,
|
||||||
IoModuleService& io_modules,
|
IoModuleService& io_modules,
|
||||||
IoZoneRuntime& io_zones)
|
IoZoneRuntime& io_zones,
|
||||||
|
PathService& paths)
|
||||||
: db_(db),
|
: db_(db),
|
||||||
maps_(maps),
|
maps_(maps),
|
||||||
transitions_(transitions),
|
transitions_(transitions),
|
||||||
missions_(missions),
|
missions_(missions),
|
||||||
io_modules_(io_modules),
|
io_modules_(io_modules),
|
||||||
io_zones_(io_zones),
|
io_zones_(io_zones),
|
||||||
|
paths_(paths),
|
||||||
position_resolver_(maps)
|
position_resolver_(maps)
|
||||||
{
|
{
|
||||||
load();
|
load();
|
||||||
@@ -153,6 +156,8 @@ void MissionQueue::ensureRunnerDefaults()
|
|||||||
runner_["current_action"] = nullptr;
|
runner_["current_action"] = nullptr;
|
||||||
if (!runner_.contains("paused"))
|
if (!runner_.contains("paused"))
|
||||||
runner_["paused"] = false;
|
runner_["paused"] = false;
|
||||||
|
if (!runner_.contains("last_position_id"))
|
||||||
|
runner_["last_position_id"] = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
void MissionQueue::startWorkerIfNeeded()
|
void MissionQueue::startWorkerIfNeeded()
|
||||||
@@ -602,6 +607,57 @@ void MissionQueue::runAutoTransitionIfNeeded(const std::string& from_map_id,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MissionQueue::completeMoveToPosition(const ResolvedPosition& resolved,
|
||||||
|
const std::string& label,
|
||||||
|
nlohmann::json& log)
|
||||||
|
{
|
||||||
|
std::string from_id;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
|
if (runner_.contains("last_position_id") && runner_["last_position_id"].is_string())
|
||||||
|
from_id = runner_["last_position_id"].get<std::string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto map = maps_.find(resolved.map_id);
|
||||||
|
const std::string site_id = map ? map->value("site_id", "") : "";
|
||||||
|
if (!from_id.empty() && from_id != resolved.position_id)
|
||||||
|
{
|
||||||
|
std::string path_err;
|
||||||
|
const nlohmann::json path =
|
||||||
|
paths_.getOrCreatePath(resolved.map_id, site_id, from_id, resolved.position_id, path_err);
|
||||||
|
const int pt_count =
|
||||||
|
path.contains("points") && path["points"].is_array() ? static_cast<int>(path["points"].size()) : 0;
|
||||||
|
if (!path_err.empty())
|
||||||
|
{
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
|
{"level", "warn"},
|
||||||
|
{"message", "Path planning: " + path_err}});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
|
{"level", "info"},
|
||||||
|
{"message", "Path " + from_id + " → " + resolved.position_id + " (" + std::to_string(pt_count) +
|
||||||
|
" pts)"}});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
|
{"level", "info"},
|
||||||
|
{"message", label + " → " + resolved.label}});
|
||||||
|
sleepMs(1200);
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
|
io_zones_.onRobotAtPosition(db_, maps_, io_modules_, resolved.map_id, resolved.position_id, log);
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
|
runner_["last_position_id"] = resolved.position_id;
|
||||||
|
runner_["last_map_id"] = resolved.map_id;
|
||||||
|
saveUnlocked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
||||||
const nlohmann::json& parameters,
|
const nlohmann::json& parameters,
|
||||||
nlohmann::json& log,
|
nlohmann::json& log,
|
||||||
@@ -725,30 +781,7 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j
|
|||||||
if (cancel_)
|
if (cancel_)
|
||||||
throw MissionCancelled();
|
throw MissionCancelled();
|
||||||
}
|
}
|
||||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
completeMoveToPosition(*resolved, label, log);
|
||||||
{"level", "info"},
|
|
||||||
{"message", label + " → " + resolved->label}});
|
|
||||||
sleepMs(1200);
|
|
||||||
if (cancel_)
|
|
||||||
throw MissionCancelled();
|
|
||||||
io_zones_.onRobotAtPosition(db_, maps_, io_modules_, resolved->map_id, resolved->position_id, log);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allow_auto_transition && type == "move_to_position" && !pos_ref.empty())
|
|
||||||
{
|
|
||||||
const std::string active_map = getActiveMapIdFromDb(db_);
|
|
||||||
const auto resolved = position_resolver_.resolve(pos_ref, active_map);
|
|
||||||
if (resolved)
|
|
||||||
{
|
|
||||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
|
||||||
{"level", "info"},
|
|
||||||
{"message", label + " → " + resolved->label}});
|
|
||||||
sleepMs(1200);
|
|
||||||
if (cancel_)
|
|
||||||
throw MissionCancelled();
|
|
||||||
io_zones_.onRobotAtPosition(db_, maps_, io_modules_, resolved->map_id, resolved->position_id, log);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class IoModuleService;
|
|||||||
class IoZoneRuntime;
|
class IoZoneRuntime;
|
||||||
class MapStore;
|
class MapStore;
|
||||||
class MissionStore;
|
class MissionStore;
|
||||||
|
class PathService;
|
||||||
class TransitionStore;
|
class TransitionStore;
|
||||||
|
|
||||||
class MissionQueue
|
class MissionQueue
|
||||||
@@ -27,7 +28,8 @@ public:
|
|||||||
TransitionStore& transitions,
|
TransitionStore& transitions,
|
||||||
MissionStore& missions,
|
MissionStore& missions,
|
||||||
IoModuleService& io_modules,
|
IoModuleService& io_modules,
|
||||||
IoZoneRuntime& io_zones);
|
IoZoneRuntime& io_zones,
|
||||||
|
PathService& paths);
|
||||||
~MissionQueue();
|
~MissionQueue();
|
||||||
|
|
||||||
MissionQueue(const MissionQueue&) = delete;
|
MissionQueue(const MissionQueue&) = delete;
|
||||||
@@ -53,6 +55,7 @@ private:
|
|||||||
MissionStore& missions_;
|
MissionStore& missions_;
|
||||||
IoModuleService& io_modules_;
|
IoModuleService& io_modules_;
|
||||||
IoZoneRuntime& io_zones_;
|
IoZoneRuntime& io_zones_;
|
||||||
|
PathService& paths_;
|
||||||
PositionResolver position_resolver_;
|
PositionResolver position_resolver_;
|
||||||
mutable std::recursive_mutex mu_;
|
mutable std::recursive_mutex mu_;
|
||||||
nlohmann::json queue_;
|
nlohmann::json queue_;
|
||||||
@@ -82,6 +85,7 @@ private:
|
|||||||
int loop_depth);
|
int loop_depth);
|
||||||
void sleepMs(int ms);
|
void sleepMs(int ms);
|
||||||
void setRunnerState(const std::string& state, const std::string& message = "");
|
void setRunnerState(const std::string& state, const std::string& message = "");
|
||||||
|
void completeMoveToPosition(const ResolvedPosition& resolved, const std::string& label, nlohmann::json& log);
|
||||||
void insertByPriorityUnlocked(nlohmann::json& entry);
|
void insertByPriorityUnlocked(nlohmann::json& entry);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
287
src/path/path_planner.cpp
Normal file
287
src/path/path_planner.cpp
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
#include "path/path_planner.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <limits>
|
||||||
|
#include <queue>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr double kInf = std::numeric_limits<double>::infinity();
|
||||||
|
|
||||||
|
double mapResolution(const nlohmann::json& map)
|
||||||
|
{
|
||||||
|
if (map.contains("resolution") && map["resolution"].is_number())
|
||||||
|
return std::max(0.01, map["resolution"].get<double>());
|
||||||
|
return 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
double mapImageHeight(const nlohmann::json& map)
|
||||||
|
{
|
||||||
|
if (map.contains("height") && map["height"].is_number())
|
||||||
|
return map["height"].get<double>();
|
||||||
|
return 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::pair<double, double> PathPlanner::worldToPixel(const nlohmann::json& map, double wx, double wy, double img_h)
|
||||||
|
{
|
||||||
|
const double res = mapResolution(map);
|
||||||
|
const double ox = map.value("origin_x", 0.0);
|
||||||
|
const double oy = map.value("origin_y", 0.0);
|
||||||
|
return {((wx - ox) / res), (img_h - (wy - oy) / res)};
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldPoint PathPlanner::pixelToWorld(const nlohmann::json& map, double px, double py, double img_h)
|
||||||
|
{
|
||||||
|
const double res = mapResolution(map);
|
||||||
|
const double ox = map.value("origin_x", 0.0);
|
||||||
|
const double oy = map.value("origin_y", 0.0);
|
||||||
|
return {ox + px * res, oy + (img_h - py) * res};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PathPlanner::pointInPolygon(double px, double py, const nlohmann::json& points)
|
||||||
|
{
|
||||||
|
if (!points.is_array() || points.size() < 3)
|
||||||
|
return false;
|
||||||
|
bool inside = false;
|
||||||
|
size_t j = points.size() - 1;
|
||||||
|
for (size_t i = 0; i < points.size(); ++i)
|
||||||
|
{
|
||||||
|
const auto& pi = points[i];
|
||||||
|
const auto& pj = points[j];
|
||||||
|
if (!pi.is_object() || !pj.is_object())
|
||||||
|
continue;
|
||||||
|
const double xi = pi.value("x", 0.0);
|
||||||
|
const double yi = pi.value("y", 0.0);
|
||||||
|
const double xj = pj.value("x", 0.0);
|
||||||
|
const double yj = pj.value("y", 0.0);
|
||||||
|
const bool intersect = ((yi > py) != (yj > py)) && (px < (xj - xi) * (py - yi) / ((yj - yi) + 1e-12) + xi);
|
||||||
|
if (intersect)
|
||||||
|
inside = !inside;
|
||||||
|
j = i;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PathPlanner::pointNearPolyline(double px, double py, const nlohmann::json& points, double half_width)
|
||||||
|
{
|
||||||
|
if (!points.is_array() || points.size() < 2)
|
||||||
|
return false;
|
||||||
|
const double limit = half_width + 4.0;
|
||||||
|
for (size_t j = 0; j + 1 < points.size(); ++j)
|
||||||
|
{
|
||||||
|
const auto& p1 = points[j];
|
||||||
|
const auto& p2 = points[j + 1];
|
||||||
|
if (!p1.is_object() || !p2.is_object())
|
||||||
|
continue;
|
||||||
|
const double x1 = p1.value("x", 0.0);
|
||||||
|
const double y1 = p1.value("y", 0.0);
|
||||||
|
const double x2 = p2.value("x", 0.0);
|
||||||
|
const double y2 = p2.value("y", 0.0);
|
||||||
|
const double dx = x2 - x1;
|
||||||
|
const double dy = y2 - y1;
|
||||||
|
const double len_sq = dx * dx + dy * dy;
|
||||||
|
double dist = 0;
|
||||||
|
if (len_sq == 0)
|
||||||
|
dist = std::hypot(px - x1, py - y1);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
double t = ((px - x1) * dx + (py - y1) * dy) / len_sq;
|
||||||
|
t = std::clamp(t, 0.0, 1.0);
|
||||||
|
dist = std::hypot(px - (x1 + t * dx), py - (y1 + t * dy));
|
||||||
|
}
|
||||||
|
if (dist <= limit)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
double PathPlanner::zoneTraversalCost(const nlohmann::json& zones, double px, double py, const nlohmann::json& map)
|
||||||
|
{
|
||||||
|
if (!zones.is_array())
|
||||||
|
return 1.0;
|
||||||
|
for (auto it = zones.rbegin(); it != zones.rend(); ++it)
|
||||||
|
{
|
||||||
|
const auto& z = *it;
|
||||||
|
if (!z.is_object())
|
||||||
|
continue;
|
||||||
|
const std::string type = z.value("type", "");
|
||||||
|
if (type != "forbidden" && type != "preferred" && type != "unpreferred")
|
||||||
|
continue;
|
||||||
|
const auto& points = z.value("points", nlohmann::json::array());
|
||||||
|
bool hit = false;
|
||||||
|
if (z.value("geometry", "") == "line")
|
||||||
|
{
|
||||||
|
double half = 8.0;
|
||||||
|
if (z.contains("line_width_cm") && z["line_width_cm"].is_number())
|
||||||
|
{
|
||||||
|
const double res = mapResolution(map);
|
||||||
|
half = std::max(4.0, z["line_width_cm"].get<double>() / (res * 100.0) / 2.0);
|
||||||
|
}
|
||||||
|
hit = pointNearPolyline(px, py, points, half);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
hit = pointInPolygon(px, py, points);
|
||||||
|
}
|
||||||
|
if (!hit)
|
||||||
|
continue;
|
||||||
|
if (type == "forbidden")
|
||||||
|
return kInf;
|
||||||
|
if (type == "unpreferred")
|
||||||
|
return 4.0;
|
||||||
|
if (type == "preferred")
|
||||||
|
return 0.35;
|
||||||
|
}
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double PathPlanner::cellCost(const nlohmann::json& map, double wx, double wy, double img_h)
|
||||||
|
{
|
||||||
|
const auto px_py = worldToPixel(map, wx, wy, img_h);
|
||||||
|
const auto& zones = map.value("zones", nlohmann::json::array());
|
||||||
|
return zoneTraversalCost(zones, px_py.first, px_py.second, map);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<WorldPoint> PathPlanner::positionWorld(const nlohmann::json& map, const std::string& position_id)
|
||||||
|
{
|
||||||
|
const auto& zones = map.value("zones", nlohmann::json::array());
|
||||||
|
if (!zones.is_array())
|
||||||
|
return std::nullopt;
|
||||||
|
for (const auto& z : zones)
|
||||||
|
{
|
||||||
|
if (!z.is_object() || z.value("type", "") != "position")
|
||||||
|
continue;
|
||||||
|
if (z.value("id", "") != position_id)
|
||||||
|
continue;
|
||||||
|
return WorldPoint{z.value("x", 0.0), z.value("y", 0.0)};
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json PathPlanner::plan(const nlohmann::json& map, const WorldPoint& from, const WorldPoint& to)
|
||||||
|
{
|
||||||
|
nlohmann::json fallback = nlohmann::json::array();
|
||||||
|
fallback.push_back({{"x", from.x}, {"y", from.y}});
|
||||||
|
fallback.push_back({{"x", to.x}, {"y", to.y}});
|
||||||
|
|
||||||
|
const double img_h = mapImageHeight(map);
|
||||||
|
const double res = mapResolution(map);
|
||||||
|
const double cell_m = std::max(res * 4.0, 0.2);
|
||||||
|
|
||||||
|
const double min_x = std::min(from.x, to.x) - 2.0;
|
||||||
|
const double max_x = std::max(from.x, to.x) + 2.0;
|
||||||
|
const double min_y = std::min(from.y, to.y) - 2.0;
|
||||||
|
const double max_y = std::max(from.y, to.y) + 2.0;
|
||||||
|
|
||||||
|
const int cols = std::clamp(static_cast<int>(std::ceil((max_x - min_x) / cell_m)) + 1, 8, 120);
|
||||||
|
const int rows = std::clamp(static_cast<int>(std::ceil((max_y - min_y) / cell_m)) + 1, 8, 120);
|
||||||
|
|
||||||
|
auto toCell = [&](double wx, double wy) -> GridCell {
|
||||||
|
const int cx = std::clamp(static_cast<int>(std::round((wx - min_x) / cell_m)), 0, cols - 1);
|
||||||
|
const int cy = std::clamp(static_cast<int>(std::round((wy - min_y) / cell_m)), 0, rows - 1);
|
||||||
|
return {cx, cy};
|
||||||
|
};
|
||||||
|
|
||||||
|
auto cellWorld = [&](int cx, int cy) -> WorldPoint {
|
||||||
|
return {min_x + cx * cell_m, min_y + cy * cell_m};
|
||||||
|
};
|
||||||
|
|
||||||
|
const GridCell start = toCell(from.x, from.y);
|
||||||
|
const GridCell goal = toCell(to.x, to.y);
|
||||||
|
|
||||||
|
const auto key = [](const GridCell& c) { return std::to_string(c.x) + "," + std::to_string(c.y); };
|
||||||
|
|
||||||
|
std::vector<double> costs(static_cast<size_t>(cols * rows), kInf);
|
||||||
|
std::vector<GridCell> parent(static_cast<size_t>(cols * rows), {-1, -1});
|
||||||
|
auto idx = [&](int x, int y) { return y * cols + x; };
|
||||||
|
|
||||||
|
struct Node
|
||||||
|
{
|
||||||
|
double f;
|
||||||
|
int x;
|
||||||
|
int y;
|
||||||
|
};
|
||||||
|
auto cmp = [](const Node& a, const Node& b) { return a.f > b.f; };
|
||||||
|
std::priority_queue<Node, std::vector<Node>, decltype(cmp)> open(cmp);
|
||||||
|
|
||||||
|
const auto start_cost = cellCost(map, from.x, from.y, img_h);
|
||||||
|
if (!std::isfinite(start_cost))
|
||||||
|
return fallback;
|
||||||
|
costs[static_cast<size_t>(idx(start.x, start.y))] = 0;
|
||||||
|
open.push({0, start.x, start.y});
|
||||||
|
|
||||||
|
const int dx[8] = {1, -1, 0, 0, 1, 1, -1, -1};
|
||||||
|
const int dy[8] = {0, 0, 1, -1, 1, -1, 1, -1};
|
||||||
|
|
||||||
|
bool found = false;
|
||||||
|
while (!open.empty())
|
||||||
|
{
|
||||||
|
const Node cur = open.top();
|
||||||
|
open.pop();
|
||||||
|
if (cur.x == goal.x && cur.y == goal.y)
|
||||||
|
{
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const size_t cur_i = static_cast<size_t>(idx(cur.x, cur.y));
|
||||||
|
if (cur.f > costs[cur_i] + 1e-6)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
for (int k = 0; k < 8; ++k)
|
||||||
|
{
|
||||||
|
const int nx = cur.x + dx[k];
|
||||||
|
const int ny = cur.y + dy[k];
|
||||||
|
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows)
|
||||||
|
continue;
|
||||||
|
const WorldPoint wp = cellWorld(nx, ny);
|
||||||
|
const double step = cellCost(map, wp.x, wp.y, img_h);
|
||||||
|
if (!std::isfinite(step))
|
||||||
|
continue;
|
||||||
|
const double move = (k < 4) ? 1.0 : 1.414;
|
||||||
|
const size_t ni = static_cast<size_t>(idx(nx, ny));
|
||||||
|
const double next = costs[cur_i] + step * move;
|
||||||
|
if (next + 1e-9 < costs[ni])
|
||||||
|
{
|
||||||
|
costs[ni] = next;
|
||||||
|
parent[ni] = {cur.x, cur.y};
|
||||||
|
const double h = std::hypot(nx - goal.x, ny - goal.y);
|
||||||
|
open.push({next + h, nx, ny});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found)
|
||||||
|
return fallback;
|
||||||
|
|
||||||
|
std::vector<GridCell> cells;
|
||||||
|
GridCell c = goal;
|
||||||
|
while (c.x >= 0)
|
||||||
|
{
|
||||||
|
cells.push_back(c);
|
||||||
|
if (c.x == start.x && c.y == start.y)
|
||||||
|
break;
|
||||||
|
const size_t pi = static_cast<size_t>(idx(c.x, c.y));
|
||||||
|
c = parent[pi];
|
||||||
|
if (c.x < 0)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::reverse(cells.begin(), cells.end());
|
||||||
|
|
||||||
|
nlohmann::json out = nlohmann::json::array();
|
||||||
|
out.push_back({{"x", from.x}, {"y", from.y}});
|
||||||
|
for (size_t i = 1; i + 1 < cells.size(); ++i)
|
||||||
|
{
|
||||||
|
const WorldPoint wp = cellWorld(cells[i].x, cells[i].y);
|
||||||
|
out.push_back({{"x", wp.x}, {"y", wp.y}});
|
||||||
|
}
|
||||||
|
out.push_back({{"x", to.x}, {"y", to.y}});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
41
src/path/path_planner.hpp
Normal file
41
src/path/path_planner.hpp
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
struct WorldPoint
|
||||||
|
{
|
||||||
|
double x = 0;
|
||||||
|
double y = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
class PathPlanner
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static std::optional<WorldPoint> positionWorld(const nlohmann::json& map, const std::string& position_id);
|
||||||
|
static nlohmann::json plan(const nlohmann::json& map,
|
||||||
|
const WorldPoint& from,
|
||||||
|
const WorldPoint& to);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct GridCell
|
||||||
|
{
|
||||||
|
int x = 0;
|
||||||
|
int y = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
static double cellCost(const nlohmann::json& map, double wx, double wy, double img_h);
|
||||||
|
static std::pair<double, double> worldToPixel(const nlohmann::json& map, double wx, double wy, double img_h);
|
||||||
|
static WorldPoint pixelToWorld(const nlohmann::json& map, double px, double py, double img_h);
|
||||||
|
static bool pointInPolygon(double px, double py, const nlohmann::json& points);
|
||||||
|
static bool pointNearPolyline(double px, double py, const nlohmann::json& points, double half_width);
|
||||||
|
static double zoneTraversalCost(const nlohmann::json& zones, double px, double py, const nlohmann::json& map);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
104
src/path/path_service.cpp
Normal file
104
src/path/path_service.cpp
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#include "path/path_service.hpp"
|
||||||
|
|
||||||
|
#include "path/path_planner.hpp"
|
||||||
|
#include "storage/map_store.hpp"
|
||||||
|
#include "storage/path_store.hpp"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
PathService::PathService(PathStore& paths, MapStore& maps) : paths_(paths), maps_(maps) {}
|
||||||
|
|
||||||
|
nlohmann::json PathService::getOrCreatePath(const std::string& map_id,
|
||||||
|
const std::string& site_id,
|
||||||
|
const std::string& from_position_id,
|
||||||
|
const std::string& to_position_id,
|
||||||
|
std::string& err)
|
||||||
|
{
|
||||||
|
if (auto cached = paths_.findBetween(map_id, from_position_id, to_position_id))
|
||||||
|
return *cached;
|
||||||
|
|
||||||
|
const auto map = maps_.find(map_id);
|
||||||
|
if (!map)
|
||||||
|
{
|
||||||
|
err = "map not found";
|
||||||
|
return nlohmann::json::object();
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto from = PathPlanner::positionWorld(*map, from_position_id);
|
||||||
|
const auto to = PathPlanner::positionWorld(*map, to_position_id);
|
||||||
|
if (!from || !to)
|
||||||
|
{
|
||||||
|
err = "position not found on map";
|
||||||
|
return nlohmann::json::object();
|
||||||
|
}
|
||||||
|
|
||||||
|
const nlohmann::json points = PathPlanner::plan(*map, *from, *to);
|
||||||
|
nlohmann::json payload = {{"site_id", site_id.empty() ? map->value("site_id", "") : site_id},
|
||||||
|
{"map_id", map_id},
|
||||||
|
{"from_position_id", from_position_id},
|
||||||
|
{"to_position_id", to_position_id},
|
||||||
|
{"points", points},
|
||||||
|
{"auto_created", true}};
|
||||||
|
const auto saved = paths_.upsert(payload, err);
|
||||||
|
return saved ? *saved : nlohmann::json::object();
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json PathService::pathsBetweenPositions(const std::string& map_id,
|
||||||
|
const std::string& from_position_id,
|
||||||
|
const std::string& to_position_id) const
|
||||||
|
{
|
||||||
|
if (auto hit = paths_.findBetween(map_id, from_position_id, to_position_id))
|
||||||
|
return *hit;
|
||||||
|
return nlohmann::json::object();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PathService::invalidateMapPositions(const nlohmann::json& old_map, const nlohmann::json& new_map)
|
||||||
|
{
|
||||||
|
if (!old_map.is_object() || !new_map.is_object())
|
||||||
|
return;
|
||||||
|
const std::string map_id = new_map.value("id", old_map.value("id", ""));
|
||||||
|
if (map_id.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
auto positionSnapshot = [](const nlohmann::json& map) {
|
||||||
|
std::unordered_map<std::string, std::pair<double, double>> out;
|
||||||
|
const auto& zones = map.value("zones", nlohmann::json::array());
|
||||||
|
if (!zones.is_array())
|
||||||
|
return out;
|
||||||
|
for (const auto& z : zones)
|
||||||
|
{
|
||||||
|
if (!z.is_object() || z.value("type", "") != "position")
|
||||||
|
continue;
|
||||||
|
const std::string id = z.value("id", "");
|
||||||
|
if (id.empty())
|
||||||
|
continue;
|
||||||
|
out[id] = {z.value("x", 0.0), z.value("y", 0.0)};
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const auto before = positionSnapshot(old_map);
|
||||||
|
const auto after = positionSnapshot(new_map);
|
||||||
|
for (const auto& [id, coords] : after)
|
||||||
|
{
|
||||||
|
const auto it = before.find(id);
|
||||||
|
if (it == before.end())
|
||||||
|
{
|
||||||
|
paths_.removeForPosition(map_id, id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const double eps = 1e-4;
|
||||||
|
if (std::abs(it->second.first - coords.first) > eps || std::abs(it->second.second - coords.second) > eps)
|
||||||
|
paths_.removeForPosition(map_id, id);
|
||||||
|
}
|
||||||
|
for (const auto& [id, _] : before)
|
||||||
|
{
|
||||||
|
if (after.find(id) == after.end())
|
||||||
|
paths_.removeForPosition(map_id, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
35
src/path/path_service.hpp
Normal file
35
src/path/path_service.hpp
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class MapStore;
|
||||||
|
class PathStore;
|
||||||
|
|
||||||
|
class PathService
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
PathService(PathStore& paths, MapStore& maps);
|
||||||
|
|
||||||
|
nlohmann::json getOrCreatePath(const std::string& map_id,
|
||||||
|
const std::string& site_id,
|
||||||
|
const std::string& from_position_id,
|
||||||
|
const std::string& to_position_id,
|
||||||
|
std::string& err);
|
||||||
|
|
||||||
|
void invalidateMapPositions(const nlohmann::json& old_map, const nlohmann::json& new_map);
|
||||||
|
|
||||||
|
nlohmann::json pathsBetweenPositions(const std::string& map_id,
|
||||||
|
const std::string& from_position_id,
|
||||||
|
const std::string& to_position_id) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
PathStore& paths_;
|
||||||
|
MapStore& maps_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
@@ -87,6 +87,7 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
|||||||
{"maps", maps},
|
{"maps", maps},
|
||||||
{"transitions", transition_store_.list(site_id)},
|
{"transitions", transition_store_.list(site_id)},
|
||||||
{"io_modules", io_module_store_.list(site_id)},
|
{"io_modules", io_module_store_.list(site_id)},
|
||||||
|
{"paths", path_store_.list(site_id)},
|
||||||
{"sounds", sound_store_.list()}};
|
{"sounds", sound_store_.list()}};
|
||||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
res.body = bundle.dump(2);
|
res.body = bundle.dump(2);
|
||||||
@@ -112,6 +113,7 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
|||||||
|
|
||||||
nlohmann::json imported = {{"maps", nlohmann::json::array()},
|
nlohmann::json imported = {{"maps", nlohmann::json::array()},
|
||||||
{"io_modules", nlohmann::json::array()},
|
{"io_modules", nlohmann::json::array()},
|
||||||
|
{"paths", nlohmann::json::array()},
|
||||||
{"transitions", nlohmann::json::array()}};
|
{"transitions", nlohmann::json::array()}};
|
||||||
std::unordered_map<std::string, std::string> map_id_remap;
|
std::unordered_map<std::string, std::string> map_id_remap;
|
||||||
|
|
||||||
@@ -159,6 +161,26 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bundle.contains("paths") && bundle["paths"].is_array())
|
||||||
|
{
|
||||||
|
for (const auto& path : bundle["paths"])
|
||||||
|
{
|
||||||
|
if (!path.is_object())
|
||||||
|
continue;
|
||||||
|
nlohmann::json payload = path;
|
||||||
|
payload.erase("id");
|
||||||
|
payload["site_id"] = site_id;
|
||||||
|
const std::string old_map = payload.value("map_id", "");
|
||||||
|
if (map_id_remap.count(old_map))
|
||||||
|
payload["map_id"] = map_id_remap[old_map];
|
||||||
|
std::string err;
|
||||||
|
const auto saved = path_store_.upsert(payload, err);
|
||||||
|
if (!saved)
|
||||||
|
return HttpUtil::jsonError(res, 400, "path import failed: " + err);
|
||||||
|
imported["paths"].push_back(*saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (bundle.contains("transitions") && bundle["transitions"].is_array())
|
if (bundle.contains("transitions") && bundle["transitions"].is_array())
|
||||||
{
|
{
|
||||||
for (const auto& tr : bundle["transitions"])
|
for (const auto& tr : bundle["transitions"])
|
||||||
@@ -240,10 +262,13 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
|||||||
{
|
{
|
||||||
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
}
|
}
|
||||||
|
const auto existing = map_store_.find(id);
|
||||||
std::string err;
|
std::string err;
|
||||||
if (!map_store_.update(id, body, err))
|
if (!map_store_.update(id, body, err))
|
||||||
return HttpUtil::jsonError(res, 404, err);
|
return HttpUtil::jsonError(res, 404, err);
|
||||||
const auto updated = map_store_.find(id);
|
const auto updated = map_store_.find(id);
|
||||||
|
if (existing && updated)
|
||||||
|
path_service_.invalidateMapPositions(*existing, *updated);
|
||||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||||
});
|
});
|
||||||
|
|||||||
36
src/server/api_path_routes.cpp
Normal file
36
src/server/api_path_routes.cpp
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#include "server/api_server.hpp"
|
||||||
|
|
||||||
|
#include "util/http_util.hpp"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
void ApiServer::registerPathRoutes(httplib::Server& svr)
|
||||||
|
{
|
||||||
|
svr.Get("/api/paths", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string site_id = req.has_param("site_id") ? req.get_param_value("site_id") : "";
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = nlohmann::json({{"paths", path_store_.list(site_id)}}).dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Get(R"(/api/paths/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
const auto path = path_store_.find(id);
|
||||||
|
if (!path)
|
||||||
|
return HttpUtil::jsonError(res, 404, "path not found");
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = path->dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Delete(R"(/api/paths/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
std::string err;
|
||||||
|
if (!path_store_.remove(id, err))
|
||||||
|
return HttpUtil::jsonError(res, 404, err);
|
||||||
|
res.status = 204;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
@@ -22,6 +22,8 @@ ApiServer::ApiServer(StateRepository& repo,
|
|||||||
TransitionStore& transition_store,
|
TransitionStore& transition_store,
|
||||||
IoModuleStore& io_module_store,
|
IoModuleStore& io_module_store,
|
||||||
IoModuleService& io_module_service,
|
IoModuleService& io_module_service,
|
||||||
|
PathStore& path_store,
|
||||||
|
PathService& path_service,
|
||||||
DashboardStore& dashboard_store)
|
DashboardStore& dashboard_store)
|
||||||
: repo_(repo),
|
: repo_(repo),
|
||||||
mission_queue_(mission_queue),
|
mission_queue_(mission_queue),
|
||||||
@@ -35,6 +37,8 @@ ApiServer::ApiServer(StateRepository& repo,
|
|||||||
transition_store_(transition_store),
|
transition_store_(transition_store),
|
||||||
io_module_store_(io_module_store),
|
io_module_store_(io_module_store),
|
||||||
io_module_service_(io_module_service),
|
io_module_service_(io_module_service),
|
||||||
|
path_store_(path_store),
|
||||||
|
path_service_(path_service),
|
||||||
dashboard_store_(dashboard_store)
|
dashboard_store_(dashboard_store)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -558,6 +562,7 @@ void ApiServer::registerRoutes(httplib::Server& svr)
|
|||||||
registerMediaRoutes(svr);
|
registerMediaRoutes(svr);
|
||||||
registerTransitionRoutes(svr);
|
registerTransitionRoutes(svr);
|
||||||
registerIoModuleRoutes(svr);
|
registerIoModuleRoutes(svr);
|
||||||
|
registerPathRoutes(svr);
|
||||||
registerDashboardRoutes(svr);
|
registerDashboardRoutes(svr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
#include "storage/sound_store.hpp"
|
#include "storage/sound_store.hpp"
|
||||||
#include "storage/io_module_store.hpp"
|
#include "storage/io_module_store.hpp"
|
||||||
#include "io/io_module_service.hpp"
|
#include "io/io_module_service.hpp"
|
||||||
|
#include "storage/path_store.hpp"
|
||||||
|
#include "path/path_service.hpp"
|
||||||
#include "storage/transition_store.hpp"
|
#include "storage/transition_store.hpp"
|
||||||
#include "storage/state_repository.hpp"
|
#include "storage/state_repository.hpp"
|
||||||
|
|
||||||
@@ -33,6 +35,8 @@ public:
|
|||||||
TransitionStore& transition_store,
|
TransitionStore& transition_store,
|
||||||
IoModuleStore& io_module_store,
|
IoModuleStore& io_module_store,
|
||||||
IoModuleService& io_module_service,
|
IoModuleService& io_module_service,
|
||||||
|
PathStore& path_store,
|
||||||
|
PathService& path_service,
|
||||||
DashboardStore& dashboard_store);
|
DashboardStore& dashboard_store);
|
||||||
|
|
||||||
void registerRoutes(httplib::Server& svr);
|
void registerRoutes(httplib::Server& svr);
|
||||||
@@ -50,6 +54,8 @@ private:
|
|||||||
TransitionStore& transition_store_;
|
TransitionStore& transition_store_;
|
||||||
IoModuleStore& io_module_store_;
|
IoModuleStore& io_module_store_;
|
||||||
IoModuleService& io_module_service_;
|
IoModuleService& io_module_service_;
|
||||||
|
PathStore& path_store_;
|
||||||
|
PathService& path_service_;
|
||||||
DashboardStore& dashboard_store_;
|
DashboardStore& dashboard_store_;
|
||||||
|
|
||||||
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
|
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
|
||||||
@@ -62,6 +68,7 @@ private:
|
|||||||
void registerMediaRoutes(httplib::Server& svr);
|
void registerMediaRoutes(httplib::Server& svr);
|
||||||
void registerTransitionRoutes(httplib::Server& svr);
|
void registerTransitionRoutes(httplib::Server& svr);
|
||||||
void registerIoModuleRoutes(httplib::Server& svr);
|
void registerIoModuleRoutes(httplib::Server& svr);
|
||||||
|
void registerPathRoutes(httplib::Server& svr);
|
||||||
void registerDashboardRoutes(httplib::Server& svr);
|
void registerDashboardRoutes(httplib::Server& svr);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,21 @@ CREATE TABLE IF NOT EXISTS io_modules (
|
|||||||
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS paths (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
site_id TEXT NOT NULL,
|
||||||
|
map_id TEXT NOT NULL,
|
||||||
|
from_position_id TEXT NOT NULL,
|
||||||
|
to_position_id TEXT NOT NULL,
|
||||||
|
auto_created INTEGER NOT NULL DEFAULT 1,
|
||||||
|
points_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(map_id, from_position_id, to_position_id),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dashboards (
|
CREATE TABLE IF NOT EXISTS dashboards (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -431,6 +446,29 @@ bool Database::applySchemaMigrations(std::string& err)
|
|||||||
setMeta("schema_version", "7");
|
setMeta("schema_version", "7");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ver = getMeta("schema_version").value_or("1");
|
||||||
|
if (ver == "7")
|
||||||
|
{
|
||||||
|
if (!execSql(db_,
|
||||||
|
"CREATE TABLE IF NOT EXISTS paths ("
|
||||||
|
"id TEXT PRIMARY KEY, "
|
||||||
|
"site_id TEXT NOT NULL, "
|
||||||
|
"map_id TEXT NOT NULL, "
|
||||||
|
"from_position_id TEXT NOT NULL, "
|
||||||
|
"to_position_id TEXT NOT NULL, "
|
||||||
|
"auto_created INTEGER NOT NULL DEFAULT 1, "
|
||||||
|
"points_json TEXT NOT NULL DEFAULT '[]', "
|
||||||
|
"created_at TEXT NOT NULL, "
|
||||||
|
"updated_at TEXT NOT NULL, "
|
||||||
|
"UNIQUE(map_id, from_position_id, to_position_id), "
|
||||||
|
"FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE, "
|
||||||
|
"FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE CASCADE"
|
||||||
|
")",
|
||||||
|
err))
|
||||||
|
return false;
|
||||||
|
setMeta("schema_version", "8");
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
256
src/storage/path_store.cpp
Normal file
256
src/storage/path_store.cpp
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
#include "storage/path_store.hpp"
|
||||||
|
|
||||||
|
#include "storage/database.hpp"
|
||||||
|
#include "util/id_util.hpp"
|
||||||
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||||
|
{
|
||||||
|
auto text = [&](int col) -> std::string {
|
||||||
|
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
|
||||||
|
return "";
|
||||||
|
const char* v = reinterpret_cast<const char*>(sqlite3_column_text(stmt, col));
|
||||||
|
return v ? std::string(v) : "";
|
||||||
|
};
|
||||||
|
nlohmann::json points = nlohmann::json::array();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const std::string raw = text(6);
|
||||||
|
if (!raw.empty())
|
||||||
|
points = nlohmann::json::parse(raw);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
points = nlohmann::json::array();
|
||||||
|
}
|
||||||
|
return {{"id", text(0)},
|
||||||
|
{"site_id", text(1)},
|
||||||
|
{"map_id", text(2)},
|
||||||
|
{"from_position_id", text(3)},
|
||||||
|
{"to_position_id", text(4)},
|
||||||
|
{"auto_created", sqlite3_column_int(stmt, 5) != 0},
|
||||||
|
{"points", points.is_array() ? points : nlohmann::json::array()},
|
||||||
|
{"created_at", text(7)},
|
||||||
|
{"updated_at", text(8)}};
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr const char* kSelect =
|
||||||
|
"SELECT id, site_id, map_id, from_position_id, to_position_id, auto_created, points_json, created_at, updated_at "
|
||||||
|
"FROM paths";
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
PathStore::PathStore(Database& db) : db_(db) {}
|
||||||
|
|
||||||
|
nlohmann::json PathStore::list(const std::string& site_id) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
nlohmann::json out = nlohmann::json::array();
|
||||||
|
std::string sql = kSelect;
|
||||||
|
if (!site_id.empty())
|
||||||
|
sql += " WHERE site_id = ?1";
|
||||||
|
sql += " ORDER BY map_id, from_position_id, to_position_id";
|
||||||
|
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
|
||||||
|
return out;
|
||||||
|
if (!site_id.empty())
|
||||||
|
sqlite3_bind_text(stmt, 1, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
|
out.push_back(rowToJson(stmt));
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> PathStore::find(const std::string& id) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
const std::string sql = std::string(kSelect) + " WHERE id = ?1";
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
|
||||||
|
return std::nullopt;
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
std::optional<nlohmann::json> out;
|
||||||
|
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
|
out = rowToJson(stmt);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> PathStore::findBetween(const std::string& map_id,
|
||||||
|
const std::string& from_position_id,
|
||||||
|
const std::string& to_position_id) const
|
||||||
|
{
|
||||||
|
if (map_id.empty() || from_position_id.empty() || to_position_id.empty())
|
||||||
|
return std::nullopt;
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
const std::string sql = std::string(kSelect) +
|
||||||
|
" WHERE map_id = ?1 AND from_position_id = ?2 AND to_position_id = ?3 LIMIT 1";
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
|
||||||
|
return std::nullopt;
|
||||||
|
sqlite3_bind_text(stmt, 1, map_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 2, from_position_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 3, to_position_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
std::optional<nlohmann::json> out;
|
||||||
|
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
|
out = rowToJson(stmt);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> PathStore::upsert(const nlohmann::json& payload, std::string& err)
|
||||||
|
{
|
||||||
|
if (!payload.is_object())
|
||||||
|
{
|
||||||
|
err = "payload must be an object";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const std::string site_id = StringUtil::trimCopy(payload.value("site_id", ""));
|
||||||
|
const std::string map_id = StringUtil::trimCopy(payload.value("map_id", ""));
|
||||||
|
const std::string from_id = StringUtil::trimCopy(payload.value("from_position_id", ""));
|
||||||
|
const std::string to_id = StringUtil::trimCopy(payload.value("to_position_id", ""));
|
||||||
|
if (site_id.empty() || map_id.empty() || from_id.empty() || to_id.empty())
|
||||||
|
{
|
||||||
|
err = "missing required fields";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (from_id == to_id)
|
||||||
|
{
|
||||||
|
err = "from and to positions must differ";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const auto& points = payload.contains("points") && payload["points"].is_array() ? payload["points"]
|
||||||
|
: nlohmann::json::array();
|
||||||
|
const std::string points_json = points.dump();
|
||||||
|
const bool auto_created = payload.value("auto_created", true);
|
||||||
|
const std::string now = IdUtil::nowIso8601();
|
||||||
|
|
||||||
|
std::string id = payload.value("id", IdUtil::newId());
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* lookup = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"SELECT id FROM paths WHERE map_id = ?1 AND from_position_id = ?2 AND to_position_id = ?3",
|
||||||
|
-1,
|
||||||
|
&lookup,
|
||||||
|
nullptr) == SQLITE_OK)
|
||||||
|
{
|
||||||
|
sqlite3_bind_text(lookup, 1, map_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(lookup, 2, from_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(lookup, 3, to_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
if (sqlite3_step(lookup) == SQLITE_ROW)
|
||||||
|
{
|
||||||
|
const char* existing = reinterpret_cast<const char*>(sqlite3_column_text(lookup, 0));
|
||||||
|
if (existing)
|
||||||
|
id = existing;
|
||||||
|
}
|
||||||
|
sqlite3_finalize(lookup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"INSERT INTO paths(id, site_id, map_id, from_position_id, to_position_id, auto_created, "
|
||||||
|
"points_json, created_at, updated_at) "
|
||||||
|
"VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9) "
|
||||||
|
"ON CONFLICT(map_id, from_position_id, to_position_id) DO UPDATE SET "
|
||||||
|
"points_json=excluded.points_json, auto_created=excluded.auto_created, updated_at=excluded.updated_at",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 2, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 3, map_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 4, from_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 5, to_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_int(stmt, 6, auto_created ? 1 : 0);
|
||||||
|
sqlite3_bind_text(stmt, 7, points_json.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 8, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 9, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_stmt* get_stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), (std::string(kSelect) + " WHERE id = ?1").c_str(), -1, &get_stmt, nullptr) !=
|
||||||
|
SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(get_stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
std::optional<nlohmann::json> out;
|
||||||
|
if (sqlite3_step(get_stmt) == SQLITE_ROW)
|
||||||
|
out = rowToJson(get_stmt);
|
||||||
|
sqlite3_finalize(get_stmt);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PathStore::remove(const std::string& id, std::string& err)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM paths 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_step(stmt);
|
||||||
|
const bool ok = sqlite3_changes(db_.handle()) > 0;
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
if (!ok)
|
||||||
|
err = "path not found";
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
int PathStore::removeForMap(const std::string& map_id)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM paths WHERE map_id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||||
|
return 0;
|
||||||
|
sqlite3_bind_text(stmt, 1, map_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_step(stmt);
|
||||||
|
const int n = sqlite3_changes(db_.handle());
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
int PathStore::removeForPosition(const std::string& map_id, const std::string& position_id)
|
||||||
|
{
|
||||||
|
if (map_id.empty() || position_id.empty())
|
||||||
|
return 0;
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"DELETE FROM paths WHERE map_id = ?1 AND (from_position_id = ?2 OR to_position_id = ?2)",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
return 0;
|
||||||
|
sqlite3_bind_text(stmt, 1, map_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 2, position_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_step(stmt);
|
||||||
|
const int n = sqlite3_changes(db_.handle());
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
33
src/storage/path_store.hpp
Normal file
33
src/storage/path_store.hpp
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class Database;
|
||||||
|
|
||||||
|
class PathStore
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit PathStore(Database& db);
|
||||||
|
|
||||||
|
nlohmann::json list(const std::string& site_id = "") const;
|
||||||
|
std::optional<nlohmann::json> find(const std::string& id) const;
|
||||||
|
std::optional<nlohmann::json> findBetween(const std::string& map_id,
|
||||||
|
const std::string& from_position_id,
|
||||||
|
const std::string& to_position_id) const;
|
||||||
|
std::optional<nlohmann::json> upsert(const nlohmann::json& payload, std::string& err);
|
||||||
|
bool remove(const std::string& id, std::string& err);
|
||||||
|
int removeForMap(const std::string& map_id);
|
||||||
|
int removeForPosition(const std::string& map_id, const std::string& position_id);
|
||||||
|
|
||||||
|
private:
|
||||||
|
Database& db_;
|
||||||
|
mutable std::mutex mu_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
@@ -15,6 +15,7 @@ const pageTransitionsEl = el("pageTransitions");
|
|||||||
const pageUsersEl = el("pageUsers");
|
const pageUsersEl = el("pageUsers");
|
||||||
const pageUserGroupsEl = el("pageUserGroups");
|
const pageUserGroupsEl = el("pageUserGroups");
|
||||||
const pageIoModulesEl = el("pageIoModules");
|
const pageIoModulesEl = el("pageIoModules");
|
||||||
|
const pagePathsEl = el("pagePaths");
|
||||||
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");
|
||||||
@@ -129,7 +130,7 @@ const state = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function setActivePage(page) {
|
function setActivePage(page) {
|
||||||
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "users", "integrations", "monitoring", "help"];
|
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "users", "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));
|
||||||
@@ -144,6 +145,7 @@ function setActivePage(page) {
|
|||||||
if (pageTransitionsEl) pageTransitionsEl.hidden = p !== "transitions";
|
if (pageTransitionsEl) pageTransitionsEl.hidden = p !== "transitions";
|
||||||
if (pageUserGroupsEl) pageUserGroupsEl.hidden = p !== "user-groups";
|
if (pageUserGroupsEl) pageUserGroupsEl.hidden = p !== "user-groups";
|
||||||
if (pageIoModulesEl) pageIoModulesEl.hidden = p !== "io-modules";
|
if (pageIoModulesEl) pageIoModulesEl.hidden = p !== "io-modules";
|
||||||
|
if (pagePathsEl) pagePathsEl.hidden = p !== "paths";
|
||||||
if (pageUsersEl) pageUsersEl.hidden = p !== "users";
|
if (pageUsersEl) pageUsersEl.hidden = p !== "users";
|
||||||
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
||||||
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
||||||
@@ -159,6 +161,7 @@ function setActivePage(page) {
|
|||||||
contentEl.classList.toggle("content--transitions", p === "transitions");
|
contentEl.classList.toggle("content--transitions", p === "transitions");
|
||||||
contentEl.classList.toggle("content--user-groups", p === "user-groups");
|
contentEl.classList.toggle("content--user-groups", p === "user-groups");
|
||||||
contentEl.classList.toggle("content--io-modules", p === "io-modules");
|
contentEl.classList.toggle("content--io-modules", p === "io-modules");
|
||||||
|
contentEl.classList.toggle("content--paths", p === "paths");
|
||||||
contentEl.classList.toggle("content--users", p === "users");
|
contentEl.classList.toggle("content--users", p === "users");
|
||||||
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");
|
||||||
@@ -175,6 +178,8 @@ function setActivePage(page) {
|
|||||||
else if (window.UserGroupsApp?.onPageHide) window.UserGroupsApp.onPageHide();
|
else if (window.UserGroupsApp?.onPageHide) window.UserGroupsApp.onPageHide();
|
||||||
if (p === "io-modules" && window.IoModulesApp) window.IoModulesApp.onPageShow();
|
if (p === "io-modules" && window.IoModulesApp) window.IoModulesApp.onPageShow();
|
||||||
else if (window.IoModulesApp?.onPageHide) window.IoModulesApp.onPageHide();
|
else if (window.IoModulesApp?.onPageHide) window.IoModulesApp.onPageHide();
|
||||||
|
if (p === "paths" && window.PathsApp) window.PathsApp.onPageShow();
|
||||||
|
else if (window.PathsApp?.onPageHide) window.PathsApp.onPageHide();
|
||||||
if (p === "users" && window.UsersApp) window.UsersApp.onPageShow();
|
if (p === "users" && window.UsersApp) window.UsersApp.onPageShow();
|
||||||
else if (window.UsersApp?.onPageHide) window.UsersApp.onPageHide();
|
else if (window.UsersApp?.onPageHide) window.UsersApp.onPageHide();
|
||||||
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
transitions: "maps",
|
transitions: "maps",
|
||||||
"user-groups": "users",
|
"user-groups": "users",
|
||||||
"io-modules": "integrations",
|
"io-modules": "integrations",
|
||||||
|
paths: "maps",
|
||||||
users: "users",
|
users: "users",
|
||||||
integrations: "integrations",
|
integrations: "integrations",
|
||||||
};
|
};
|
||||||
@@ -177,6 +178,7 @@
|
|||||||
document.body.classList.toggle("auth-readonly-users", !canWrite("users"));
|
document.body.classList.toggle("auth-readonly-users", !canWrite("users"));
|
||||||
document.body.classList.toggle("auth-readonly-user-groups", !canWrite("users"));
|
document.body.classList.toggle("auth-readonly-user-groups", !canWrite("users"));
|
||||||
document.body.classList.toggle("auth-readonly-io-modules", !canWrite("integrations"));
|
document.body.classList.toggle("auth-readonly-io-modules", !canWrite("integrations"));
|
||||||
|
document.body.classList.toggle("auth-readonly-paths", !canWrite("maps"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUserMenu() {
|
function updateUserMenu() {
|
||||||
|
|||||||
48
www/i18n.js
48
www/i18n.js
@@ -76,6 +76,7 @@
|
|||||||
"nav.transitions": "Transitions",
|
"nav.transitions": "Transitions",
|
||||||
"nav.user-groups": "User groups",
|
"nav.user-groups": "User groups",
|
||||||
"nav.io-modules": "I/O modules",
|
"nav.io-modules": "I/O modules",
|
||||||
|
"nav.paths": "Paths",
|
||||||
"nav.users": "Users",
|
"nav.users": "Users",
|
||||||
"nav.build-robot": "Build Robot",
|
"nav.build-robot": "Build Robot",
|
||||||
"nav.monitoring-log": "System log",
|
"nav.monitoring-log": "System log",
|
||||||
@@ -317,7 +318,7 @@
|
|||||||
"maps.view": "Xem",
|
"maps.view": "Xem",
|
||||||
"maps.importComingSoon": "Import site sẽ có trong phiên bản sau.",
|
"maps.importComingSoon": "Import site sẽ có trong phiên bản sau.",
|
||||||
"maps.importNoSite": "Chọn site trước khi import.",
|
"maps.importNoSite": "Chọn site trước khi import.",
|
||||||
"maps.importSuccess": "Import xong: {maps} map(s), {io} I/O module(s), {transitions} transition(s).",
|
"maps.importSuccess": "Import xong: {maps} map(s), {io} I/O module(s), {transitions} transition(s), {paths} path(s).",
|
||||||
"maps.helpTitle": "Trợ giúp Maps",
|
"maps.helpTitle": "Trợ giúp Maps",
|
||||||
"maps.helpText": "Tạo map mới, upload ảnh PNG qua menu ⋮, sau đó kích hoạt map cho robot.",
|
"maps.helpText": "Tạo map mới, upload ảnh PNG qua menu ⋮, sau đó kích hoạt map cho robot.",
|
||||||
"maps.createDialog.title": "Tạo map",
|
"maps.createDialog.title": "Tạo map",
|
||||||
@@ -401,6 +402,7 @@
|
|||||||
"maps.editor.canvasTip": "Kéo map để di chuyển vùng nhìn hoặc dùng nút zoom in/out để phóng to/thu nhỏ.",
|
"maps.editor.canvasTip": "Kéo map để di chuyển vùng nhìn hoặc dùng nút zoom in/out để phóng to/thu nhỏ.",
|
||||||
"maps.editor.unsaved": "Chưa lưu",
|
"maps.editor.unsaved": "Chưa lưu",
|
||||||
"maps.editor.unsavedLeave": "Có thay đổi chưa lưu. Rời editor?",
|
"maps.editor.unsavedLeave": "Có thay đổi chưa lưu. Rời editor?",
|
||||||
|
"maps.editor.preferredPathWarning": "Map này có path đã lưu. Thay đổi preferred zone có thể không áp dụng cho đến khi bạn xóa các path liên quan trong Setup → Paths. Tiếp tục lưu?",
|
||||||
"maps.editor.menu": "Menu",
|
"maps.editor.menu": "Menu",
|
||||||
"maps.editor.undo": "Hoàn tác",
|
"maps.editor.undo": "Hoàn tác",
|
||||||
"maps.editor.save": "Lưu",
|
"maps.editor.save": "Lưu",
|
||||||
@@ -692,6 +694,26 @@
|
|||||||
"ioModules.testConnection": "Kiểm tra kết nối",
|
"ioModules.testConnection": "Kiểm tra kết nối",
|
||||||
"ioModules.testOk": "Kết nối TCP thành công.",
|
"ioModules.testOk": "Kết nối TCP thành công.",
|
||||||
|
|
||||||
|
"paths.title": "Paths",
|
||||||
|
"paths.subtitle": "Xem và xóa tuyến đường đã lưu giữa các position.",
|
||||||
|
"paths.helpTitle": "Trợ giúp Paths",
|
||||||
|
"paths.helpBody": "Path là tuyến đường giữa hai position trên cùng một map. Path được tạo tự động lần đầu robot chạy từ position A đến B và được tái sử dụng cho các lần sau. Xóa path để buộc tính lại (ví dụ sau khi thêm preferred zone). Không thể tạo path thủ công.",
|
||||||
|
"paths.clearFilters": "Xóa bộ lọc",
|
||||||
|
"paths.filterLabel": "Lọc:",
|
||||||
|
"paths.filterPlaceholder": "Lọc theo position hoặc map...",
|
||||||
|
"paths.itemsFound": "{n} mục",
|
||||||
|
"paths.pageOf": "Trang {page} / {total}",
|
||||||
|
"paths.colFrom": "From position",
|
||||||
|
"paths.colTo": "To position",
|
||||||
|
"paths.colMap": "Map",
|
||||||
|
"paths.colFunctions": "Functions",
|
||||||
|
"paths.empty": "Chưa có path. Path được tạo tự động khi robot di chuyển giữa hai position.",
|
||||||
|
"paths.emptyFilter": "Không có path khớp bộ lọc.",
|
||||||
|
"paths.view": "Xem trên map",
|
||||||
|
"paths.viewUnavailable": "Không mở được map editor.",
|
||||||
|
"paths.deleteTitle": "Xóa path?",
|
||||||
|
"paths.deleteConfirmText": "Xóa path từ \"{from}\" đến \"{to}\"? Path sẽ được tạo lại khi robot chạy lại giữa hai position 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",
|
||||||
@@ -887,6 +909,7 @@
|
|||||||
"nav.transitions": "Transitions",
|
"nav.transitions": "Transitions",
|
||||||
"nav.user-groups": "User groups",
|
"nav.user-groups": "User groups",
|
||||||
"nav.io-modules": "I/O modules",
|
"nav.io-modules": "I/O modules",
|
||||||
|
"nav.paths": "Paths",
|
||||||
"nav.users": "Users",
|
"nav.users": "Users",
|
||||||
"nav.build-robot": "Build Robot",
|
"nav.build-robot": "Build Robot",
|
||||||
"nav.monitoring-log": "System log",
|
"nav.monitoring-log": "System log",
|
||||||
@@ -1128,7 +1151,7 @@
|
|||||||
"maps.view": "View",
|
"maps.view": "View",
|
||||||
"maps.importComingSoon": "Import site will be available in a future release.",
|
"maps.importComingSoon": "Import site will be available in a future release.",
|
||||||
"maps.importNoSite": "Select a site before importing.",
|
"maps.importNoSite": "Select a site before importing.",
|
||||||
"maps.importSuccess": "Import complete: {maps} map(s), {io} I/O module(s), {transitions} transition(s).",
|
"maps.importSuccess": "Import complete: {maps} map(s), {io} I/O module(s), {transitions} transition(s), {paths} path(s).",
|
||||||
"maps.helpTitle": "Maps help",
|
"maps.helpTitle": "Maps help",
|
||||||
"maps.helpText": "Create a new map, upload a PNG via the ⋮ menu, then activate the map for the robot.",
|
"maps.helpText": "Create a new map, upload a PNG via the ⋮ menu, then activate the map for the robot.",
|
||||||
"maps.createDialog.title": "Create map",
|
"maps.createDialog.title": "Create map",
|
||||||
@@ -1212,6 +1235,7 @@
|
|||||||
"maps.editor.canvasTip": "Drag the map to move your view or use the zoom-in and -out buttons to zoom.",
|
"maps.editor.canvasTip": "Drag the map to move your view or use the zoom-in and -out buttons to zoom.",
|
||||||
"maps.editor.unsaved": "Unsaved",
|
"maps.editor.unsaved": "Unsaved",
|
||||||
"maps.editor.unsavedLeave": "You have unsaved changes. Leave the editor?",
|
"maps.editor.unsavedLeave": "You have unsaved changes. Leave the editor?",
|
||||||
|
"maps.editor.preferredPathWarning": "This map has cached paths. Preferred zone changes may not apply until you delete the related paths under Setup → Paths. Save anyway?",
|
||||||
"maps.editor.menu": "Menu",
|
"maps.editor.menu": "Menu",
|
||||||
"maps.editor.undo": "Undo",
|
"maps.editor.undo": "Undo",
|
||||||
"maps.editor.save": "Save",
|
"maps.editor.save": "Save",
|
||||||
@@ -1503,6 +1527,26 @@
|
|||||||
"ioModules.testConnection": "Test connection",
|
"ioModules.testConnection": "Test connection",
|
||||||
"ioModules.testOk": "TCP connection successful.",
|
"ioModules.testOk": "TCP connection successful.",
|
||||||
|
|
||||||
|
"paths.title": "Paths",
|
||||||
|
"paths.subtitle": "View and delete cached routes between positions.",
|
||||||
|
"paths.helpTitle": "Paths help",
|
||||||
|
"paths.helpBody": "A path is a saved route between two positions on the same map. It is created automatically the first time the robot drives from position A to B and reused afterward. Delete a path to force recalculation (for example after adding preferred zones). Paths cannot be created manually.",
|
||||||
|
"paths.clearFilters": "Clear filters",
|
||||||
|
"paths.filterLabel": "Filter:",
|
||||||
|
"paths.filterPlaceholder": "Filter by position or map...",
|
||||||
|
"paths.itemsFound": "{n} item(s) found",
|
||||||
|
"paths.pageOf": "Page {page} of {total}",
|
||||||
|
"paths.colFrom": "From position",
|
||||||
|
"paths.colTo": "To position",
|
||||||
|
"paths.colMap": "Map",
|
||||||
|
"paths.colFunctions": "Functions",
|
||||||
|
"paths.empty": "No paths yet. Paths are created automatically when the robot drives between two positions.",
|
||||||
|
"paths.emptyFilter": "No paths match the filter.",
|
||||||
|
"paths.view": "View on map",
|
||||||
|
"paths.viewUnavailable": "Could not open the map editor.",
|
||||||
|
"paths.deleteTitle": "Delete path?",
|
||||||
|
"paths.deleteConfirmText": "Delete path from \"{from}\" to \"{to}\"? It will be recreated when the robot drives between these positions again.",
|
||||||
|
|
||||||
"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",
|
||||||
|
|||||||
@@ -1272,6 +1272,68 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="page" id="pagePaths" data-page-content="paths" hidden>
|
||||||
|
<div id="pathsListView" class="mapsMirPage">
|
||||||
|
<header class="mapsMirHeader">
|
||||||
|
<div class="mapsMirHeaderText">
|
||||||
|
<h1 class="mapsMirTitle" data-i18n="paths.title">Paths</h1>
|
||||||
|
<p class="mapsMirSubtitle">
|
||||||
|
<span data-i18n="paths.subtitle">View and delete cached routes between positions.</span>
|
||||||
|
<button type="button" class="mapsMirHelpBtn" id="pathsHelpBtn" data-i18n-title="paths.helpTitle" aria-label="Help">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirHeaderActions">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="pathsClearFiltersBtn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||||
|
<span data-i18n="paths.clearFilters">Clear filters</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mapsMirFilterBar">
|
||||||
|
<label class="mapsMirFilterLabel" for="pathsFilterInput" data-i18n="paths.filterLabel">Filter:</label>
|
||||||
|
<input type="search" id="pathsFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="paths.filterPlaceholder" placeholder="Filter by position or map..." autocomplete="off" />
|
||||||
|
<span id="pathsFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
|
||||||
|
<div class="mapsMirPager">
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="pathsPageFirst" aria-label="First page">«</button>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="pathsPagePrev" aria-label="Previous page">‹</button>
|
||||||
|
<span id="pathsPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="pathsPageNext" aria-label="Next page">›</button>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="pathsPageLast" aria-label="Last page">»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mapsMirTableWrap">
|
||||||
|
<table class="mapsMirTable mapsMirTable--paths" id="pathsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="pathsMirThIcon" aria-hidden="true"></th>
|
||||||
|
<th data-i18n="paths.colFrom">From position</th>
|
||||||
|
<th data-i18n="paths.colTo">To position</th>
|
||||||
|
<th data-i18n="paths.colMap">Map</th>
|
||||||
|
<th class="mapsMirThFunctions" data-i18n="paths.colFunctions">Functions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="pathList"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="pathListEmpty" class="mapsMirEmpty" hidden data-i18n="paths.empty">No paths yet. Paths are created automatically when the robot drives between two positions.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="pathDeleteConfirmDialog" class="mapsMirDialog">
|
||||||
|
<div class="mapsMirDialogBody">
|
||||||
|
<h2 class="mapsMirDialogTitle" data-i18n="paths.deleteTitle">Delete path?</h2>
|
||||||
|
<p id="pathDeleteConfirmText" class="mapsMirDialogText"></p>
|
||||||
|
<div class="mapsMirDialogFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="pathDeleteCancelBtn" data-i18n="common.no">No</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="pathDeleteYesBtn" data-i18n="common.yes">Yes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="page" id="pageUsers" data-page-content="users" hidden>
|
<div class="page" id="pageUsers" data-page-content="users" hidden>
|
||||||
<div id="usersListView" class="mapsMirPage">
|
<div id="usersListView" class="mapsMirPage">
|
||||||
<header class="mapsMirHeader">
|
<header class="mapsMirHeader">
|
||||||
@@ -2557,6 +2619,7 @@ GET /api/v2.0.0/status</pre>
|
|||||||
<script src="/users.js"></script>
|
<script src="/users.js"></script>
|
||||||
<script src="/user-groups.js"></script>
|
<script src="/user-groups.js"></script>
|
||||||
<script src="/io-modules.js"></script>
|
<script src="/io-modules.js"></script>
|
||||||
|
<script src="/paths.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>
|
||||||
|
|||||||
@@ -61,6 +61,10 @@
|
|||||||
yamlMeta: null,
|
yamlMeta: null,
|
||||||
/** Pending ROS metadata from upload dialog (set before PNG picker). */
|
/** Pending ROS metadata from upload dialog (set before PNG picker). */
|
||||||
uploadMeta: null,
|
uploadMeta: null,
|
||||||
|
/** Dotted path overlay when opened from Setup → Paths (view). */
|
||||||
|
pathPreview: null,
|
||||||
|
/** Fingerprint of preferred zones when map was loaded (for save warning). */
|
||||||
|
initialPreferredFp: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const titleEl = el("mapEditorTitle");
|
const titleEl = el("mapEditorTitle");
|
||||||
@@ -500,6 +504,46 @@
|
|||||||
return mapMetaForOriginDisplay() || state.map || {};
|
return mapMetaForOriginDisplay() || state.map || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preferredZonesFingerprint(zones) {
|
||||||
|
return zones
|
||||||
|
.filter((z) => z && z.type === "preferred")
|
||||||
|
.map((z) =>
|
||||||
|
JSON.stringify({
|
||||||
|
id: z.id,
|
||||||
|
points: z.points,
|
||||||
|
geometry: z.geometry,
|
||||||
|
line_width_cm: z.line_width_cm,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.sort()
|
||||||
|
.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPathPreview() {
|
||||||
|
if (!objectsSvgEl || !state.pathPreview?.points?.length) return;
|
||||||
|
const geo = Geo();
|
||||||
|
const meta = mapMetaForEditor();
|
||||||
|
const { width, height } = floorPlanSize();
|
||||||
|
if (!geo || !width) return;
|
||||||
|
const pts = state.pathPreview.points
|
||||||
|
.map((p) => {
|
||||||
|
const px = geo.worldToPixel(meta, width, height, p.x, p.y);
|
||||||
|
return `${px.x},${px.y}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
const poly = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
|
||||||
|
poly.setAttribute("points", pts);
|
||||||
|
poly.setAttribute("fill", "none");
|
||||||
|
poly.setAttribute("stroke", "#2563eb");
|
||||||
|
poly.setAttribute("stroke-width", "3");
|
||||||
|
poly.setAttribute("stroke-dasharray", "8 6");
|
||||||
|
poly.setAttribute("stroke-linecap", "round");
|
||||||
|
poly.setAttribute("stroke-linejoin", "round");
|
||||||
|
poly.classList.add("mapEditorPathPreview");
|
||||||
|
poly.setAttribute("pointer-events", "none");
|
||||||
|
objectsSvgEl.appendChild(poly);
|
||||||
|
}
|
||||||
|
|
||||||
function renderObjects() {
|
function renderObjects() {
|
||||||
const obj = Objects();
|
const obj = Objects();
|
||||||
if (!objectsSvgEl || !obj) return;
|
if (!objectsSvgEl || !obj) return;
|
||||||
@@ -530,6 +574,7 @@
|
|||||||
draft: state.draft,
|
draft: state.draft,
|
||||||
selectionRect: state.selectionRect,
|
selectionRect: state.selectionRect,
|
||||||
});
|
});
|
||||||
|
renderPathPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
function imagePointFromEvent(evt) {
|
function imagePointFromEvent(evt) {
|
||||||
@@ -1769,6 +1814,7 @@
|
|||||||
state.map = await api(`/api/maps/${encodeURIComponent(state.mapId)}`);
|
state.map = await api(`/api/maps/${encodeURIComponent(state.mapId)}`);
|
||||||
await loadYamlMeta();
|
await loadYamlMeta();
|
||||||
syncZonesFromMap();
|
syncZonesFromMap();
|
||||||
|
state.initialPreferredFp = preferredZonesFingerprint(state.zones);
|
||||||
updateHeader();
|
updateHeader();
|
||||||
renderMapImage();
|
renderMapImage();
|
||||||
fillSettingsForm();
|
fillSettingsForm();
|
||||||
@@ -1796,6 +1842,8 @@
|
|||||||
state.selectionRect = null;
|
state.selectionRect = null;
|
||||||
state.positionDrag = null;
|
state.positionDrag = null;
|
||||||
state.pendingPosition = null;
|
state.pendingPosition = null;
|
||||||
|
state.pathPreview = callbacks.pathPreview || null;
|
||||||
|
state.initialPreferredFp = "";
|
||||||
updateObjectTypePickerUi();
|
updateObjectTypePickerUi();
|
||||||
state.view = Geo()?.createView(1, 0, 0) || { scale: 1, panX: 0, panY: 0 };
|
state.view = Geo()?.createView(1, 0, 0) || { scale: 1, panX: 0, panY: 0 };
|
||||||
if (tipEl) {
|
if (tipEl) {
|
||||||
@@ -1829,6 +1877,8 @@
|
|||||||
state.selectionRect = null;
|
state.selectionRect = null;
|
||||||
state.positionDrag = null;
|
state.positionDrag = null;
|
||||||
state.pendingPosition = null;
|
state.pendingPosition = null;
|
||||||
|
state.pathPreview = null;
|
||||||
|
state.initialPreferredFp = "";
|
||||||
drawLineDialogEl?.close();
|
drawLineDialogEl?.close();
|
||||||
menuDialogEl?.close();
|
menuDialogEl?.close();
|
||||||
settingsDialogEl?.close();
|
settingsDialogEl?.close();
|
||||||
@@ -2062,6 +2112,19 @@
|
|||||||
if (imageUpdated) state.map = { ...state.map, ...imageUpdated };
|
if (imageUpdated) state.map = { ...state.map, ...imageUpdated };
|
||||||
state.rasterDirty = false;
|
state.rasterDirty = false;
|
||||||
}
|
}
|
||||||
|
const preferredChanged =
|
||||||
|
preferredZonesFingerprint(state.zones) !== state.initialPreferredFp;
|
||||||
|
if (preferredChanged && state.map?.id) {
|
||||||
|
try {
|
||||||
|
const pathsData = await api(
|
||||||
|
`/api/paths?site_id=${encodeURIComponent(state.map.site_id || "")}`,
|
||||||
|
);
|
||||||
|
const onMap = (pathsData.paths || []).filter((p) => p.map_id === state.map.id);
|
||||||
|
if (onMap.length && !confirm(t("maps.editor.preferredPathWarning"))) return;
|
||||||
|
} catch {
|
||||||
|
/* continue save */
|
||||||
|
}
|
||||||
|
}
|
||||||
const updated = await api(`/api/maps/${encodeURIComponent(state.map.id)}`, {
|
const updated = await api(`/api/maps/${encodeURIComponent(state.map.id)}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -2069,6 +2132,7 @@
|
|||||||
});
|
});
|
||||||
state.map = updated;
|
state.map = updated;
|
||||||
syncZonesFromMap();
|
syncZonesFromMap();
|
||||||
|
state.initialPreferredFp = preferredZonesFingerprint(state.zones);
|
||||||
state.callbacks.onMapUpdated?.(updated);
|
state.callbacks.onMapUpdated?.(updated);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
updateHeader();
|
updateHeader();
|
||||||
|
|||||||
24
www/maps.js
24
www/maps.js
@@ -301,6 +301,7 @@
|
|||||||
}
|
}
|
||||||
window.MapEditorApp?.open?.(mapId, {
|
window.MapEditorApp?.open?.(mapId, {
|
||||||
readOnly: opts.readOnly,
|
readOnly: opts.readOnly,
|
||||||
|
pathPreview: opts.pathPreview || null,
|
||||||
onMapUpdated: (updated) => {
|
onMapUpdated: (updated) => {
|
||||||
const idx = store.maps.findIndex((m) => m.id === updated.id);
|
const idx = store.maps.findIndex((m) => m.id === updated.id);
|
||||||
if (idx >= 0) store.maps[idx] = updated;
|
if (idx >= 0) store.maps[idx] = updated;
|
||||||
@@ -326,6 +327,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEditorWithPath(path) {
|
||||||
|
if (!path?.map_id) return;
|
||||||
|
openEditor(path.map_id, {
|
||||||
|
readOnly: !canWrite(),
|
||||||
|
pathPreview: {
|
||||||
|
points: Array.isArray(path.points) ? path.points : [],
|
||||||
|
fromPositionId: path.from_position_id,
|
||||||
|
toPositionId: path.to_position_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function confirmDeleteMap(map) {
|
function confirmDeleteMap(map) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
deleteDialogResolve = resolve;
|
deleteDialogResolve = resolve;
|
||||||
@@ -554,8 +567,16 @@
|
|||||||
Array.isArray(result.maps) ? result.maps.length : 0,
|
Array.isArray(result.maps) ? result.maps.length : 0,
|
||||||
Array.isArray(result.io_modules) ? result.io_modules.length : 0,
|
Array.isArray(result.io_modules) ? result.io_modules.length : 0,
|
||||||
Array.isArray(result.transitions) ? result.transitions.length : 0,
|
Array.isArray(result.transitions) ? result.transitions.length : 0,
|
||||||
|
Array.isArray(result.paths) ? result.paths.length : 0,
|
||||||
];
|
];
|
||||||
alert(t("maps.importSuccess", { maps: counts[0], io: counts[1], transitions: counts[2] }));
|
alert(
|
||||||
|
t("maps.importSuccess", {
|
||||||
|
maps: counts[0],
|
||||||
|
io: counts[1],
|
||||||
|
transitions: counts[2],
|
||||||
|
paths: counts[3],
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message);
|
alert(e.message);
|
||||||
}
|
}
|
||||||
@@ -659,6 +680,7 @@
|
|||||||
getMaps: () => [...store.maps],
|
getMaps: () => [...store.maps],
|
||||||
getMapById: findMap,
|
getMapById: findMap,
|
||||||
activateMap,
|
activateMap,
|
||||||
|
openEditorWithPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
{ section: "transitions", page: "transitions" },
|
{ section: "transitions", page: "transitions" },
|
||||||
{ section: "user-groups", page: "user-groups" },
|
{ section: "user-groups", page: "user-groups" },
|
||||||
{ section: "io-modules", page: "io-modules" },
|
{ section: "io-modules", page: "io-modules" },
|
||||||
|
{ section: "paths", page: "paths" },
|
||||||
{ section: "users", page: "users" },
|
{ section: "users", page: "users" },
|
||||||
{ section: "build-robot", page: "config" },
|
{ section: "build-robot", page: "config" },
|
||||||
],
|
],
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
transitions: { module: "setup", section: "transitions" },
|
transitions: { module: "setup", section: "transitions" },
|
||||||
"user-groups": { module: "setup", section: "user-groups" },
|
"user-groups": { module: "setup", section: "user-groups" },
|
||||||
"io-modules": { module: "setup", section: "io-modules" },
|
"io-modules": { module: "setup", section: "io-modules" },
|
||||||
|
paths: { module: "setup", section: "paths" },
|
||||||
users: { module: "setup", section: "users" },
|
users: { module: "setup", section: "users" },
|
||||||
integrations: { module: "system", section: "integrations" },
|
integrations: { module: "system", section: "integrations" },
|
||||||
monitoring: { module: "monitoring", section: "monitoring-log" },
|
monitoring: { module: "monitoring", section: "monitoring-log" },
|
||||||
|
|||||||
257
www/paths.js
Normal file
257
www/paths.js
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
(() => {
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
path: `<svg class="pathsMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><circle cx="5" cy="11" r="2.5" fill="currentColor"/><circle cx="17" cy="11" r="2.5" fill="currentColor"/><path d="M7.5 11h7" stroke="currentColor" stroke-width="1.5" stroke-dasharray="2 2" stroke-linecap="round"/></svg>`,
|
||||||
|
view: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M1 7s2.5-4 6-4 6 4 6 4-2.5 4-6 4-6-4-6-4z" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="7" cy="7" r="1.8" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>`,
|
||||||
|
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||||
|
|
||||||
|
const listEl = el("pathList");
|
||||||
|
const emptyEl = el("pathListEmpty");
|
||||||
|
const tableEl = el("pathsTable");
|
||||||
|
const filterInputEl = el("pathsFilterInput");
|
||||||
|
const filterCountEl = el("pathsFilterCount");
|
||||||
|
const pageLabelEl = el("pathsPageLabel");
|
||||||
|
const deleteConfirmDialogEl = el("pathDeleteConfirmDialog");
|
||||||
|
const deleteConfirmTextEl = el("pathDeleteConfirmText");
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
paths: [],
|
||||||
|
maps: [],
|
||||||
|
sites: [],
|
||||||
|
pendingDeleteId: null,
|
||||||
|
filter: "",
|
||||||
|
page: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function canWrite() {
|
||||||
|
if (!window.AuthApp?.canWrite) return true;
|
||||||
|
return window.AuthApp.canWrite("maps");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiJson(url, opts = {}) {
|
||||||
|
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) throw new Error((data && data.error) || text || res.statusText);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
const [sitesData, mapsData, pathsData] = await Promise.all([
|
||||||
|
apiJson("/api/sites"),
|
||||||
|
apiJson("/api/maps"),
|
||||||
|
apiJson("/api/paths"),
|
||||||
|
]);
|
||||||
|
store.sites = Array.isArray(sitesData.sites) ? sitesData.sites : [];
|
||||||
|
store.maps = Array.isArray(mapsData.maps) ? mapsData.maps : [];
|
||||||
|
store.paths = Array.isArray(pathsData.paths) ? pathsData.paths : [];
|
||||||
|
return store.paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapName(id) {
|
||||||
|
return store.maps.find((m) => m.id === id)?.name || id || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionLabel(mapId, positionId) {
|
||||||
|
const map = store.maps.find((m) => m.id === mapId);
|
||||||
|
const zones = Array.isArray(map?.zones) ? map.zones : [];
|
||||||
|
const hit = zones.find((z) => z && z.type === "position" && z.id === positionId);
|
||||||
|
const pname = hit?.name || positionId || "—";
|
||||||
|
return `${mapName(mapId)} / ${pname}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredPaths() {
|
||||||
|
const q = store.filter.trim().toLowerCase();
|
||||||
|
let items = [...store.paths];
|
||||||
|
if (q) {
|
||||||
|
items = items.filter((p) => {
|
||||||
|
const from = positionLabel(p.map_id, p.from_position_id).toLowerCase();
|
||||||
|
const to = positionLabel(p.map_id, p.to_position_id).toLowerCase();
|
||||||
|
const map = mapName(p.map_id).toLowerCase();
|
||||||
|
return from.includes(q) || to.includes(q) || map.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
const ma = mapName(a.map_id).localeCompare(mapName(b.map_id));
|
||||||
|
if (ma !== 0) return ma;
|
||||||
|
return positionLabel(a.map_id, a.from_position_id).localeCompare(
|
||||||
|
positionLabel(b.map_id, b.from_position_id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageCount(total) {
|
||||||
|
return Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
if (!listEl) return;
|
||||||
|
const items = filteredPaths();
|
||||||
|
const total = items.length;
|
||||||
|
const pages = pageCount(total);
|
||||||
|
if (store.page > pages) store.page = pages;
|
||||||
|
const start = (store.page - 1) * PAGE_SIZE;
|
||||||
|
const pageItems = items.slice(start, start + PAGE_SIZE);
|
||||||
|
|
||||||
|
if (filterCountEl) filterCountEl.textContent = t("paths.itemsFound", { n: total });
|
||||||
|
if (pageLabelEl) pageLabelEl.textContent = t("paths.pageOf", { page: store.page, total: pages });
|
||||||
|
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
if (tableEl) tableEl.hidden = total === 0;
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.hidden = total > 0;
|
||||||
|
emptyEl.textContent = store.filter ? t("paths.emptyFilter") : t("paths.empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
pageItems.forEach((path) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = "pathsMirRow";
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="pathsMirTdIcon">${ICONS.path}</td>
|
||||||
|
<td>${escapeHtml(positionLabel(path.map_id, path.from_position_id))}</td>
|
||||||
|
<td>${escapeHtml(positionLabel(path.map_id, path.to_position_id))}</td>
|
||||||
|
<td>${escapeHtml(mapName(path.map_id))}</td>
|
||||||
|
<td class="mapsMirTdFunctions">
|
||||||
|
<button type="button" class="mapsMirIconBtn" data-view="${escapeHtml(path.id)}" title="${escapeHtml(t("paths.view"))}">${ICONS.view}</button>
|
||||||
|
${canWrite() ? `<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${escapeHtml(path.id)}" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>` : ""}
|
||||||
|
</td>`;
|
||||||
|
listEl.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.classList.toggle("auth-readonly-paths", !canWrite());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewPath(id) {
|
||||||
|
const path = store.paths.find((p) => p.id === id);
|
||||||
|
if (!path) return;
|
||||||
|
let full = path;
|
||||||
|
try {
|
||||||
|
full = await apiJson(`/api/paths/${encodeURIComponent(id)}`);
|
||||||
|
} catch {
|
||||||
|
/* use list row */
|
||||||
|
}
|
||||||
|
if (window.MapsApp?.openEditorWithPath) {
|
||||||
|
window.NavApp?.selectSection?.("maps", "maps");
|
||||||
|
window.MapsApp.openEditorWithPath(full);
|
||||||
|
} else {
|
||||||
|
alert(t("paths.viewUnavailable"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteConfirm(id) {
|
||||||
|
const path = store.paths.find((p) => p.id === id);
|
||||||
|
if (!path) return;
|
||||||
|
store.pendingDeleteId = id;
|
||||||
|
if (deleteConfirmTextEl) {
|
||||||
|
deleteConfirmTextEl.textContent = t("paths.deleteConfirmText", {
|
||||||
|
from: positionLabel(path.map_id, path.from_position_id),
|
||||||
|
to: positionLabel(path.map_id, path.to_position_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
deleteConfirmDialogEl?.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const id = store.pendingDeleteId;
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
await apiJson(`/api/paths/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
|
store.pendingDeleteId = null;
|
||||||
|
deleteConfirmDialogEl?.close();
|
||||||
|
await refreshAll();
|
||||||
|
renderList();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
listEl?.addEventListener("click", (evt) => {
|
||||||
|
const viewBtn = evt.target.closest("[data-view]");
|
||||||
|
const deleteBtn = evt.target.closest("[data-delete]");
|
||||||
|
if (viewBtn?.dataset.view) void viewPath(viewBtn.dataset.view);
|
||||||
|
else if (deleteBtn?.dataset.delete && canWrite()) openDeleteConfirm(deleteBtn.dataset.delete);
|
||||||
|
});
|
||||||
|
|
||||||
|
filterInputEl?.addEventListener("input", () => {
|
||||||
|
store.filter = filterInputEl.value;
|
||||||
|
store.page = 1;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("pathsClearFiltersBtn")?.addEventListener("click", () => {
|
||||||
|
store.filter = "";
|
||||||
|
store.page = 1;
|
||||||
|
if (filterInputEl) filterInputEl.value = "";
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("pathsPageFirst")?.addEventListener("click", () => {
|
||||||
|
store.page = 1;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("pathsPagePrev")?.addEventListener("click", () => {
|
||||||
|
store.page = Math.max(1, store.page - 1);
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("pathsPageNext")?.addEventListener("click", () => {
|
||||||
|
store.page += 1;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("pathsPageLast")?.addEventListener("click", () => {
|
||||||
|
store.page = pageCount(filteredPaths().length);
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("pathsHelpBtn")?.addEventListener("click", () => alert(t("paths.helpBody")));
|
||||||
|
|
||||||
|
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
store.pendingDeleteId = null;
|
||||||
|
deleteConfirmDialogEl?.close();
|
||||||
|
});
|
||||||
|
el("pathDeleteCancelBtn")?.addEventListener("click", () => {
|
||||||
|
store.pendingDeleteId = null;
|
||||||
|
deleteConfirmDialogEl?.close();
|
||||||
|
});
|
||||||
|
el("pathDeleteYesBtn")?.addEventListener("click", () => confirmDelete().catch((e) => alert(e.message)));
|
||||||
|
|
||||||
|
window.addEventListener("lm:locale-change", () => renderList());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPageShow() {
|
||||||
|
if (!window.AuthApp?.canAccessPage?.("paths")) return;
|
||||||
|
await refreshAll();
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageHide() {}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.PathsApp = { init, onPageShow, onPageHide, refresh: refreshAll };
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.AuthApp?.isReady()) boot();
|
||||||
|
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||||
|
})();
|
||||||
@@ -3935,6 +3935,11 @@ body.auth-readonly-io-modules .ioModulesMirRow .ioModuleDeleteBtn { pointer-even
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapsMirTable--paths thead th.pathsMirThIcon { width: 52px; }
|
||||||
|
.pathsMirIcon { color: var(--mir-accent, #0d9488); }
|
||||||
|
body.auth-readonly-paths .pathsMirRow .mapsMirIconBtn--danger { pointer-events: none; opacity: 0.55; }
|
||||||
|
.mapEditorPathPreview { filter: drop-shadow(0 0 2px rgba(37, 99, 235, 0.45)); }
|
||||||
|
|
||||||
.transMirIcon {
|
.transMirIcon {
|
||||||
color: var(--mir-green, #5cb85c);
|
color: var(--mir-green, #5cb85c);
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
Reference in New Issue
Block a user