This commit is contained in:
BIN
RBS.db-shm
Normal file
BIN
RBS.db-shm
Normal file
Binary file not shown.
BIN
RBS.db-wal
Normal file
BIN
RBS.db-wal
Normal file
Binary file not shown.
BIN
maps/6f761198ec8414ef/map.png
Normal file
BIN
maps/6f761198ec8414ef/map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
6
maps/6f761198ec8414ef/map.yaml
Normal file
6
maps/6f761198ec8414ef/map.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
image: Denso_1.png
|
||||||
|
resolution: 0.050000
|
||||||
|
origin: [-12.238091, -13.200000, 0.0]
|
||||||
|
negate: 0
|
||||||
|
occupied_thresh: 0.65
|
||||||
|
free_thresh: 0.196
|
||||||
BIN
maps/75500f988b7f4f23/map.png
Normal file
BIN
maps/75500f988b7f4f23/map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
6
maps/75500f988b7f4f23/map.yaml
Normal file
6
maps/75500f988b7f4f23/map.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
image: Denso_1.png
|
||||||
|
resolution: 0.050000
|
||||||
|
origin: [-12.238091, -13.200000, 0.0]
|
||||||
|
negate: 0
|
||||||
|
occupied_thresh: 0.65
|
||||||
|
free_thresh: 0.196
|
||||||
@@ -153,6 +153,29 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
|||||||
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
svr.Get(R"(/api/maps/([^/]+)/yaml$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
const auto path = map_store_.yamlPath(id);
|
||||||
|
if (!path)
|
||||||
|
return HttpUtil::jsonError(res, 404, "map yaml not found");
|
||||||
|
res.set_header("Content-Type", "text/yaml; charset=utf-8");
|
||||||
|
res.body = FileUtil::readBinary(*path);
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post(R"(/api/maps/([^/]+)/yaml$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
if (req.body.empty())
|
||||||
|
return HttpUtil::jsonError(res, 400, "yaml body is required");
|
||||||
|
std::string err;
|
||||||
|
if (!map_store_.saveYamlFile(id, req.body, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
const auto updated = map_store_.find(id);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||||
|
});
|
||||||
|
|
||||||
svr.Get("/api/sounds", [this](const httplib::Request&, httplib::Response& res) {
|
svr.Get("/api/sounds", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
HttpUtil::addCors(res);
|
HttpUtil::addCors(res);
|
||||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
|||||||
@@ -307,6 +307,17 @@ std::optional<std::filesystem::path> MapStore::imagePath(const std::string& id)
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::optional<std::filesystem::path> MapStore::yamlPath(const std::string& id) const
|
||||||
|
{
|
||||||
|
const auto map = find(id);
|
||||||
|
if (!map || !(*map)["yaml_file"].is_string())
|
||||||
|
return std::nullopt;
|
||||||
|
const auto path = mapDir(id) / map->value("yaml_file", "");
|
||||||
|
if (!std::filesystem::exists(path))
|
||||||
|
return std::nullopt;
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
bool MapStore::saveImageFile(const std::string& id,
|
bool MapStore::saveImageFile(const std::string& id,
|
||||||
const std::string& filename,
|
const std::string& filename,
|
||||||
const std::string& bytes,
|
const std::string& bytes,
|
||||||
@@ -349,4 +360,44 @@ bool MapStore::saveImageFile(const std::string& id,
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool MapStore::saveYamlFile(const std::string& id, const std::string& yaml_text, std::string& err)
|
||||||
|
{
|
||||||
|
if (!find(id))
|
||||||
|
{
|
||||||
|
err = "map not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr const char* kYamlName = "map.yaml";
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::create_directories(mapDir(id), ec);
|
||||||
|
const auto path = mapDir(id) / kYamlName;
|
||||||
|
if (!FileUtil::writeBinaryAtomic(path, yaml_text))
|
||||||
|
{
|
||||||
|
err = "failed to write yaml file";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string now = IdUtil::nowIso8601();
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"UPDATE maps SET yaml_file = ?2, updated_at = ?3 WHERE id = ?1",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 2, kYamlName, -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||||
|
if (!ok)
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace lm
|
} // namespace lm
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ public:
|
|||||||
|
|
||||||
std::filesystem::path mapDir(const std::string& id) const;
|
std::filesystem::path mapDir(const std::string& id) const;
|
||||||
std::optional<std::filesystem::path> imagePath(const std::string& id) const;
|
std::optional<std::filesystem::path> imagePath(const std::string& id) const;
|
||||||
|
std::optional<std::filesystem::path> yamlPath(const std::string& id) const;
|
||||||
bool saveImageFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
|
bool saveImageFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
|
||||||
|
bool saveYamlFile(const std::string& id, const std::string& yaml_text, std::string& err);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Database& db_;
|
Database& db_;
|
||||||
|
|||||||
1724
src/third_party/stb_image_write.h
vendored
Normal file
1724
src/third_party/stb_image_write.h
vendored
Normal file
File diff suppressed because it is too large
Load Diff
212
src/util/map_image_util.cpp
Normal file
212
src/util/map_image_util.cpp
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
#include "util/map_image_util.hpp"
|
||||||
|
|
||||||
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
#define STB_IMAGE_WRITE_IMPLEMENTATION
|
||||||
|
#define STB_IMAGE_WRITE_STATIC
|
||||||
|
#include "third_party/stb_image_write.h"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string lowerExt(const std::string& filename)
|
||||||
|
{
|
||||||
|
const auto dot = filename.rfind('.');
|
||||||
|
if (dot == std::string::npos)
|
||||||
|
return "";
|
||||||
|
std::string ext = filename.substr(dot);
|
||||||
|
return StringUtil::toLower(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPngExtension(const std::string& ext)
|
||||||
|
{
|
||||||
|
return ext == ".png";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPgmExtension(const std::string& ext)
|
||||||
|
{
|
||||||
|
return ext == ".pgm";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool pngMagic(const std::string& bytes)
|
||||||
|
{
|
||||||
|
static const unsigned char kSig[] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||||
|
if (bytes.size() < 8)
|
||||||
|
return false;
|
||||||
|
return std::equal(std::begin(kSig), std::end(kSig), bytes.begin());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool pgmMagic(const std::string& bytes)
|
||||||
|
{
|
||||||
|
return bytes.size() >= 2 && bytes[0] == 'P' && (bytes[1] == '5' || bytes[1] == '2');
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyNegate(std::vector<uint8_t>& gray)
|
||||||
|
{
|
||||||
|
for (auto& px : gray)
|
||||||
|
px = static_cast<uint8_t>(255 - px);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool MapImageUtil::decodePgm(const std::string& bytes, std::vector<uint8_t>& gray, ImageSize& size, std::string& err)
|
||||||
|
{
|
||||||
|
if (bytes.size() < 4 || bytes[0] != 'P')
|
||||||
|
{
|
||||||
|
err = "not a PGM image";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (bytes[1] != '5')
|
||||||
|
{
|
||||||
|
err = "only binary PGM (P5) is supported";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t i = 2;
|
||||||
|
auto skipSpace = [&]() {
|
||||||
|
while (i < bytes.size() && (bytes[i] == ' ' || bytes[i] == '\t' || bytes[i] == '\r' || bytes[i] == '\n'))
|
||||||
|
++i;
|
||||||
|
};
|
||||||
|
auto readInt = [&](int& out) -> bool {
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
skipSpace();
|
||||||
|
if (i < bytes.size() && bytes[i] == '#')
|
||||||
|
{
|
||||||
|
while (i < bytes.size() && bytes[i] != '\n')
|
||||||
|
++i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (i >= bytes.size() || !std::isdigit(static_cast<unsigned char>(bytes[i])))
|
||||||
|
return false;
|
||||||
|
out = 0;
|
||||||
|
while (i < bytes.size() && std::isdigit(static_cast<unsigned char>(bytes[i])))
|
||||||
|
{
|
||||||
|
out = out * 10 + (bytes[i] - '0');
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
skipSpace();
|
||||||
|
if (!readInt(size.width) || !readInt(size.height))
|
||||||
|
{
|
||||||
|
err = "invalid PGM header";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int maxval = 0;
|
||||||
|
if (!readInt(maxval))
|
||||||
|
{
|
||||||
|
err = "invalid PGM header";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (size.width <= 0 || size.height <= 0 || maxval <= 0 || maxval > 65535)
|
||||||
|
{
|
||||||
|
err = "invalid PGM dimensions";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < bytes.size() && bytes[i] == '\r')
|
||||||
|
++i;
|
||||||
|
if (i < bytes.size() && bytes[i] == '\n')
|
||||||
|
++i;
|
||||||
|
|
||||||
|
const std::size_t data_size = static_cast<std::size_t>(size.width) * static_cast<std::size_t>(size.height);
|
||||||
|
if (bytes.size() - i < data_size)
|
||||||
|
{
|
||||||
|
err = "PGM data truncated";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
gray.assign(bytes.begin() + static_cast<std::ptrdiff_t>(i),
|
||||||
|
bytes.begin() + static_cast<std::ptrdiff_t>(i + data_size));
|
||||||
|
if (maxval != 255)
|
||||||
|
{
|
||||||
|
const double scale = 255.0 / static_cast<double>(maxval);
|
||||||
|
for (auto& px : gray)
|
||||||
|
px = static_cast<uint8_t>(std::min(255.0, px * scale));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MapImageUtil::pngDimensions(const std::string& bytes, ImageSize& size, std::string& err)
|
||||||
|
{
|
||||||
|
if (!pngMagic(bytes))
|
||||||
|
{
|
||||||
|
err = "not a PNG image";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (bytes.size() < 24)
|
||||||
|
{
|
||||||
|
err = "PNG header truncated";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
auto readU32 = [&](std::size_t pos) -> int {
|
||||||
|
return (static_cast<unsigned char>(bytes[pos]) << 24) | (static_cast<unsigned char>(bytes[pos + 1]) << 16) |
|
||||||
|
(static_cast<unsigned char>(bytes[pos + 2]) << 8) | static_cast<unsigned char>(bytes[pos + 3]);
|
||||||
|
};
|
||||||
|
size.width = readU32(16);
|
||||||
|
size.height = readU32(20);
|
||||||
|
if (size.width <= 0 || size.height <= 0)
|
||||||
|
{
|
||||||
|
err = "invalid PNG dimensions";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MapImageUtil::toPngBytes(const std::string& bytes,
|
||||||
|
const std::string& filename_hint,
|
||||||
|
int negate,
|
||||||
|
std::string& png_out,
|
||||||
|
ImageSize& size,
|
||||||
|
std::string& err)
|
||||||
|
{
|
||||||
|
const std::string ext = lowerExt(filename_hint);
|
||||||
|
const bool as_png = isPngExtension(ext) || pngMagic(bytes);
|
||||||
|
const bool as_pgm = isPgmExtension(ext) || (!as_png && pgmMagic(bytes));
|
||||||
|
|
||||||
|
if (as_png)
|
||||||
|
{
|
||||||
|
if (!pngDimensions(bytes, size, err))
|
||||||
|
return false;
|
||||||
|
if (negate == 0)
|
||||||
|
{
|
||||||
|
png_out = bytes;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
err = "PNG negate not supported";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!as_pgm)
|
||||||
|
{
|
||||||
|
err = "image must be PNG or PGM";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<uint8_t> gray;
|
||||||
|
if (!decodePgm(bytes, gray, size, err))
|
||||||
|
return false;
|
||||||
|
if (negate != 0)
|
||||||
|
applyNegate(gray);
|
||||||
|
|
||||||
|
int out_len = 0;
|
||||||
|
unsigned char* png_mem = stbi_write_png_to_mem(gray.data(), size.width, size.width, size.height, 1, &out_len);
|
||||||
|
if (!png_mem || out_len <= 0)
|
||||||
|
{
|
||||||
|
err = "failed to encode PNG";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
png_out.assign(reinterpret_cast<char*>(png_mem), static_cast<std::size_t>(out_len));
|
||||||
|
STBIW_FREE(png_mem);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
36
src/util/map_image_util.hpp
Normal file
36
src/util/map_image_util.hpp
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
struct ImageSize
|
||||||
|
{
|
||||||
|
int width = 0;
|
||||||
|
int height = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MapImageUtil
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
/** Decode P5 PGM (grayscale). */
|
||||||
|
static bool decodePgm(const std::string& bytes, std::vector<uint8_t>& gray, ImageSize& size, std::string& err);
|
||||||
|
|
||||||
|
/** Read PNG width/height from IHDR without full decode. */
|
||||||
|
static bool pngDimensions(const std::string& bytes, ImageSize& size, std::string& err);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize map image to PNG bytes (always output filename map.png).
|
||||||
|
* Supports PNG passthrough or PGM conversion; optional negate (ROS map_server).
|
||||||
|
*/
|
||||||
|
static bool toPngBytes(const std::string& bytes,
|
||||||
|
const std::string& filename_hint,
|
||||||
|
int negate,
|
||||||
|
std::string& png_out,
|
||||||
|
ImageSize& size,
|
||||||
|
std::string& err);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
166
src/util/ros_map_yaml.cpp
Normal file
166
src/util/ros_map_yaml.cpp
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
#include "util/ros_map_yaml.hpp"
|
||||||
|
|
||||||
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string stripComment(const std::string& line)
|
||||||
|
{
|
||||||
|
const auto pos = line.find('#');
|
||||||
|
if (pos == std::string::npos)
|
||||||
|
return line;
|
||||||
|
return line.substr(0, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string trim(const std::string& s)
|
||||||
|
{
|
||||||
|
return StringUtil::trimCopy(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parseOriginArray(const std::string& value, RosMapYaml& out)
|
||||||
|
{
|
||||||
|
const auto start = value.find('[');
|
||||||
|
const auto end = value.find(']');
|
||||||
|
if (start == std::string::npos || end == std::string::npos || end <= start)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
std::string inner = value.substr(start + 1, end - start - 1);
|
||||||
|
for (char& c : inner)
|
||||||
|
{
|
||||||
|
if (c == ',')
|
||||||
|
c = ' ';
|
||||||
|
}
|
||||||
|
std::istringstream iss(inner);
|
||||||
|
double x = 0, y = 0, yaw = 0;
|
||||||
|
if (!(iss >> x >> y))
|
||||||
|
return false;
|
||||||
|
iss >> yaw;
|
||||||
|
out.origin_x = x;
|
||||||
|
out.origin_y = y;
|
||||||
|
out.origin_yaw = yaw;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
double parseDouble(const std::string& value, bool& ok)
|
||||||
|
{
|
||||||
|
ok = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
size_t idx = 0;
|
||||||
|
const double v = std::stod(value, &idx);
|
||||||
|
if (idx > 0)
|
||||||
|
ok = true;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int parseInt(const std::string& value, bool& ok)
|
||||||
|
{
|
||||||
|
ok = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
size_t idx = 0;
|
||||||
|
const int v = std::stoi(value, &idx);
|
||||||
|
if (idx > 0)
|
||||||
|
ok = true;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::optional<RosMapYaml> RosMapYamlParser::parse(const std::string& yaml_text, std::string& err)
|
||||||
|
{
|
||||||
|
RosMapYaml out;
|
||||||
|
std::istringstream stream(yaml_text);
|
||||||
|
std::string line;
|
||||||
|
bool has_resolution = false;
|
||||||
|
|
||||||
|
while (std::getline(stream, line))
|
||||||
|
{
|
||||||
|
line = trim(stripComment(line));
|
||||||
|
if (line.empty())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const auto colon = line.find(':');
|
||||||
|
if (colon == std::string::npos)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const std::string key = trim(line.substr(0, colon));
|
||||||
|
const std::string value = trim(line.substr(colon + 1));
|
||||||
|
if (value.empty() && key != "image")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (key == "image")
|
||||||
|
{
|
||||||
|
out.image = value;
|
||||||
|
}
|
||||||
|
else if (key == "resolution")
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
out.resolution = parseDouble(value, ok);
|
||||||
|
if (!ok || out.resolution <= 0.0)
|
||||||
|
{
|
||||||
|
err = "invalid resolution in yaml";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
has_resolution = true;
|
||||||
|
}
|
||||||
|
else if (key == "origin")
|
||||||
|
{
|
||||||
|
if (!parseOriginArray(value, out))
|
||||||
|
{
|
||||||
|
err = "invalid origin in yaml";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (key == "negate")
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
out.negate = parseInt(value, ok);
|
||||||
|
if (!ok)
|
||||||
|
out.negate = 0;
|
||||||
|
}
|
||||||
|
else if (key == "occupied_thresh")
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
out.occupied_thresh = parseDouble(value, ok);
|
||||||
|
if (!ok)
|
||||||
|
out.occupied_thresh = 0.65;
|
||||||
|
}
|
||||||
|
else if (key == "free_thresh")
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
out.free_thresh = parseDouble(value, ok);
|
||||||
|
if (!ok)
|
||||||
|
out.free_thresh = 0.196;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (out.image.empty())
|
||||||
|
{
|
||||||
|
err = "yaml missing image field";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (!has_resolution)
|
||||||
|
{
|
||||||
|
err = "yaml missing resolution field";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
26
src/util/ros_map_yaml.hpp
Normal file
26
src/util/ros_map_yaml.hpp
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
struct RosMapYaml
|
||||||
|
{
|
||||||
|
std::string image;
|
||||||
|
double resolution = 0.05;
|
||||||
|
double origin_x = 0.0;
|
||||||
|
double origin_y = 0.0;
|
||||||
|
double origin_yaw = 0.0;
|
||||||
|
int negate = 0;
|
||||||
|
double occupied_thresh = 0.65;
|
||||||
|
double free_thresh = 0.196;
|
||||||
|
};
|
||||||
|
|
||||||
|
class RosMapYamlParser
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static std::optional<RosMapYaml> parse(const std::string& yaml_text, std::string& err);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
46
www/i18n.js
46
www/i18n.js
@@ -358,11 +358,25 @@
|
|||||||
"maps.settings.originX": "Origin X",
|
"maps.settings.originX": "Origin X",
|
||||||
"maps.settings.originY": "Origin Y",
|
"maps.settings.originY": "Origin Y",
|
||||||
"maps.settings.originYaw": "Origin yaw",
|
"maps.settings.originYaw": "Origin yaw",
|
||||||
|
"maps.editor.originLabelShort": "Gốc ({x}, {y})",
|
||||||
|
"maps.editor.originTooltip": "Origin map: X={x} m, Y={y} m, yaw={yaw}°",
|
||||||
|
"maps.uploadConfirm.title": "Ghi đè floor plan?",
|
||||||
|
"maps.uploadConfirm.text": "Ảnh map hiện tại sẽ bị thay thế. Tiếp tục?",
|
||||||
|
"maps.uploadConfirm.yes": "Ghi đè",
|
||||||
|
"maps.uploadMeta.title": "Metadata map (ROS)",
|
||||||
|
"maps.uploadMeta.hint": "Nhập origin, resolution và ngưỡng occupancy — hoặc import file .yaml.",
|
||||||
|
"maps.uploadMeta.importYaml": "Import file YAML…",
|
||||||
|
"maps.uploadMeta.negate": "Negate",
|
||||||
|
"maps.uploadMeta.occupiedThresh": "Occupied thresh",
|
||||||
|
"maps.uploadMeta.freeThresh": "Free thresh",
|
||||||
|
"maps.uploadMeta.continue": "Tiếp tục — chọn PNG",
|
||||||
|
"maps.uploadMeta.invalidResolution": "Resolution phải lớn hơn 0.",
|
||||||
|
"maps.uploadMeta.invalidYaml": "Không đọc được file YAML.",
|
||||||
"maps.editor.back": "Maps",
|
"maps.editor.back": "Maps",
|
||||||
"maps.editor.goBack": "Quay lại",
|
"maps.editor.goBack": "Quay lại",
|
||||||
"maps.editor.subtitle": "Chỉnh sửa và vẽ map.",
|
"maps.editor.subtitle": "Chỉnh sửa và vẽ map.",
|
||||||
"maps.editor.helpTitle": "Trợ giúp map editor",
|
"maps.editor.helpTitle": "Trợ giúp map editor",
|
||||||
"maps.editor.helpText": "Dùng công cụ Pan để kéo map, zoom bằng nút +/- hoặc con lăn chuột. Menu ⋮ để upload/lưu map.",
|
"maps.editor.helpText": "Ba lớp: View (pan/zoom màn hình) → Image (pixel floor plan, 20 px/m) → World (X,Y mét). Dùng Pan, zoom, Fit; di chuột để xem tọa độ.",
|
||||||
"maps.editor.toolbarAria": "Mapping tools",
|
"maps.editor.toolbarAria": "Mapping tools",
|
||||||
"maps.editor.canvasTip": "Kéo map để di chuyển vùng nhìn hoặc dùng nút zoom in/out để phóng to/thu nhỏ.",
|
"maps.editor.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",
|
||||||
@@ -381,7 +395,12 @@
|
|||||||
"maps.editor.fit": "Vừa khung",
|
"maps.editor.fit": "Vừa khung",
|
||||||
"maps.editor.zoomIn": "Phóng to",
|
"maps.editor.zoomIn": "Phóng to",
|
||||||
"maps.editor.zoomOut": "Thu nhỏ",
|
"maps.editor.zoomOut": "Thu nhỏ",
|
||||||
"maps.editor.noData": "Chưa có dữ liệu map — mở menu ⋮ để upload PNG.",
|
"maps.editor.noData": "Chưa có floor plan — menu ⋮ để upload PNG.",
|
||||||
|
"maps.editor.statusView": "zoom {zoom}% · pan ({panX}, {panY})",
|
||||||
|
"maps.editor.statusImageIdle": "— px (di chuột trên map)",
|
||||||
|
"maps.editor.statusImage": "({px}, {py}) px",
|
||||||
|
"maps.editor.statusWorldIdle": "— m",
|
||||||
|
"maps.editor.statusWorld": "X {x}, Y {y} m",
|
||||||
"maps.editor.objectTypesNone": "Chưa chọn object-type",
|
"maps.editor.objectTypesNone": "Chưa chọn object-type",
|
||||||
"maps.menu.save": "Lưu map",
|
"maps.menu.save": "Lưu map",
|
||||||
|
|
||||||
@@ -858,11 +877,25 @@
|
|||||||
"maps.settings.originX": "Origin X",
|
"maps.settings.originX": "Origin X",
|
||||||
"maps.settings.originY": "Origin Y",
|
"maps.settings.originY": "Origin Y",
|
||||||
"maps.settings.originYaw": "Origin yaw",
|
"maps.settings.originYaw": "Origin yaw",
|
||||||
|
"maps.editor.originLabelShort": "Origin ({x}, {y})",
|
||||||
|
"maps.editor.originTooltip": "Map origin: X={x} m, Y={y} m, yaw={yaw}°",
|
||||||
|
"maps.uploadConfirm.title": "Overwrite floor plan?",
|
||||||
|
"maps.uploadConfirm.text": "The current map image will be replaced. Continue?",
|
||||||
|
"maps.uploadConfirm.yes": "Overwrite",
|
||||||
|
"maps.uploadMeta.title": "Map metadata (ROS)",
|
||||||
|
"maps.uploadMeta.hint": "Enter origin, resolution, and occupancy thresholds — or import a .yaml file.",
|
||||||
|
"maps.uploadMeta.importYaml": "Import YAML file…",
|
||||||
|
"maps.uploadMeta.negate": "Negate",
|
||||||
|
"maps.uploadMeta.occupiedThresh": "Occupied thresh",
|
||||||
|
"maps.uploadMeta.freeThresh": "Free thresh",
|
||||||
|
"maps.uploadMeta.continue": "Continue — choose PNG",
|
||||||
|
"maps.uploadMeta.invalidResolution": "Resolution must be greater than 0.",
|
||||||
|
"maps.uploadMeta.invalidYaml": "Could not read YAML file.",
|
||||||
"maps.editor.back": "Maps",
|
"maps.editor.back": "Maps",
|
||||||
"maps.editor.goBack": "Go back",
|
"maps.editor.goBack": "Go back",
|
||||||
"maps.editor.subtitle": "Edit and draw the map.",
|
"maps.editor.subtitle": "Edit and draw the map.",
|
||||||
"maps.editor.helpTitle": "Map editor help",
|
"maps.editor.helpTitle": "Map editor help",
|
||||||
"maps.editor.helpText": "Use the Pan tool to drag the map, zoom with +/- buttons or the mouse wheel. Open ⋮ menu to upload or save the map.",
|
"maps.editor.helpText": "Three layers: View (screen pan/zoom) → Image (floor plan pixels, 20 px/m) → World (X,Y in metres). Use Pan, zoom, Fit; hover for coordinates.",
|
||||||
"maps.editor.toolbarAria": "Mapping tools",
|
"maps.editor.toolbarAria": "Mapping tools",
|
||||||
"maps.editor.canvasTip": "Drag the map to move your view or use the zoom-in and -out buttons to zoom.",
|
"maps.editor.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",
|
||||||
@@ -881,7 +914,12 @@
|
|||||||
"maps.editor.fit": "Fit to view",
|
"maps.editor.fit": "Fit to view",
|
||||||
"maps.editor.zoomIn": "Zoom in",
|
"maps.editor.zoomIn": "Zoom in",
|
||||||
"maps.editor.zoomOut": "Zoom out",
|
"maps.editor.zoomOut": "Zoom out",
|
||||||
"maps.editor.noData": "No map data — open ⋮ menu to upload a PNG.",
|
"maps.editor.noData": "No floor plan — ⋮ menu to upload PNG.",
|
||||||
|
"maps.editor.statusView": "zoom {zoom}% · pan ({panX}, {panY})",
|
||||||
|
"maps.editor.statusImageIdle": "— px (hover map)",
|
||||||
|
"maps.editor.statusImage": "({px}, {py}) px",
|
||||||
|
"maps.editor.statusWorldIdle": "— m",
|
||||||
|
"maps.editor.statusWorld": "X {x}, Y {y} m",
|
||||||
"maps.editor.objectTypesNone": "No object-type selected",
|
"maps.editor.objectTypesNone": "No object-type selected",
|
||||||
"maps.menu.save": "Save map",
|
"maps.menu.save": "Save map",
|
||||||
|
|
||||||
|
|||||||
105
www/index.html
105
www/index.html
@@ -1038,14 +1038,31 @@
|
|||||||
|
|
||||||
<div class="mapEditorCanvasWrap" id="mapEditorCanvasWrap">
|
<div class="mapEditorCanvasWrap" id="mapEditorCanvasWrap">
|
||||||
<div class="mapEditorCanvasTip" id="mapEditorCanvasTip" role="status" data-i18n="maps.editor.canvasTip">Drag the map to move your view or use the zoom-in and -out buttons to zoom.</div>
|
<div class="mapEditorCanvasTip" id="mapEditorCanvasTip" role="status" data-i18n="maps.editor.canvasTip">Drag the map to move your view or use the zoom-in and -out buttons to zoom.</div>
|
||||||
<div class="mapEditorViewport">
|
<!-- Layer 1: View space — pan/zoom on canvasInner only -->
|
||||||
|
<div class="mapEditorViewport" id="mapEditorViewport">
|
||||||
<div class="mapEditorCanvasInner" id="mapEditorCanvasInner">
|
<div class="mapEditorCanvasInner" id="mapEditorCanvasInner">
|
||||||
<div class="mapEditorSheet" id="mapEditorSheet">
|
<!-- Layer 2: Image space — floor plan pixels 1:1 (20 px/m at res 0.05) -->
|
||||||
<img id="mapEditorImage" class="mapEditorImage" alt="" hidden />
|
<div class="mapEditorImageLayer" id="mapEditorImageLayer">
|
||||||
<div id="mapEditorEmpty" class="mapEditorEmpty" hidden data-i18n="maps.editor.noData">No map data — open ⋮ menu to upload a PNG.</div>
|
<div class="mapEditorSheet" id="mapEditorSheet">
|
||||||
|
<div class="mapEditorSheetGrid" id="mapEditorSheetGrid" aria-hidden="true"></div>
|
||||||
|
<img id="mapEditorImage" class="mapEditorImage" alt="" draggable="false" hidden />
|
||||||
|
<div id="mapEditorOrigin" class="mapEditorOrigin" hidden aria-hidden="true">
|
||||||
|
<span class="mapEditorOriginAxis mapEditorOriginAxis--x" aria-hidden="true"></span>
|
||||||
|
<span class="mapEditorOriginAxis mapEditorOriginAxis--y" aria-hidden="true"></span>
|
||||||
|
<span class="mapEditorOriginDot" aria-hidden="true"></span>
|
||||||
|
<span class="mapEditorOriginLabel" id="mapEditorOriginLabel"></span>
|
||||||
|
</div>
|
||||||
|
<div id="mapEditorEmpty" class="mapEditorEmpty" hidden data-i18n="maps.editor.noData">No map data — open ⋮ menu to upload a PNG.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Layer readout: view | image px | world m -->
|
||||||
|
<div class="mapEditorStatusBar" id="mapEditorStatusBar" aria-live="polite">
|
||||||
|
<span class="mapEditorStatusItem mapEditorStatusItem--view" id="mapEditorStatusView">View —</span>
|
||||||
|
<span class="mapEditorStatusItem mapEditorStatusItem--image" id="mapEditorStatusImage">Image —</span>
|
||||||
|
<span class="mapEditorStatusItem mapEditorStatusItem--world" id="mapEditorStatusWorld">World —</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1152,21 +1169,21 @@
|
|||||||
<div class="mapsMirFieldRow">
|
<div class="mapsMirFieldRow">
|
||||||
<label class="mapsMirField">
|
<label class="mapsMirField">
|
||||||
<span class="mapsMirFieldLabel" data-i18n="maps.settings.resolution">Resolution (m/px)</span>
|
<span class="mapsMirFieldLabel" data-i18n="maps.settings.resolution">Resolution (m/px)</span>
|
||||||
<input type="number" id="mapSettingsResolution" step="0.001" min="0.001" />
|
<input type="number" id="mapSettingsResolution" step="any" min="0.001" />
|
||||||
</label>
|
</label>
|
||||||
<label class="mapsMirField">
|
<label class="mapsMirField">
|
||||||
<span class="mapsMirFieldLabel" data-i18n="maps.settings.originX">Origin X</span>
|
<span class="mapsMirFieldLabel" data-i18n="maps.settings.originX">Origin X</span>
|
||||||
<input type="number" id="mapSettingsOriginX" step="0.01" />
|
<input type="number" id="mapSettingsOriginX" step="any" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mapsMirFieldRow">
|
<div class="mapsMirFieldRow">
|
||||||
<label class="mapsMirField">
|
<label class="mapsMirField">
|
||||||
<span class="mapsMirFieldLabel" data-i18n="maps.settings.originY">Origin Y</span>
|
<span class="mapsMirFieldLabel" data-i18n="maps.settings.originY">Origin Y</span>
|
||||||
<input type="number" id="mapSettingsOriginY" step="0.01" />
|
<input type="number" id="mapSettingsOriginY" step="any" />
|
||||||
</label>
|
</label>
|
||||||
<label class="mapsMirField">
|
<label class="mapsMirField">
|
||||||
<span class="mapsMirFieldLabel" data-i18n="maps.settings.originYaw">Origin yaw</span>
|
<span class="mapsMirFieldLabel" data-i18n="maps.settings.originYaw">Origin yaw</span>
|
||||||
<input type="number" id="mapSettingsOriginYaw" step="0.01" />
|
<input type="number" id="mapSettingsOriginYaw" step="any" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mapsMirDialogFooter">
|
<div class="mapsMirDialogFooter">
|
||||||
@@ -1176,12 +1193,72 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="mapUploadConfirmDialog" class="mapsMirDialog">
|
||||||
|
<div class="mapsMirDialogPanel">
|
||||||
|
<h2 class="mapsMirDialogTitle" data-i18n="maps.uploadConfirm.title">Overwrite map?</h2>
|
||||||
|
<p id="mapUploadConfirmText" class="mapsMirDialogText"></p>
|
||||||
|
<div class="mapsMirDialogFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="mapUploadConfirmNoBtn" data-i18n="common.no">No</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="mapUploadConfirmYesBtn" data-i18n="maps.uploadConfirm.yes">Overwrite</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="mapUploadMetaDialog" class="mapsMirDialog">
|
||||||
|
<form id="mapUploadMetaForm" method="dialog">
|
||||||
|
<h2 class="mapsMirDialogTitle" data-i18n="maps.uploadMeta.title">Map metadata</h2>
|
||||||
|
<p class="mapsMirDialogText" data-i18n="maps.uploadMeta.hint">Enter ROS map_server parameters or import a .yaml file.</p>
|
||||||
|
<button type="button" class="mapsMirLinkBtn" id="mapUploadImportYamlBtn" data-i18n="maps.uploadMeta.importYaml">Import YAML file…</button>
|
||||||
|
<input type="file" id="mapUploadYamlInput" accept=".yaml,.yml,text/yaml" hidden />
|
||||||
|
<div class="mapsMirFieldRow">
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.settings.resolution">Resolution (m/px)</span>
|
||||||
|
<input type="number" id="mapUploadResolution" step="any" min="0.001" required />
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.uploadMeta.negate">Negate</span>
|
||||||
|
<input type="number" id="mapUploadNegate" step="1" min="0" max="1" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirFieldRow">
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.settings.originX">Origin X</span>
|
||||||
|
<input type="number" id="mapUploadOriginX" step="any" required />
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.settings.originY">Origin Y</span>
|
||||||
|
<input type="number" id="mapUploadOriginY" step="any" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirFieldRow">
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.settings.originYaw">Origin yaw</span>
|
||||||
|
<input type="number" id="mapUploadOriginYaw" step="any" />
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.uploadMeta.occupiedThresh">Occupied thresh</span>
|
||||||
|
<input type="number" id="mapUploadOccupiedThresh" step="any" min="0" max="1" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="maps.uploadMeta.freeThresh">Free thresh</span>
|
||||||
|
<input type="number" id="mapUploadFreeThresh" step="any" min="0" max="1" />
|
||||||
|
</label>
|
||||||
|
<div class="mapsMirDialogFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="mapUploadMetaCancelBtn" data-i18n="common.cancel">Cancel</button>
|
||||||
|
<button type="submit" class="mapsMirBtn mapsMirBtn--green" data-i18n="maps.uploadMeta.continue">Continue</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="mapActivateDialog" class="mapsMirDialog">
|
<dialog id="mapActivateDialog" class="mapsMirDialog">
|
||||||
<h2 class="mapsMirDialogTitle" data-i18n="maps.activateDialog.title">Activate map?</h2>
|
<div class="mapsMirDialogPanel">
|
||||||
<p id="mapActivateDialogText" class="mapsMirDialogText"></p>
|
<h2 class="mapsMirDialogTitle" data-i18n="maps.activateDialog.title">Activate map?</h2>
|
||||||
<div class="mapsMirDialogFooter">
|
<p id="mapActivateDialogText" class="mapsMirDialogText"></p>
|
||||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="mapActivateNoBtn" data-i18n="common.no">No</button>
|
<div class="mapsMirDialogFooter">
|
||||||
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="mapActivateYesBtn" data-i18n="common.yes">Yes</button>
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="mapActivateNoBtn" data-i18n="common.no">No</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="mapActivateYesBtn" data-i18n="common.yes">Yes</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
@@ -1645,6 +1722,8 @@ GET /api/v2.0.0/status</pre>
|
|||||||
<script src="/auth.js"></script>
|
<script src="/auth.js"></script>
|
||||||
<script src="/nav.js"></script>
|
<script src="/nav.js"></script>
|
||||||
<script src="/missions.js"></script>
|
<script src="/missions.js"></script>
|
||||||
|
<script src="/map-geo.js"></script>
|
||||||
|
<script src="/map-yaml.js"></script>
|
||||||
<script src="/maps.js"></script>
|
<script src="/maps.js"></script>
|
||||||
<script src="/map-editor.js"></script>
|
<script src="/map-editor.js"></script>
|
||||||
<script src="/topbar.js"></script>
|
<script src="/topbar.js"></script>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const el = (id) => document.getElementById(id);
|
const el = (id) => document.getElementById(id);
|
||||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||||
|
const Geo = () => window.MapGeo;
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
mapId: null,
|
mapId: null,
|
||||||
@@ -9,23 +10,46 @@
|
|||||||
readOnly: false,
|
readOnly: false,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
activeTool: "pan",
|
activeTool: "pan",
|
||||||
view: { scale: 1, panX: 0, panY: 0 },
|
/** Layer 1 — view space (screen pan/zoom only). */
|
||||||
|
view: Geo()?.createView(1, 0, 0) || { scale: 1, panX: 0, panY: 0 },
|
||||||
panning: null,
|
panning: null,
|
||||||
tipVisible: true,
|
tipVisible: true,
|
||||||
|
/** Pending ROS metadata from upload dialog (set before PNG picker). */
|
||||||
|
uploadMeta: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const titleEl = el("mapEditorTitle");
|
const titleEl = el("mapEditorTitle");
|
||||||
const dirtyEl = el("mapEditorDirty");
|
const dirtyEl = el("mapEditorDirty");
|
||||||
const canvasWrapEl = el("mapEditorCanvasWrap");
|
const canvasWrapEl = el("mapEditorCanvasWrap");
|
||||||
|
const viewportEl = el("mapEditorViewport");
|
||||||
const canvasInnerEl = el("mapEditorCanvasInner");
|
const canvasInnerEl = el("mapEditorCanvasInner");
|
||||||
|
const imageLayerEl = el("mapEditorImageLayer");
|
||||||
const sheetEl = el("mapEditorSheet");
|
const sheetEl = el("mapEditorSheet");
|
||||||
|
const gridEl = el("mapEditorSheetGrid");
|
||||||
const imageEl = el("mapEditorImage");
|
const imageEl = el("mapEditorImage");
|
||||||
|
const originEl = el("mapEditorOrigin");
|
||||||
const emptyEl = el("mapEditorEmpty");
|
const emptyEl = el("mapEditorEmpty");
|
||||||
const tipEl = el("mapEditorCanvasTip");
|
const tipEl = el("mapEditorCanvasTip");
|
||||||
|
const statusViewEl = el("mapEditorStatusView");
|
||||||
|
const statusImageEl = el("mapEditorStatusImage");
|
||||||
|
const statusWorldEl = el("mapEditorStatusWorld");
|
||||||
const uploadInputEl = el("mapEditorUploadInput");
|
const uploadInputEl = el("mapEditorUploadInput");
|
||||||
const menuDialogEl = el("mapEditorMenuDialog");
|
const menuDialogEl = el("mapEditorMenuDialog");
|
||||||
const settingsDialogEl = el("mapEditorSettingsDialog");
|
const settingsDialogEl = el("mapEditorSettingsDialog");
|
||||||
const activateDialogEl = el("mapActivateDialog");
|
const activateDialogEl = el("mapActivateDialog");
|
||||||
|
const uploadConfirmDialogEl = el("mapUploadConfirmDialog");
|
||||||
|
const uploadMetaDialogEl = el("mapUploadMetaDialog");
|
||||||
|
const uploadYamlInputEl = el("mapUploadYamlInput");
|
||||||
|
|
||||||
|
const uploadMetaFields = {
|
||||||
|
resolution: el("mapUploadResolution"),
|
||||||
|
originX: el("mapUploadOriginX"),
|
||||||
|
originY: el("mapUploadOriginY"),
|
||||||
|
originYaw: el("mapUploadOriginYaw"),
|
||||||
|
negate: el("mapUploadNegate"),
|
||||||
|
occupiedThresh: el("mapUploadOccupiedThresh"),
|
||||||
|
freeThresh: el("mapUploadFreeThresh"),
|
||||||
|
};
|
||||||
|
|
||||||
const toolBtnEls = () => document.querySelectorAll(".mapEditorMapTool[data-tool]");
|
const toolBtnEls = () => document.querySelectorAll(".mapEditorMapTool[data-tool]");
|
||||||
|
|
||||||
@@ -59,6 +83,20 @@
|
|||||||
return `/api/maps/${encodeURIComponent(map.id)}/image?t=${encodeURIComponent(map.updated_at || "")}`;
|
return `/api/maps/${encodeURIComponent(map.id)}/image?t=${encodeURIComponent(map.updated_at || "")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function viewportSize() {
|
||||||
|
const rect = viewportEl?.getBoundingClientRect();
|
||||||
|
return { width: rect?.width || 1, height: rect?.height || 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Layer 2 — image dimensions in floor-plan pixels. */
|
||||||
|
function floorPlanSize() {
|
||||||
|
return Geo()?.imageSize(state.map, imageEl) || { width: 0, height: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFloorPlan() {
|
||||||
|
return !!(state.map?.image_file && imageEl && !imageEl.hidden && imageEl.naturalWidth);
|
||||||
|
}
|
||||||
|
|
||||||
function setDirty(flag) {
|
function setDirty(flag) {
|
||||||
state.dirty = !!flag;
|
state.dirty = !!flag;
|
||||||
if (dirtyEl) dirtyEl.hidden = !state.dirty;
|
if (dirtyEl) dirtyEl.hidden = !state.dirty;
|
||||||
@@ -86,51 +124,184 @@
|
|||||||
updateCanvasCursor();
|
updateCanvasCursor();
|
||||||
}
|
}
|
||||||
|
|
||||||
function centerSheetInView() {
|
/** Layer 1 — apply view transform to inner canvas only. */
|
||||||
if (!canvasWrapEl || !sheetEl) return;
|
|
||||||
const wrap = canvasWrapEl.getBoundingClientRect();
|
|
||||||
const sw = sheetEl.offsetWidth || 480;
|
|
||||||
const sh = sheetEl.offsetHeight || 360;
|
|
||||||
state.view.panX = Math.max(40, (wrap.width - sw * state.view.scale) / 2);
|
|
||||||
state.view.panY = Math.max(40, (wrap.height - sh * state.view.scale) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyViewTransform() {
|
function applyViewTransform() {
|
||||||
if (!canvasInnerEl) return;
|
Geo()?.applyViewTransform(canvasInnerEl, state.view);
|
||||||
const { scale, panX, panY } = state.view;
|
updateStatusBar();
|
||||||
canvasInnerEl.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fitToView() {
|
function fitToView() {
|
||||||
dismissCanvasTip();
|
dismissCanvasTip();
|
||||||
if (!canvasWrapEl || !sheetEl) return;
|
const vp = viewportSize();
|
||||||
const wrap = canvasWrapEl.getBoundingClientRect();
|
const { width, height } = floorPlanSize();
|
||||||
const sw = imageEl && !imageEl.hidden ? imageEl.naturalWidth || sheetEl.offsetWidth : sheetEl.offsetWidth;
|
const blankW = sheetEl?.offsetWidth || 480;
|
||||||
const sh = imageEl && !imageEl.hidden ? imageEl.naturalHeight || sheetEl.offsetHeight : sheetEl.offsetHeight;
|
const blankH = sheetEl?.offsetHeight || 360;
|
||||||
const pad = 48;
|
state.view = Geo()?.fitViewToImage(
|
||||||
const scale = Math.min((wrap.width - pad) / sw, (wrap.height - pad) / sh, 4);
|
vp.width,
|
||||||
state.view.scale = Math.max(0.1, scale);
|
vp.height,
|
||||||
state.view.panX = (wrap.width - sw * state.view.scale) / 2;
|
hasFloorPlan() ? width : blankW,
|
||||||
state.view.panY = (wrap.height - sh * state.view.scale) / 2;
|
hasFloorPlan() ? height : blankH,
|
||||||
|
) || state.view;
|
||||||
applyViewTransform();
|
applyViewTransform();
|
||||||
updateCanvasCursor();
|
updateCanvasCursor();
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoomBy(factor) {
|
function centerSheetInView() {
|
||||||
dismissCanvasTip();
|
const vp = viewportSize();
|
||||||
state.view.scale = Math.min(8, Math.max(0.1, state.view.scale * factor));
|
const { width, height } = floorPlanSize();
|
||||||
|
const blankW = sheetEl?.offsetWidth || 480;
|
||||||
|
const blankH = sheetEl?.offsetHeight || 360;
|
||||||
|
state.view = Geo()?.centerViewOnImage(
|
||||||
|
vp.width,
|
||||||
|
vp.height,
|
||||||
|
hasFloorPlan() ? width : blankW,
|
||||||
|
hasFloorPlan() ? height : blankH,
|
||||||
|
state.view,
|
||||||
|
) || state.view;
|
||||||
applyViewTransform();
|
applyViewTransform();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSheetSize() {
|
function zoomBy(factor, anchorClientX, anchorClientY) {
|
||||||
if (!sheetEl || !imageEl) return;
|
dismissCanvasTip();
|
||||||
if (!imageEl.hidden && imageEl.naturalWidth) {
|
const vpRect = viewportEl?.getBoundingClientRect();
|
||||||
sheetEl.style.width = `${imageEl.naturalWidth}px`;
|
if (!vpRect) return;
|
||||||
sheetEl.style.minHeight = `${imageEl.naturalHeight}px`;
|
const anchorVx = anchorClientX != null ? anchorClientX - vpRect.left : vpRect.width / 2;
|
||||||
|
const anchorVy = anchorClientY != null ? anchorClientY - vpRect.top : vpRect.height / 2;
|
||||||
|
state.view = Geo()?.zoomViewAt(state.view, anchorVx, anchorVy, factor) || state.view;
|
||||||
|
applyViewTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Layer 2 — sheet sized 1:1 with PNG pixels. */
|
||||||
|
function updateImageLayer() {
|
||||||
|
if (!sheetEl || !imageLayerEl) return;
|
||||||
|
const has = hasFloorPlan();
|
||||||
|
const { width, height } = floorPlanSize();
|
||||||
|
|
||||||
|
sheetEl.classList.toggle("mapEditorSheet--hasImage", has);
|
||||||
|
sheetEl.classList.toggle("mapEditorSheet--blank", !has);
|
||||||
|
|
||||||
|
if (has && width && height) {
|
||||||
|
sheetEl.style.width = `${width}px`;
|
||||||
|
sheetEl.style.height = `${height}px`;
|
||||||
|
imageLayerEl.style.width = `${width}px`;
|
||||||
|
imageLayerEl.style.height = `${height}px`;
|
||||||
} else {
|
} else {
|
||||||
sheetEl.style.width = "";
|
sheetEl.style.width = "";
|
||||||
|
sheetEl.style.height = "";
|
||||||
sheetEl.style.minWidth = "480px";
|
sheetEl.style.minWidth = "480px";
|
||||||
sheetEl.style.minHeight = "360px";
|
sheetEl.style.minHeight = "360px";
|
||||||
|
imageLayerEl.style.width = "";
|
||||||
|
imageLayerEl.style.height = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gridEl && has) {
|
||||||
|
const mapForLayer = mapMetaForOriginDisplay() || state.map;
|
||||||
|
const steps = Geo()?.gridSteps(mapForLayer) || { minor: 20, major: 100 };
|
||||||
|
gridEl.style.setProperty("--map-grid-minor", `${steps.minor}px`);
|
||||||
|
gridEl.style.setProperty("--map-grid-major", `${steps.major}px`);
|
||||||
|
gridEl.hidden = false;
|
||||||
|
} else if (gridEl) {
|
||||||
|
gridEl.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originEl) originEl.hidden = !has;
|
||||||
|
updateOriginMarker();
|
||||||
|
updateStatusBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Origin fields → world (0,0) on the floor plan (ROS map_server). */
|
||||||
|
function mapMetaForOriginDisplay() {
|
||||||
|
if (!state.map) return null;
|
||||||
|
const base = { ...state.map };
|
||||||
|
if (uploadMetaDialogEl?.open) {
|
||||||
|
const m = readUploadMetaPayload();
|
||||||
|
return { ...base, ...m };
|
||||||
|
}
|
||||||
|
if (settingsDialogEl?.open || state.dirty) {
|
||||||
|
const s = readSettingsPayload();
|
||||||
|
return { ...base, ...s };
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOriginMarker() {
|
||||||
|
if (!originEl) return;
|
||||||
|
const geo = Geo();
|
||||||
|
const { width, height } = floorPlanSize();
|
||||||
|
if (!geo || !hasFloorPlan() || !width || !height) {
|
||||||
|
originEl.hidden = true;
|
||||||
|
originEl.setAttribute("aria-hidden", "true");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapMeta = mapMetaForOriginDisplay();
|
||||||
|
const pt = geo.worldToPixel(mapMeta, width, height, 0, 0);
|
||||||
|
const ox = Number(mapMeta?.origin_x) || 0;
|
||||||
|
const oy = Number(mapMeta?.origin_y) || 0;
|
||||||
|
const oyaw = Number(mapMeta?.origin_yaw) || 0;
|
||||||
|
const yawDeg = (-oyaw * 180) / Math.PI;
|
||||||
|
const onMap = pt.x >= -2 && pt.y >= -2 && pt.x <= width + 2 && pt.y <= height + 2;
|
||||||
|
|
||||||
|
originEl.hidden = false;
|
||||||
|
originEl.setAttribute("aria-hidden", "false");
|
||||||
|
originEl.classList.toggle("mapEditorOrigin--offMap", !onMap);
|
||||||
|
originEl.style.left = `${pt.x}px`;
|
||||||
|
originEl.style.top = `${pt.y}px`;
|
||||||
|
originEl.style.transform = `rotate(${yawDeg}deg)`;
|
||||||
|
|
||||||
|
const labelEl = el("mapEditorOriginLabel");
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = t("maps.editor.originLabelShort", {
|
||||||
|
x: ox.toFixed(2),
|
||||||
|
y: oy.toFixed(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
originEl.title = t("maps.editor.originTooltip", {
|
||||||
|
x: ox.toFixed(3),
|
||||||
|
y: oy.toFixed(3),
|
||||||
|
yaw: ((oyaw * 180) / Math.PI).toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatusBar(pointerClient) {
|
||||||
|
const geo = Geo();
|
||||||
|
const { width, height } = floorPlanSize();
|
||||||
|
const pct = Math.round((state.view.scale || 1) * 100);
|
||||||
|
|
||||||
|
if (statusViewEl) {
|
||||||
|
statusViewEl.textContent = t("maps.editor.statusView", {
|
||||||
|
zoom: pct,
|
||||||
|
panX: Math.round(state.view.panX),
|
||||||
|
panY: Math.round(state.view.panY),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!geo || !hasFloorPlan() || !pointerClient) {
|
||||||
|
if (statusImageEl) statusImageEl.textContent = t("maps.editor.statusImageIdle");
|
||||||
|
if (statusWorldEl) statusWorldEl.textContent = t("maps.editor.statusWorldIdle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sheetRect = sheetEl?.getBoundingClientRect();
|
||||||
|
const imgPt = geo.clientToImage(pointerClient.x, pointerClient.y, sheetRect, width, height);
|
||||||
|
if (!imgPt) {
|
||||||
|
if (statusImageEl) statusImageEl.textContent = t("maps.editor.statusImageIdle");
|
||||||
|
if (statusWorldEl) statusWorldEl.textContent = t("maps.editor.statusWorldIdle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const world = geo.pixelToWorld(state.map, width, height, imgPt.x, imgPt.y);
|
||||||
|
if (statusImageEl) {
|
||||||
|
statusImageEl.textContent = t("maps.editor.statusImage", {
|
||||||
|
px: Math.round(imgPt.x),
|
||||||
|
py: Math.round(imgPt.y),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (statusWorldEl) {
|
||||||
|
statusWorldEl.textContent = t("maps.editor.statusWorld", {
|
||||||
|
x: world.x.toFixed(2),
|
||||||
|
y: world.y.toFixed(2),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,11 +319,11 @@
|
|||||||
if (emptyEl) emptyEl.hidden = false;
|
if (emptyEl) emptyEl.hidden = false;
|
||||||
}
|
}
|
||||||
updateMenuActionsUi();
|
updateMenuActionsUi();
|
||||||
updateSheetSize();
|
updateImageLayer();
|
||||||
imageEl?.addEventListener(
|
imageEl?.addEventListener(
|
||||||
"load",
|
"load",
|
||||||
() => {
|
() => {
|
||||||
updateSheetSize();
|
updateImageLayer();
|
||||||
fitToView();
|
fitToView();
|
||||||
},
|
},
|
||||||
{ once: true },
|
{ once: true },
|
||||||
@@ -222,6 +393,7 @@
|
|||||||
state.dirty = false;
|
state.dirty = false;
|
||||||
state.tipVisible = true;
|
state.tipVisible = true;
|
||||||
state.activeTool = "pan";
|
state.activeTool = "pan";
|
||||||
|
state.view = Geo()?.createView(1, 0, 0) || { scale: 1, panX: 0, panY: 0 };
|
||||||
if (tipEl) {
|
if (tipEl) {
|
||||||
tipEl.hidden = false;
|
tipEl.hidden = false;
|
||||||
tipEl.textContent = t("maps.editor.canvasTip");
|
tipEl.textContent = t("maps.editor.canvasTip");
|
||||||
@@ -236,9 +408,12 @@
|
|||||||
state.mapId = null;
|
state.mapId = null;
|
||||||
state.map = null;
|
state.map = null;
|
||||||
state.callbacks = {};
|
state.callbacks = {};
|
||||||
|
state.uploadMeta = null;
|
||||||
menuDialogEl?.close();
|
menuDialogEl?.close();
|
||||||
settingsDialogEl?.close();
|
settingsDialogEl?.close();
|
||||||
activateDialogEl?.close();
|
activateDialogEl?.close();
|
||||||
|
uploadConfirmDialogEl?.close();
|
||||||
|
uploadMetaDialogEl?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadImageDimensions(file) {
|
function loadImageDimensions(file) {
|
||||||
@@ -257,15 +432,119 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readUploadMetaPayload() {
|
||||||
|
return {
|
||||||
|
resolution: Number(uploadMetaFields.resolution?.value) || 0.05,
|
||||||
|
origin_x: Number(uploadMetaFields.originX?.value) || 0,
|
||||||
|
origin_y: Number(uploadMetaFields.originY?.value) || 0,
|
||||||
|
origin_yaw: Number(uploadMetaFields.originYaw?.value) || 0,
|
||||||
|
negate: Number(uploadMetaFields.negate?.value) || 0,
|
||||||
|
occupied_thresh: Number(uploadMetaFields.occupiedThresh?.value) || 0.65,
|
||||||
|
free_thresh: Number(uploadMetaFields.freeThresh?.value) || 0.196,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillUploadMetaForm(meta) {
|
||||||
|
const m = meta || {};
|
||||||
|
if (uploadMetaFields.resolution) {
|
||||||
|
uploadMetaFields.resolution.value = m.resolution != null ? m.resolution : 0.05;
|
||||||
|
}
|
||||||
|
if (uploadMetaFields.originX) uploadMetaFields.originX.value = m.origin_x != null ? m.origin_x : 0;
|
||||||
|
if (uploadMetaFields.originY) uploadMetaFields.originY.value = m.origin_y != null ? m.origin_y : 0;
|
||||||
|
if (uploadMetaFields.originYaw) uploadMetaFields.originYaw.value = m.origin_yaw != null ? m.origin_yaw : 0;
|
||||||
|
if (uploadMetaFields.negate) uploadMetaFields.negate.value = m.negate != null ? m.negate : 0;
|
||||||
|
if (uploadMetaFields.occupiedThresh) {
|
||||||
|
uploadMetaFields.occupiedThresh.value = m.occupied_thresh != null ? m.occupied_thresh : 0.65;
|
||||||
|
}
|
||||||
|
if (uploadMetaFields.freeThresh) {
|
||||||
|
uploadMetaFields.freeThresh.value = m.free_thresh != null ? m.free_thresh : 0.196;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchExistingYamlMeta() {
|
||||||
|
if (!state.map?.yaml_file) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/maps/${encodeURIComponent(state.map.id)}/yaml`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const text = await res.text();
|
||||||
|
const parsed = window.MapYaml?.parse(text);
|
||||||
|
if (!parsed || parsed.error) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openUploadMetaDialog() {
|
||||||
|
const map = state.map;
|
||||||
|
const defaults = {
|
||||||
|
resolution: map?.resolution != null ? map.resolution : 0.05,
|
||||||
|
origin_x: map?.origin_x != null ? map.origin_x : 0,
|
||||||
|
origin_y: map?.origin_y != null ? map.origin_y : 0,
|
||||||
|
origin_yaw: map?.origin_yaw != null ? map.origin_yaw : 0,
|
||||||
|
negate: 0,
|
||||||
|
occupied_thresh: 0.65,
|
||||||
|
free_thresh: 0.196,
|
||||||
|
};
|
||||||
|
const fromYaml = await fetchExistingYamlMeta();
|
||||||
|
fillUploadMetaForm(fromYaml || defaults);
|
||||||
|
menuDialogEl?.close();
|
||||||
|
uploadMetaDialogEl?.showModal();
|
||||||
|
updateOriginMarker();
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginUploadOverwrite() {
|
||||||
|
if (!state.map || state.readOnly) return;
|
||||||
|
if (state.map.image_file) {
|
||||||
|
const textEl = el("mapUploadConfirmText");
|
||||||
|
if (textEl) textEl.textContent = t("maps.uploadConfirm.text");
|
||||||
|
menuDialogEl?.close();
|
||||||
|
uploadConfirmDialogEl?.showModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openUploadMetaDialog().catch((e) => alert(e.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyYamlToUploadForm(text) {
|
||||||
|
const parsed = window.MapYaml?.parse(text);
|
||||||
|
if (!parsed || parsed.error) {
|
||||||
|
alert(t("maps.uploadMeta.invalidYaml"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fillUploadMetaForm(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveYamlForMap(imageFilename) {
|
||||||
|
const meta = state.uploadMeta || readUploadMetaPayload();
|
||||||
|
const yamlText = window.MapYaml?.serialize({
|
||||||
|
...meta,
|
||||||
|
image: imageFilename || "map.png",
|
||||||
|
});
|
||||||
|
if (!yamlText) return;
|
||||||
|
await api(`/api/maps/${encodeURIComponent(state.map.id)}/yaml`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "text/yaml; charset=utf-8" },
|
||||||
|
body: yamlText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadImage(file) {
|
async function uploadImage(file) {
|
||||||
if (!state.map || !file || state.readOnly) return;
|
if (!state.map || !file || state.readOnly) return;
|
||||||
if (!/\.png$/i.test(file.name)) {
|
if (!/\.png$/i.test(file.name)) {
|
||||||
alert(t("maps.error.pngOnly"));
|
alert(t("maps.error.pngOnly"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const meta = state.uploadMeta || readUploadMetaPayload();
|
||||||
|
if (!meta.resolution || meta.resolution <= 0) {
|
||||||
|
alert(t("maps.uploadMeta.invalidResolution"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dims = await loadImageDimensions(file);
|
const dims = await loadImageDimensions(file);
|
||||||
|
const pngName = file.name.endsWith(".png") ? file.name : `${file.name}.png`;
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file, file.name.endsWith(".png") ? file.name : `${file.name}.png`);
|
form.append("file", file, pngName);
|
||||||
const res = await fetch(`/api/maps/${encodeURIComponent(state.map.id)}/image`, {
|
const res = await fetch(`/api/maps/${encodeURIComponent(state.map.id)}/image`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -286,16 +565,28 @@
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...readSettingsPayload(),
|
resolution: meta.resolution,
|
||||||
|
origin_x: meta.origin_x,
|
||||||
|
origin_y: meta.origin_y,
|
||||||
|
origin_yaw: meta.origin_yaw,
|
||||||
width: dims.width,
|
width: dims.width,
|
||||||
height: dims.height,
|
height: dims.height,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
await saveYamlForMap(pngName);
|
||||||
|
updated = (await api(`/api/maps/${encodeURIComponent(state.map.id)}`)) || updated;
|
||||||
|
} catch {
|
||||||
|
/* yaml save is best-effort */
|
||||||
|
}
|
||||||
|
state.uploadMeta = null;
|
||||||
state.map = updated;
|
state.map = updated;
|
||||||
state.callbacks.onMapUpdated?.(updated);
|
state.callbacks.onMapUpdated?.(updated);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
|
fillSettingsForm();
|
||||||
renderMapImage();
|
renderMapImage();
|
||||||
menuDialogEl?.close();
|
menuDialogEl?.close();
|
||||||
|
uploadMetaDialogEl?.close();
|
||||||
promptActivate();
|
promptActivate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,6 +606,7 @@
|
|||||||
state.callbacks.onMapUpdated?.(updated);
|
state.callbacks.onMapUpdated?.(updated);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
updateHeader();
|
updateHeader();
|
||||||
|
updateImageLayer();
|
||||||
menuDialogEl?.close();
|
menuDialogEl?.close();
|
||||||
promptActivate();
|
promptActivate();
|
||||||
}
|
}
|
||||||
@@ -341,15 +633,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindCanvasPanZoom() {
|
function bindCanvasPanZoom() {
|
||||||
canvasWrapEl?.addEventListener("wheel", (evt) => {
|
const blockNativeDrag = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
imageEl?.addEventListener("dragstart", blockNativeDrag);
|
||||||
|
sheetEl?.addEventListener("dragstart", blockNativeDrag);
|
||||||
|
imageLayerEl?.addEventListener("dragstart", blockNativeDrag);
|
||||||
|
|
||||||
|
viewportEl?.addEventListener("wheel", (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
dismissCanvasTip();
|
dismissCanvasTip();
|
||||||
const factor = evt.deltaY < 0 ? 1.1 : 0.9;
|
const factor = evt.deltaY < 0 ? 1.1 : 0.9;
|
||||||
zoomBy(factor);
|
zoomBy(factor, evt.clientX, evt.clientY);
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
canvasWrapEl?.addEventListener("mousedown", (evt) => {
|
viewportEl?.addEventListener("mousemove", (evt) => {
|
||||||
|
updateStatusBar({ x: evt.clientX, y: evt.clientY });
|
||||||
|
});
|
||||||
|
|
||||||
|
viewportEl?.addEventListener("mouseleave", () => {
|
||||||
|
updateStatusBar();
|
||||||
|
});
|
||||||
|
|
||||||
|
viewportEl?.addEventListener("mousedown", (evt) => {
|
||||||
if (evt.button !== 0 || state.activeTool !== "pan") return;
|
if (evt.button !== 0 || state.activeTool !== "pan") return;
|
||||||
|
evt.preventDefault();
|
||||||
dismissCanvasTip();
|
dismissCanvasTip();
|
||||||
state.panning = {
|
state.panning = {
|
||||||
startX: evt.clientX,
|
startX: evt.clientX,
|
||||||
@@ -372,6 +681,11 @@
|
|||||||
state.panning = null;
|
state.panning = null;
|
||||||
updateCanvasCursor();
|
updateCanvasCursor();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (!state.mapId) return;
|
||||||
|
applyViewTransform();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
@@ -394,6 +708,7 @@
|
|||||||
el("mapEditorSettingsBtn")?.addEventListener("click", () => {
|
el("mapEditorSettingsBtn")?.addEventListener("click", () => {
|
||||||
fillSettingsForm();
|
fillSettingsForm();
|
||||||
settingsDialogEl?.showModal();
|
settingsDialogEl?.showModal();
|
||||||
|
updateOriginMarker();
|
||||||
});
|
});
|
||||||
el("mapEditorSaveBtn")?.addEventListener("click", () => {
|
el("mapEditorSaveBtn")?.addEventListener("click", () => {
|
||||||
saveMap().catch((e) => alert(e.message));
|
saveMap().catch((e) => alert(e.message));
|
||||||
@@ -403,12 +718,17 @@
|
|||||||
el("mapEditorCenterBtn")?.addEventListener("click", () => {
|
el("mapEditorCenterBtn")?.addEventListener("click", () => {
|
||||||
dismissCanvasTip();
|
dismissCanvasTip();
|
||||||
centerSheetInView();
|
centerSheetInView();
|
||||||
applyViewTransform();
|
|
||||||
});
|
});
|
||||||
el("mapEditorZoomInBtn")?.addEventListener("click", () => zoomBy(1.2));
|
el("mapEditorZoomInBtn")?.addEventListener("click", () => {
|
||||||
el("mapEditorZoomOutBtn")?.addEventListener("click", () => zoomBy(1 / 1.2));
|
const rect = viewportEl?.getBoundingClientRect();
|
||||||
|
zoomBy(1.2, rect ? rect.left + rect.width / 2 : undefined, rect ? rect.top + rect.height / 2 : undefined);
|
||||||
|
});
|
||||||
|
el("mapEditorZoomOutBtn")?.addEventListener("click", () => {
|
||||||
|
const rect = viewportEl?.getBoundingClientRect();
|
||||||
|
zoomBy(1 / 1.2, rect ? rect.left + rect.width / 2 : undefined, rect ? rect.top + rect.height / 2 : undefined);
|
||||||
|
});
|
||||||
|
|
||||||
el("mapMenuUploadOverwrite")?.addEventListener("click", () => uploadInputEl?.click());
|
el("mapMenuUploadOverwrite")?.addEventListener("click", () => beginUploadOverwrite());
|
||||||
el("mapMenuDownload")?.addEventListener("click", () => {
|
el("mapMenuDownload")?.addEventListener("click", () => {
|
||||||
const url = mapImageUrl(state.map);
|
const url = mapImageUrl(state.map);
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
@@ -426,11 +746,59 @@
|
|||||||
uploadImage(file).catch((e) => alert(e.message));
|
uploadImage(file).catch((e) => alert(e.message));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
el("mapUploadConfirmYesBtn")?.addEventListener("click", () => {
|
||||||
|
uploadConfirmDialogEl?.close();
|
||||||
|
openUploadMetaDialog().catch((e) => alert(e.message));
|
||||||
|
});
|
||||||
|
el("mapUploadConfirmNoBtn")?.addEventListener("click", () => uploadConfirmDialogEl?.close());
|
||||||
|
uploadConfirmDialogEl?.addEventListener("cancel", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
uploadConfirmDialogEl?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
el("mapUploadImportYamlBtn")?.addEventListener("click", () => uploadYamlInputEl?.click());
|
||||||
|
uploadYamlInputEl?.addEventListener("change", () => {
|
||||||
|
const file = uploadYamlInputEl.files?.[0];
|
||||||
|
uploadYamlInputEl.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
applyYamlToUploadForm(String(reader.result || ""));
|
||||||
|
updateOriginMarker();
|
||||||
|
};
|
||||||
|
reader.onerror = () => alert(t("maps.uploadMeta.invalidYaml"));
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
el("mapUploadMetaCancelBtn")?.addEventListener("click", () => {
|
||||||
|
state.uploadMeta = null;
|
||||||
|
uploadMetaDialogEl?.close();
|
||||||
|
updateOriginMarker();
|
||||||
|
});
|
||||||
|
uploadMetaDialogEl?.addEventListener("cancel", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
state.uploadMeta = null;
|
||||||
|
uploadMetaDialogEl?.close();
|
||||||
|
updateOriginMarker();
|
||||||
|
});
|
||||||
|
el("mapUploadMetaForm")?.addEventListener("submit", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const meta = readUploadMetaPayload();
|
||||||
|
if (!meta.resolution || meta.resolution <= 0) {
|
||||||
|
alert(t("maps.uploadMeta.invalidResolution"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.uploadMeta = meta;
|
||||||
|
uploadMetaDialogEl?.close();
|
||||||
|
uploadInputEl?.click();
|
||||||
|
});
|
||||||
|
|
||||||
el("mapEditorSettingsForm")?.addEventListener("submit", (evt) => {
|
el("mapEditorSettingsForm")?.addEventListener("submit", (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
if (!state.map) return;
|
if (!state.map) return;
|
||||||
Object.assign(state.map, readSettingsPayload());
|
Object.assign(state.map, readSettingsPayload());
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
|
updateImageLayer();
|
||||||
updateHeader();
|
updateHeader();
|
||||||
settingsDialogEl?.close();
|
settingsDialogEl?.close();
|
||||||
});
|
});
|
||||||
@@ -441,12 +809,26 @@
|
|||||||
el("mapActivateNoBtn")?.addEventListener("click", () => activateDialogEl?.close());
|
el("mapActivateNoBtn")?.addEventListener("click", () => activateDialogEl?.close());
|
||||||
|
|
||||||
Object.values(settingsFields).forEach((node) => {
|
Object.values(settingsFields).forEach((node) => {
|
||||||
node?.addEventListener("input", () => setDirty(true));
|
node?.addEventListener("input", () => {
|
||||||
|
setDirty(true);
|
||||||
|
updateOriginMarker();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.values(uploadMetaFields).forEach((node) => {
|
||||||
|
node?.addEventListener("input", () => {
|
||||||
|
updateOriginMarker();
|
||||||
|
if (node === uploadMetaFields.resolution && uploadMetaDialogEl?.open) {
|
||||||
|
updateImageLayer();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("lm:locale-change", () => {
|
window.addEventListener("lm:locale-change", () => {
|
||||||
if (state.tipVisible && tipEl) tipEl.textContent = t("maps.editor.canvasTip");
|
if (state.tipVisible && tipEl) tipEl.textContent = t("maps.editor.canvasTip");
|
||||||
updateHeader();
|
updateHeader();
|
||||||
|
updateStatusBar();
|
||||||
|
updateOriginMarker();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
160
www/map-geo.js
Normal file
160
www/map-geo.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
(() => {
|
||||||
|
/**
|
||||||
|
* MiR-style map coordinate model (3 layers):
|
||||||
|
*
|
||||||
|
* 1. View space — screen pixels in the viewport; pan + zoom (UI only).
|
||||||
|
* 2. Image space — floor plan pixels (1 px image = 1 px on sheet; 20 px/m at res 0.05).
|
||||||
|
* 3. World space — map coordinates in metres + yaw (robot, positions).
|
||||||
|
*
|
||||||
|
* view --scale+translate--> image --resolution+origin--> world
|
||||||
|
*/
|
||||||
|
|
||||||
|
function meta(map) {
|
||||||
|
return {
|
||||||
|
resolution: Number(map?.resolution) || 0.05,
|
||||||
|
originX: Number(map?.origin_x) || 0,
|
||||||
|
originY: Number(map?.origin_y) || 0,
|
||||||
|
originYaw: Number(map?.origin_yaw) || 0,
|
||||||
|
width: Number(map?.width) || 0,
|
||||||
|
height: Number(map?.height) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pixelsPerMeter(map) {
|
||||||
|
const res = meta(map).resolution;
|
||||||
|
return res > 0 ? 1 / res : 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageSize(map, imageEl) {
|
||||||
|
const w = imageEl?.naturalWidth || meta(map).width || 0;
|
||||||
|
const h = imageEl?.naturalHeight || meta(map).height || 0;
|
||||||
|
return { width: w, height: h };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** --- View space (layer 1) --- */
|
||||||
|
|
||||||
|
function createView(scale = 1, panX = 0, panY = 0) {
|
||||||
|
return { scale, panX, panY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyViewTransform(el, view) {
|
||||||
|
if (!el || !view) return;
|
||||||
|
el.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${view.scale})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitViewToImage(viewportW, viewportH, imageW, imageH, pad = 48) {
|
||||||
|
if (!imageW || !imageH) {
|
||||||
|
return createView(1, Math.max(40, pad), Math.max(40, pad));
|
||||||
|
}
|
||||||
|
const scale = Math.min((viewportW - pad) / imageW, (viewportH - pad) / imageH, 4);
|
||||||
|
const s = Math.max(0.1, scale);
|
||||||
|
return {
|
||||||
|
scale: s,
|
||||||
|
panX: (viewportW - imageW * s) / 2,
|
||||||
|
panY: (viewportH - imageH * s) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerViewOnImage(viewportW, viewportH, imageW, imageH, view) {
|
||||||
|
const s = view?.scale || 1;
|
||||||
|
return {
|
||||||
|
scale: s,
|
||||||
|
panX: Math.max(40, (viewportW - imageW * s) / 2),
|
||||||
|
panY: Math.max(40, (viewportH - imageH * s) / 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Viewport-local px → image px (inverse of translate+scale). */
|
||||||
|
function viewportToImage(view, vx, vy) {
|
||||||
|
const s = view.scale || 1;
|
||||||
|
return {
|
||||||
|
x: (vx - view.panX) / s,
|
||||||
|
y: (vy - view.panY) / s,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Image px → viewport-local px. */
|
||||||
|
function imageToViewport(view, ix, iy) {
|
||||||
|
return {
|
||||||
|
x: view.panX + ix * view.scale,
|
||||||
|
y: view.panY + iy * view.scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zoom toward a viewport anchor; keeps image point under cursor fixed. */
|
||||||
|
function zoomViewAt(view, anchorVx, anchorVy, factor, minScale = 0.1, maxScale = 8) {
|
||||||
|
const img = viewportToImage(view, anchorVx, anchorVy);
|
||||||
|
const nextScale = Math.min(maxScale, Math.max(minScale, view.scale * factor));
|
||||||
|
return {
|
||||||
|
scale: nextScale,
|
||||||
|
panX: anchorVx - img.x * nextScale,
|
||||||
|
panY: anchorVy - img.y * nextScale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** --- Image space (layer 2) --- */
|
||||||
|
|
||||||
|
function gridSteps(map) {
|
||||||
|
const ppm = pixelsPerMeter(map);
|
||||||
|
return {
|
||||||
|
minor: Math.max(1, Math.round(ppm)),
|
||||||
|
major: Math.max(1, Math.round(ppm * 5)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampImagePoint(px, py, imageW, imageH) {
|
||||||
|
if (px < 0 || py < 0 || px > imageW || py > imageH) return null;
|
||||||
|
return { x: px, y: py };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Screen/client coords → image px using the transformed sheet rect. */
|
||||||
|
function clientToImage(clientX, clientY, sheetRect, imageW, imageH) {
|
||||||
|
if (!sheetRect?.width || !sheetRect?.height || !imageW || !imageH) return null;
|
||||||
|
const px = ((clientX - sheetRect.left) / sheetRect.width) * imageW;
|
||||||
|
const py = ((clientY - sheetRect.top) / sheetRect.height) * imageH;
|
||||||
|
return clampImagePoint(px, py, imageW, imageH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** --- World space (layer 3) — ROS map_server convention --- */
|
||||||
|
|
||||||
|
function worldToPixel(map, imgW, imgH, wx, wy) {
|
||||||
|
const { resolution, originX, originY } = meta(map);
|
||||||
|
return {
|
||||||
|
x: (wx - originX) / resolution,
|
||||||
|
y: imgH - (wy - originY) / resolution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pixelToWorld(map, imgW, imgH, px, py) {
|
||||||
|
const { resolution, originX, originY } = meta(map);
|
||||||
|
return {
|
||||||
|
x: originX + px * resolution,
|
||||||
|
y: originY + (imgH - py) * resolution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Viewport pointer → world (chains view→image→world). */
|
||||||
|
function clientToWorld(map, clientX, clientY, sheetRect, imageW, imageH) {
|
||||||
|
const img = clientToImage(clientX, clientY, sheetRect, imageW, imageH);
|
||||||
|
if (!img) return null;
|
||||||
|
return pixelToWorld(map, imageW, imageH, img.x, img.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.MapGeo = {
|
||||||
|
meta,
|
||||||
|
pixelsPerMeter,
|
||||||
|
imageSize,
|
||||||
|
createView,
|
||||||
|
applyViewTransform,
|
||||||
|
fitViewToImage,
|
||||||
|
centerViewOnImage,
|
||||||
|
viewportToImage,
|
||||||
|
imageToViewport,
|
||||||
|
zoomViewAt,
|
||||||
|
gridSteps,
|
||||||
|
clientToImage,
|
||||||
|
clientToWorld,
|
||||||
|
worldToPixel,
|
||||||
|
pixelToWorld,
|
||||||
|
};
|
||||||
|
})();
|
||||||
97
www/map-import.js
Normal file
97
www/map-import.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
(() => {
|
||||||
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||||
|
|
||||||
|
function fileStem(name) {
|
||||||
|
const base = String(name).replace(/^.*[/\\]/, "");
|
||||||
|
const dot = base.lastIndexOf(".");
|
||||||
|
return dot >= 0 ? base.slice(0, dot) : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isYamlFile(file) {
|
||||||
|
return /\.ya?ml$/i.test(file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMapImageFile(file) {
|
||||||
|
return /\.(png|pgm)$/i.test(file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function matchRosMapFiles(fileList) {
|
||||||
|
const files = Array.from(fileList || []);
|
||||||
|
const yamlFile = files.find(isYamlFile);
|
||||||
|
if (!yamlFile) {
|
||||||
|
throw new Error(t("maps.importNeedYaml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageFile = files.find((f) => f !== yamlFile && isMapImageFile(f));
|
||||||
|
try {
|
||||||
|
const yamlText = await yamlFile.text();
|
||||||
|
const imageRef = yamlText.match(/^image:\s*(\S+)/m)?.[1];
|
||||||
|
if (imageRef) {
|
||||||
|
const refStem = fileStem(imageRef);
|
||||||
|
const matched = files.find(
|
||||||
|
(f) => f !== yamlFile && isMapImageFile(f) && fileStem(f.name) === refStem,
|
||||||
|
);
|
||||||
|
if (matched) imageFile = matched;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort stem match */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageFile) {
|
||||||
|
throw new Error(t("maps.importNeedImage"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { yamlFile, imageFile };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFormData(yamlFile, imageFile, extras = {}) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("yaml", yamlFile, yamlFile.name);
|
||||||
|
form.append("image", imageFile, imageFile.name);
|
||||||
|
Object.entries(extras).forEach(([key, value]) => {
|
||||||
|
if (value != null && value !== "") form.append(key, value);
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiImport(path, form) {
|
||||||
|
const res = await fetch(path, { method: "POST", credentials: "include", body: form });
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = res.statusText;
|
||||||
|
try {
|
||||||
|
const err = await res.json();
|
||||||
|
if (err.error) msg = err.error;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importNewMap(fileList, extras = {}) {
|
||||||
|
const { yamlFile, imageFile } = await matchRosMapFiles(fileList);
|
||||||
|
const form = buildFormData(yamlFile, imageFile, {
|
||||||
|
name: extras.name || fileStem(yamlFile.name),
|
||||||
|
site_id: extras.site_id || "",
|
||||||
|
created_by: extras.created_by || "",
|
||||||
|
description: extras.description || "",
|
||||||
|
});
|
||||||
|
return apiImport("/api/maps/import", form);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importOverwriteMap(mapId, fileList) {
|
||||||
|
const { yamlFile, imageFile } = await matchRosMapFiles(fileList);
|
||||||
|
const form = buildFormData(yamlFile, imageFile);
|
||||||
|
return apiImport(`/api/maps/${encodeURIComponent(mapId)}/import`, form);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.MapImport = {
|
||||||
|
fileStem,
|
||||||
|
isYamlFile,
|
||||||
|
isMapImageFile,
|
||||||
|
matchRosMapFiles,
|
||||||
|
importNewMap,
|
||||||
|
importOverwriteMap,
|
||||||
|
};
|
||||||
|
})();
|
||||||
104
www/map-yaml.js
Normal file
104
www/map-yaml.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
(() => {
|
||||||
|
function stripComment(line) {
|
||||||
|
const pos = line.indexOf("#");
|
||||||
|
return pos === -1 ? line : line.slice(0, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trim(s) {
|
||||||
|
return String(s || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOriginArray(value) {
|
||||||
|
const start = value.indexOf("[");
|
||||||
|
const end = value.indexOf("]");
|
||||||
|
if (start === -1 || end === -1 || end <= start) return null;
|
||||||
|
const parts = value
|
||||||
|
.slice(start + 1, end)
|
||||||
|
.split(",")
|
||||||
|
.map((p) => Number(trim(p)))
|
||||||
|
.filter((n) => !Number.isNaN(n));
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
return { origin_x: parts[0], origin_y: parts[1], origin_yaw: parts[2] || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIntField(value) {
|
||||||
|
const n = parseInt(value, 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse ROS map_server yaml (resolution, origin, thresholds).
|
||||||
|
* @param {string} text
|
||||||
|
* @param {{ requireImage?: boolean }} opts
|
||||||
|
*/
|
||||||
|
function parse(text, opts = {}) {
|
||||||
|
const requireImage = !!opts.requireImage;
|
||||||
|
const out = {
|
||||||
|
image: "",
|
||||||
|
resolution: null,
|
||||||
|
origin_x: 0,
|
||||||
|
origin_y: 0,
|
||||||
|
origin_yaw: 0,
|
||||||
|
negate: 0,
|
||||||
|
occupied_thresh: 0.65,
|
||||||
|
free_thresh: 0.196,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of String(text || "").split(/\r?\n/)) {
|
||||||
|
const line = trim(stripComment(rawLine));
|
||||||
|
if (!line) continue;
|
||||||
|
const colon = line.indexOf(":");
|
||||||
|
if (colon === -1) continue;
|
||||||
|
const key = trim(line.slice(0, colon));
|
||||||
|
const value = trim(line.slice(colon + 1));
|
||||||
|
if (!value && key !== "image") continue;
|
||||||
|
|
||||||
|
if (key === "image") out.image = value;
|
||||||
|
else if (key === "resolution") out.resolution = parseNumber(value);
|
||||||
|
else if (key === "origin") {
|
||||||
|
const origin = parseOriginArray(value);
|
||||||
|
if (origin) Object.assign(out, origin);
|
||||||
|
} else if (key === "negate") {
|
||||||
|
const n = parseIntField(value);
|
||||||
|
if (n != null) out.negate = n;
|
||||||
|
} else if (key === "occupied_thresh") {
|
||||||
|
const n = parseNumber(value);
|
||||||
|
if (n != null) out.occupied_thresh = n;
|
||||||
|
} else if (key === "free_thresh") {
|
||||||
|
const n = parseNumber(value);
|
||||||
|
if (n != null) out.free_thresh = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireImage && !out.image) return { error: "yaml missing image field" };
|
||||||
|
if (out.resolution == null || out.resolution <= 0) return { error: "yaml missing resolution field" };
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(meta) {
|
||||||
|
const image = meta.image || "map.png";
|
||||||
|
const resolution = meta.resolution != null ? meta.resolution : 0.05;
|
||||||
|
const ox = meta.origin_x != null ? meta.origin_x : 0;
|
||||||
|
const oy = meta.origin_y != null ? meta.origin_y : 0;
|
||||||
|
const oyaw = meta.origin_yaw != null ? meta.origin_yaw : 0;
|
||||||
|
const negate = meta.negate != null ? meta.negate : 0;
|
||||||
|
const occ = meta.occupied_thresh != null ? meta.occupied_thresh : 0.65;
|
||||||
|
const free = meta.free_thresh != null ? meta.free_thresh : 0.196;
|
||||||
|
return [
|
||||||
|
`image: ${image}`,
|
||||||
|
`resolution: ${Number(resolution).toFixed(6)}`,
|
||||||
|
`origin: [${Number(ox).toFixed(6)}, ${Number(oy).toFixed(6)}, ${Number(oyaw).toFixed(6)}]`,
|
||||||
|
`negate: ${negate}`,
|
||||||
|
`occupied_thresh: ${Number(occ).toFixed(3)}`,
|
||||||
|
`free_thresh: ${Number(free).toFixed(3)}`,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.MapYaml = { parse, serialize };
|
||||||
|
})();
|
||||||
169
www/style.css
169
www/style.css
@@ -3245,11 +3245,11 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
|
|||||||
.mapsMirBtn svg { flex-shrink: 0; }
|
.mapsMirBtn svg { flex-shrink: 0; }
|
||||||
|
|
||||||
.mapsMirBtn--green {
|
.mapsMirBtn--green {
|
||||||
background: var(--mir-green);
|
background: var(--mir-green, #5cb85c);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapsMirBtn--green:hover { background: var(--mir-green-hover); }
|
.mapsMirBtn--green:hover { background: var(--mir-green-hover, #4cae4c); }
|
||||||
|
|
||||||
.mapsMirBtn--outline {
|
.mapsMirBtn--outline {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -3461,6 +3461,8 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mapsMirDialog {
|
.mapsMirDialog {
|
||||||
|
--mir-green: #5cb85c;
|
||||||
|
--mir-green-hover: #4cae4c;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -3472,7 +3474,8 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
|
|||||||
.mapsMirDialog::backdrop { background: rgba(0, 0, 0, 0.35); }
|
.mapsMirDialog::backdrop { background: rgba(0, 0, 0, 0.35); }
|
||||||
|
|
||||||
.mapsMirDialog form,
|
.mapsMirDialog form,
|
||||||
.mapsMirDialog--menu { padding: 20px 22px; }
|
.mapsMirDialog--menu,
|
||||||
|
.mapsMirDialogPanel { padding: 20px 22px; }
|
||||||
|
|
||||||
.mapsMirDialog--mapMenu {
|
.mapsMirDialog--mapMenu {
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
@@ -3681,6 +3684,8 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
|||||||
|
|
||||||
/* Map editor — MiR §4.2.3 Mapping tools */
|
/* Map editor — MiR §4.2.3 Mapping tools */
|
||||||
.mapEditorPage {
|
.mapEditorPage {
|
||||||
|
--mir-green: #5cb85c;
|
||||||
|
--mir-green-hover: #4cae4c;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -3856,6 +3861,8 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #b8b8b8;
|
background: #b8b8b8;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapEditorCanvasWrap.is-panning {
|
.mapEditorCanvasWrap.is-panning {
|
||||||
@@ -3887,8 +3894,9 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mapEditorViewport {
|
.mapEditorViewport {
|
||||||
position: absolute;
|
position: relative;
|
||||||
inset: 0;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3897,6 +3905,14 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layer 2 — image / floor plan space (1 CSS px = 1 image px). */
|
||||||
|
.mapEditorImageLayer {
|
||||||
|
position: relative;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapEditorSheet {
|
.mapEditorSheet {
|
||||||
@@ -3905,14 +3921,150 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
|||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorSheet--blank {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorSheet--hasImage {
|
||||||
|
background: #fff;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorSheetGrid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.45;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, rgba(0, 0, 0, 0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, rgba(0, 0, 0, 0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(to right, rgba(0, 0, 0, 0.18) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, rgba(0, 0, 0, 0.18) 1px, transparent 1px);
|
||||||
|
background-size:
|
||||||
|
var(--map-grid-minor, 20px) var(--map-grid-minor, 20px),
|
||||||
|
var(--map-grid-minor, 20px) var(--map-grid-minor, 20px),
|
||||||
|
var(--map-grid-major, 100px) var(--map-grid-major, 100px),
|
||||||
|
var(--map-grid-major, 100px) var(--map-grid-major, 100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorOrigin {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorOriginAxis {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorOriginAxis--x {
|
||||||
|
width: 36px;
|
||||||
|
height: 2px;
|
||||||
|
background: #e74c3c;
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorOriginAxis--y {
|
||||||
|
width: 2px;
|
||||||
|
height: 36px;
|
||||||
|
background: #27ae60;
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorOriginDot {
|
||||||
|
position: absolute;
|
||||||
|
left: -4px;
|
||||||
|
top: -4px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e67e22;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorOriginLabel {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: -22px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border: 1px solid rgba(230, 126, 34, 0.55);
|
||||||
|
color: #c0392b;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.3;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorOrigin--offMap .mapEditorOriginLabel {
|
||||||
|
color: #888;
|
||||||
|
border-color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapEditorImage {
|
.mapEditorImage {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: none;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: 100%;
|
||||||
|
max-width: none;
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorStatusBar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(32, 32, 32, 0.94);
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorStatusItem {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorStatusItem--view::before {
|
||||||
|
content: "View · ";
|
||||||
|
color: #8ab4f8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorStatusItem--image::before {
|
||||||
|
content: "Image · ";
|
||||||
|
color: #81c995;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapEditorStatusItem--world::before {
|
||||||
|
content: "World · ";
|
||||||
|
color: #f9ab00;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapEditorEmpty {
|
.mapEditorEmpty {
|
||||||
@@ -3932,7 +4084,8 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.auth-readonly-maps-page .mapsMirBtn--green,
|
body.auth-readonly-maps-page #mapsListView .mapsMirBtn--green,
|
||||||
|
body.auth-readonly-maps-page #mapsCreateView .mapsMirBtn--green,
|
||||||
body.auth-readonly-maps-page .mapsMirIconBtn[data-edit],
|
body.auth-readonly-maps-page .mapsMirIconBtn[data-edit],
|
||||||
body.auth-readonly-maps-page .mapsMirIconBtn[data-delete],
|
body.auth-readonly-maps-page .mapsMirIconBtn[data-delete],
|
||||||
body.auth-readonly-maps-page #mapsCreateOpenBtn,
|
body.auth-readonly-maps-page #mapsCreateOpenBtn,
|
||||||
|
|||||||
Reference in New Issue
Block a user