add function map viewer
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-20 10:53:49 +07:00
parent a6cf06d7eb
commit 819323f8c8
22 changed files with 3332 additions and 67 deletions

BIN
RBS.db Normal file

Binary file not shown.

BIN
RBS.db-shm Normal file

Binary file not shown.

BIN
RBS.db-wal Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View 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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

212
src/util/map_image_util.cpp Normal file
View 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

View 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
View 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
View 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

View File

@@ -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",

View File

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

View File

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

View File

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