Xong phần map viewer
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-20 09:18:19 +02:00
parent 819323f8c8
commit 90e8e9d252
13 changed files with 431 additions and 32 deletions

4
.gitignore vendored
View File

@@ -15,6 +15,10 @@
build/ build/
# Runtime data (SQLite + media) # Runtime data (SQLite + media)
maps/
RBS.db
RBS.db-wal
RBS.db-shm
data/RBS.db data/RBS.db
data/RBS.db-wal data/RBS.db-wal
data/RBS.db-shm data/RBS.db-shm

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,6 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,6 +0,0 @@
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

@@ -21,6 +21,24 @@
namespace lm { namespace lm {
namespace {
bool isProjectRootDataPath(const std::filesystem::path& parent)
{
return parent.empty() || parent == std::filesystem::path(".");
}
std::filesystem::path resolveDataPath(std::filesystem::path data_path)
{
if (!isProjectRootDataPath(data_path.parent_path()))
return data_path;
if (data_path.filename() == "state.json")
return std::filesystem::path("data") / "state.json";
return std::filesystem::path("data") / "RBS.db";
}
} // namespace
LidarManagerApp::LidarManagerApp(int port, LidarManagerApp::LidarManagerApp(int port,
std::filesystem::path www_root, std::filesystem::path www_root,
std::filesystem::path data_path) std::filesystem::path data_path)
@@ -30,6 +48,7 @@ LidarManagerApp::LidarManagerApp(int port,
int LidarManagerApp::run() int LidarManagerApp::run()
{ {
data_path_ = resolveDataPath(data_path_);
const std::filesystem::path data_dir = data_path_.parent_path(); const std::filesystem::path data_dir = data_path_.parent_path();
Database database(data_dir); Database database(data_dir);
std::string db_err; std::string db_err;

View File

@@ -193,6 +193,48 @@ bool Database::ensureDataDirs(std::string& err)
return true; return true;
} }
bool Database::migrateLegacyMapsDir(std::string& err)
{
if (data_dir_.filename() != "data")
return true;
const std::filesystem::path legacy = "maps";
const auto target = mapsDir();
if (!std::filesystem::is_directory(legacy) || legacy == target)
return true;
std::error_code ec;
std::filesystem::create_directories(target, ec);
if (ec)
{
err = "failed to create maps directory: " + target.string();
return false;
}
for (const auto& entry : std::filesystem::directory_iterator(legacy, ec))
{
if (ec || !entry.is_directory())
continue;
const auto dest = target / entry.path().filename();
if (std::filesystem::exists(dest))
continue;
std::filesystem::rename(entry.path(), dest, ec);
if (ec)
{
ec.clear();
std::filesystem::copy(entry.path(), dest, std::filesystem::copy_options::recursive, ec);
if (ec)
{
err = "failed to migrate map directory: " + entry.path().string();
return false;
}
std::filesystem::remove_all(entry.path(), ec);
ec.clear();
}
}
return true;
}
bool Database::applySchema(std::string& err) bool Database::applySchema(std::string& err)
{ {
return execSql(db_, kSchemaSql, err); return execSql(db_, kSchemaSql, err);
@@ -494,6 +536,8 @@ bool Database::init(std::string& err)
return false; return false;
if (!ensureDataDirs(err)) if (!ensureDataDirs(err))
return false; return false;
if (!migrateLegacyMapsDir(err))
return false;
if (!migrateFromJsonIfNeeded(err)) if (!migrateFromJsonIfNeeded(err))
return false; return false;
if (!getMeta("schema_version")) if (!getMeta("schema_version"))

View File

@@ -45,6 +45,7 @@ private:
bool applySchemaMigrations(std::string& err); bool applySchemaMigrations(std::string& err);
bool migrateFromJsonIfNeeded(std::string& err); bool migrateFromJsonIfNeeded(std::string& err);
bool ensureDataDirs(std::string& err); bool ensureDataDirs(std::string& err);
bool migrateLegacyMapsDir(std::string& err);
std::optional<std::string> getMeta(const std::string& key) const; std::optional<std::string> getMeta(const std::string& key) const;
bool setMeta(const std::string& key, const std::string& value); bool setMeta(const std::string& key, const std::string& value);
}; };

View File

@@ -389,6 +389,7 @@
"maps.editor.tool.save": "Lưu map", "maps.editor.tool.save": "Lưu map",
"maps.editor.tool.pan": "Pan — di chuyển vùng nhìn", "maps.editor.tool.pan": "Pan — di chuyển vùng nhìn",
"maps.editor.tool.crosshair": "Crosshair", "maps.editor.tool.crosshair": "Crosshair",
"maps.editor.tool.origin": "Hiển thị gốc tọa độ",
"maps.editor.tool.center": "Căn giữa vùng nhìn", "maps.editor.tool.center": "Căn giữa vùng nhìn",
"maps.editor.tool.lidar": "Hiển thị LiDAR", "maps.editor.tool.lidar": "Hiển thị LiDAR",
"maps.editor.tool.waypoints": "Vị trí / waypoint", "maps.editor.tool.waypoints": "Vị trí / waypoint",
@@ -882,7 +883,7 @@
"maps.uploadConfirm.title": "Overwrite floor plan?", "maps.uploadConfirm.title": "Overwrite floor plan?",
"maps.uploadConfirm.text": "The current map image will be replaced. Continue?", "maps.uploadConfirm.text": "The current map image will be replaced. Continue?",
"maps.uploadConfirm.yes": "Overwrite", "maps.uploadConfirm.yes": "Overwrite",
"maps.uploadMeta.title": "Map metadata (ROS)", "maps.uploadMeta.title": "Map metadata",
"maps.uploadMeta.hint": "Enter origin, resolution, and occupancy thresholds — or import a .yaml file.", "maps.uploadMeta.hint": "Enter origin, resolution, and occupancy thresholds — or import a .yaml file.",
"maps.uploadMeta.importYaml": "Import YAML file…", "maps.uploadMeta.importYaml": "Import YAML file…",
"maps.uploadMeta.negate": "Negate", "maps.uploadMeta.negate": "Negate",
@@ -908,6 +909,7 @@
"maps.editor.tool.save": "Save map", "maps.editor.tool.save": "Save map",
"maps.editor.tool.pan": "Pan — move view", "maps.editor.tool.pan": "Pan — move view",
"maps.editor.tool.crosshair": "Crosshair", "maps.editor.tool.crosshair": "Crosshair",
"maps.editor.tool.origin": "Show map origin",
"maps.editor.tool.center": "Center view", "maps.editor.tool.center": "Center view",
"maps.editor.tool.lidar": "LiDAR overlay", "maps.editor.tool.lidar": "LiDAR overlay",
"maps.editor.tool.waypoints": "Positions", "maps.editor.tool.waypoints": "Positions",

View File

@@ -1016,6 +1016,9 @@
<button type="button" class="mapEditorMapTool" id="mapEditorCrosshairBtn" disabled data-i18n-title="maps.editor.tool.crosshair" title="Crosshair"> <button type="button" class="mapEditorMapTool" id="mapEditorCrosshairBtn" disabled data-i18n-title="maps.editor.tool.crosshair" title="Crosshair">
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="1.3"/></svg> <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="1.3"/></svg>
</button> </button>
<button type="button" class="mapEditorMapTool is-active" id="mapEditorOriginBtn" data-i18n-title="maps.editor.tool.origin" title="Show origin" aria-pressed="true">
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="2.2" fill="currentColor"/><path d="M10 10h7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M10 10V2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
<button type="button" class="mapEditorMapTool" id="mapEditorFitBtn" data-i18n-title="maps.editor.fit" title="Fit"> <button type="button" class="mapEditorMapTool" id="mapEditorFitBtn" data-i18n-title="maps.editor.fit" title="Fit">
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M3 7V3h4M13 3h4v4M17 13h-4v4M7 17H3v-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M3 7V3h4M13 3h4v4M17 13h-4v4M7 17H3v-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button> </button>
@@ -1045,12 +1048,14 @@
<div class="mapEditorImageLayer" id="mapEditorImageLayer"> <div class="mapEditorImageLayer" id="mapEditorImageLayer">
<div class="mapEditorSheet" id="mapEditorSheet"> <div class="mapEditorSheet" id="mapEditorSheet">
<div class="mapEditorSheetGrid" id="mapEditorSheetGrid" aria-hidden="true"></div> <div class="mapEditorSheetGrid" id="mapEditorSheetGrid" aria-hidden="true"></div>
<img id="mapEditorImage" class="mapEditorImage" alt="" draggable="false" hidden /> <img id="mapEditorImage" class="mapEditorImageLoader" alt="" draggable="false" hidden />
<canvas id="mapEditorOccupancyCanvas" class="mapEditorOccupancyCanvas" hidden aria-hidden="true"></canvas>
<div id="mapEditorOrigin" class="mapEditorOrigin" hidden aria-hidden="true"> <div id="mapEditorOrigin" class="mapEditorOrigin" hidden aria-hidden="true">
<span class="mapEditorOriginAxis mapEditorOriginAxis--x" aria-hidden="true"></span> <span class="mapEditorOriginAxis mapEditorOriginAxis--x" aria-hidden="true"></span>
<span class="mapEditorOriginAxis mapEditorOriginAxis--y" aria-hidden="true"></span> <span class="mapEditorOriginAxis mapEditorOriginAxis--z" aria-hidden="true"></span>
<span class="mapEditorOriginDot" aria-hidden="true"></span> <span class="mapEditorOriginDot" aria-hidden="true"></span>
<span class="mapEditorOriginLabel" id="mapEditorOriginLabel"></span> <button type="button" class="mapEditorOriginHit" id="mapEditorOriginHit" tabindex="0" aria-describedby="mapEditorOriginLabel"></button>
<span class="mapEditorOriginLabel" id="mapEditorOriginLabel" aria-hidden="true"></span>
</div> </div>
<div id="mapEditorEmpty" class="mapEditorEmpty" hidden data-i18n="maps.editor.noData">No map data — open ⋮ menu to upload a PNG.</div> <div id="mapEditorEmpty" class="mapEditorEmpty" hidden data-i18n="maps.editor.noData">No map data — open ⋮ menu to upload a PNG.</div>
</div> </div>
@@ -1723,6 +1728,7 @@ GET /api/v2.0.0/status</pre>
<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-geo.js"></script>
<script src="/map-occupancy-canvas.js"></script>
<script src="/map-yaml.js"></script> <script src="/map-yaml.js"></script>
<script src="/maps.js"></script> <script src="/maps.js"></script>
<script src="/map-editor.js"></script> <script src="/map-editor.js"></script>

View File

@@ -2,6 +2,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 Geo = () => window.MapGeo;
const Occ = () => window.MapOccupancyCanvas;
const state = { const state = {
mapId: null, mapId: null,
@@ -14,6 +15,9 @@
view: Geo()?.createView(1, 0, 0) || { scale: 1, panX: 0, panY: 0 }, view: Geo()?.createView(1, 0, 0) || { scale: 1, panX: 0, panY: 0 },
panning: null, panning: null,
tipVisible: true, tipVisible: true,
showOrigin: true,
/** ROS yaml thresholds (occupied_thresh, free_thresh, negate) from map.yaml. */
yamlMeta: null,
/** Pending ROS metadata from upload dialog (set before PNG picker). */ /** Pending ROS metadata from upload dialog (set before PNG picker). */
uploadMeta: null, uploadMeta: null,
}; };
@@ -27,7 +31,10 @@
const sheetEl = el("mapEditorSheet"); const sheetEl = el("mapEditorSheet");
const gridEl = el("mapEditorSheetGrid"); const gridEl = el("mapEditorSheetGrid");
const imageEl = el("mapEditorImage"); const imageEl = el("mapEditorImage");
const occupancyCanvasEl = el("mapEditorOccupancyCanvas");
const originEl = el("mapEditorOrigin"); const originEl = el("mapEditorOrigin");
const originHitEl = el("mapEditorOriginHit");
const originLabelEl = el("mapEditorOriginLabel");
const emptyEl = el("mapEditorEmpty"); const emptyEl = el("mapEditorEmpty");
const tipEl = el("mapEditorCanvasTip"); const tipEl = el("mapEditorCanvasTip");
const statusViewEl = el("mapEditorStatusView"); const statusViewEl = el("mapEditorStatusView");
@@ -94,7 +101,58 @@
} }
function hasFloorPlan() { function hasFloorPlan() {
return !!(state.map?.image_file && imageEl && !imageEl.hidden && imageEl.naturalWidth); return !!(
state.map?.image_file &&
imageEl &&
!imageEl.hidden &&
imageEl.naturalWidth &&
occupancyCanvasEl &&
!occupancyCanvasEl.hidden &&
occupancyCanvasEl.width
);
}
/** ROS yaml thresholds for occupancy coloring. */
function mapRenderMeta() {
const base = mapMetaForOriginDisplay() || state.map || {};
const yaml = state.yamlMeta || {};
return {
occupied_thresh:
base.occupied_thresh != null ? base.occupied_thresh : yaml.occupied_thresh,
free_thresh: base.free_thresh != null ? base.free_thresh : yaml.free_thresh,
negate: base.negate != null ? base.negate : yaml.negate,
};
}
async function loadYamlMeta() {
state.yamlMeta = await fetchExistingYamlMeta();
}
function setOccupancyCanvasVisible(visible) {
if (!occupancyCanvasEl) return;
occupancyCanvasEl.hidden = !visible;
occupancyCanvasEl.setAttribute("aria-hidden", visible ? "false" : "true");
}
/** Paint RViz-style occupancy colors from loaded PNG (hidden loader img). */
function paintOccupancyFromImage() {
const occ = Occ();
if (!occ || !occupancyCanvasEl || !imageEl?.naturalWidth) return false;
const ok = occ.renderFromImage(occupancyCanvasEl, imageEl, mapRenderMeta());
setOccupancyCanvasVisible(ok);
return ok;
}
/**
* Paint live occupancy grid (record/stream — roadmap step 2+).
* @param {{ width: number, height: number, data: number[]|Int8Array|string }} grid
*/
function paintOccupancyGrid(grid) {
const occ = Occ();
if (!occ || !occupancyCanvasEl || !grid) return false;
const ok = occ.renderGrid(occupancyCanvasEl, grid);
if (ok) setOccupancyCanvasVisible(true);
return ok;
} }
function setDirty(flag) { function setDirty(flag) {
@@ -204,7 +262,6 @@
gridEl.hidden = true; gridEl.hidden = true;
} }
if (originEl) originEl.hidden = !has;
updateOriginMarker(); updateOriginMarker();
updateStatusBar(); updateStatusBar();
} }
@@ -224,11 +281,25 @@
return base; return base;
} }
function setShowOrigin(show) {
state.showOrigin = !!show;
const btn = el("mapEditorOriginBtn");
btn?.classList.toggle("is-active", state.showOrigin);
btn?.setAttribute("aria-pressed", state.showOrigin ? "true" : "false");
updateOriginMarker();
}
function setOriginLabelVisible(visible) {
if (!originEl) return;
originEl.classList.toggle("mapEditorOrigin--showLabel", !!visible);
if (originLabelEl) originLabelEl.setAttribute("aria-hidden", visible ? "false" : "true");
}
function updateOriginMarker() { function updateOriginMarker() {
if (!originEl) return; if (!originEl) return;
const geo = Geo(); const geo = Geo();
const { width, height } = floorPlanSize(); const { width, height } = floorPlanSize();
if (!geo || !hasFloorPlan() || !width || !height) { if (!state.showOrigin || !geo || !hasFloorPlan() || !width || !height) {
originEl.hidden = true; originEl.hidden = true;
originEl.setAttribute("aria-hidden", "true"); originEl.setAttribute("aria-hidden", "true");
return; return;
@@ -249,18 +320,23 @@
originEl.style.top = `${pt.y}px`; originEl.style.top = `${pt.y}px`;
originEl.style.transform = `rotate(${yawDeg}deg)`; originEl.style.transform = `rotate(${yawDeg}deg)`;
const labelEl = el("mapEditorOriginLabel"); if (originLabelEl) {
if (labelEl) { originLabelEl.textContent = t("maps.editor.originLabelShort", {
labelEl.textContent = t("maps.editor.originLabelShort", {
x: ox.toFixed(2), x: ox.toFixed(2),
y: oy.toFixed(2), y: oy.toFixed(2),
}); });
originLabelEl.setAttribute("aria-hidden", "true");
} }
originEl.title = t("maps.editor.originTooltip", { const tooltip = t("maps.editor.originTooltip", {
x: ox.toFixed(3), x: ox.toFixed(3),
y: oy.toFixed(3), y: oy.toFixed(3),
yaw: ((oyaw * 180) / Math.PI).toFixed(1), yaw: ((oyaw * 180) / Math.PI).toFixed(1),
}); });
if (originHitEl) {
originHitEl.title = tooltip;
originHitEl.setAttribute("aria-label", tooltip);
}
setOriginLabelVisible(false);
} }
function updateStatusBar(pointerClient) { function updateStatusBar(pointerClient) {
@@ -311,11 +387,13 @@
imageEl.src = url; imageEl.src = url;
imageEl.hidden = false; imageEl.hidden = false;
if (emptyEl) emptyEl.hidden = true; if (emptyEl) emptyEl.hidden = true;
setOccupancyCanvasVisible(false);
} else { } else {
if (imageEl) { if (imageEl) {
imageEl.hidden = true; imageEl.hidden = true;
imageEl.removeAttribute("src"); imageEl.removeAttribute("src");
} }
setOccupancyCanvasVisible(false);
if (emptyEl) emptyEl.hidden = false; if (emptyEl) emptyEl.hidden = false;
} }
updateMenuActionsUi(); updateMenuActionsUi();
@@ -323,6 +401,7 @@
imageEl?.addEventListener( imageEl?.addEventListener(
"load", "load",
() => { () => {
paintOccupancyFromImage();
updateImageLayer(); updateImageLayer();
fitToView(); fitToView();
}, },
@@ -381,6 +460,7 @@
async function reloadMap() { async function reloadMap() {
if (!state.mapId) return; if (!state.mapId) return;
state.map = await api(`/api/maps/${encodeURIComponent(state.mapId)}`); state.map = await api(`/api/maps/${encodeURIComponent(state.mapId)}`);
await loadYamlMeta();
updateHeader(); updateHeader();
renderMapImage(); renderMapImage();
fillSettingsForm(); fillSettingsForm();
@@ -392,6 +472,7 @@
state.readOnly = !!callbacks.readOnly || !callbacks.canWrite; state.readOnly = !!callbacks.readOnly || !callbacks.canWrite;
state.dirty = false; state.dirty = false;
state.tipVisible = true; state.tipVisible = true;
state.showOrigin = true;
state.activeTool = "pan"; state.activeTool = "pan";
state.view = Geo()?.createView(1, 0, 0) || { scale: 1, panX: 0, panY: 0 }; state.view = Geo()?.createView(1, 0, 0) || { scale: 1, panX: 0, panY: 0 };
if (tipEl) { if (tipEl) {
@@ -399,6 +480,7 @@
tipEl.textContent = t("maps.editor.canvasTip"); tipEl.textContent = t("maps.editor.canvasTip");
} }
setActiveTool("pan"); setActiveTool("pan");
setShowOrigin(true);
setDirty(false); setDirty(false);
applyReadOnlyUi(); applyReadOnlyUi();
reloadMap().catch((e) => alert(e.message)); reloadMap().catch((e) => alert(e.message));
@@ -409,6 +491,7 @@
state.map = null; state.map = null;
state.callbacks = {}; state.callbacks = {};
state.uploadMeta = null; state.uploadMeta = null;
state.yamlMeta = null;
menuDialogEl?.close(); menuDialogEl?.close();
settingsDialogEl?.close(); settingsDialogEl?.close();
activateDialogEl?.close(); activateDialogEl?.close();
@@ -584,6 +667,7 @@
state.callbacks.onMapUpdated?.(updated); state.callbacks.onMapUpdated?.(updated);
setDirty(false); setDirty(false);
fillSettingsForm(); fillSettingsForm();
await loadYamlMeta();
renderMapImage(); renderMapImage();
menuDialogEl?.close(); menuDialogEl?.close();
uploadMetaDialogEl?.close(); uploadMetaDialogEl?.close();
@@ -689,6 +773,11 @@
} }
function bindEvents() { function bindEvents() {
originHitEl?.addEventListener("mouseenter", () => setOriginLabelVisible(true));
originHitEl?.addEventListener("mouseleave", () => setOriginLabelVisible(false));
originHitEl?.addEventListener("focus", () => setOriginLabelVisible(true));
originHitEl?.addEventListener("blur", () => setOriginLabelVisible(false));
el("mapEditorBackBtn")?.addEventListener("click", () => { el("mapEditorBackBtn")?.addEventListener("click", () => {
if (state.dirty && !confirm(t("maps.editor.unsavedLeave"))) return; if (state.dirty && !confirm(t("maps.editor.unsavedLeave"))) return;
state.callbacks.onClose?.(); state.callbacks.onClose?.();
@@ -714,6 +803,7 @@
saveMap().catch((e) => alert(e.message)); saveMap().catch((e) => alert(e.message));
}); });
el("mapEditorPanBtn")?.addEventListener("click", () => setActiveTool("pan")); el("mapEditorPanBtn")?.addEventListener("click", () => setActiveTool("pan"));
el("mapEditorOriginBtn")?.addEventListener("click", () => setShowOrigin(!state.showOrigin));
el("mapEditorFitBtn")?.addEventListener("click", fitToView); el("mapEditorFitBtn")?.addEventListener("click", fitToView);
el("mapEditorCenterBtn")?.addEventListener("click", () => { el("mapEditorCenterBtn")?.addEventListener("click", () => {
dismissCanvasTip(); dismissCanvasTip();
@@ -818,6 +908,9 @@
Object.values(uploadMetaFields).forEach((node) => { Object.values(uploadMetaFields).forEach((node) => {
node?.addEventListener("input", () => { node?.addEventListener("input", () => {
updateOriginMarker(); updateOriginMarker();
if (uploadMetaDialogEl?.open && imageEl?.naturalWidth) {
paintOccupancyFromImage();
}
if (node === uploadMetaFields.resolution && uploadMetaDialogEl?.open) { if (node === uploadMetaFields.resolution && uploadMetaDialogEl?.open) {
updateImageLayer(); updateImageLayer();
} }
@@ -835,5 +928,11 @@
bindCanvasPanZoom(); bindCanvasPanZoom();
bindEvents(); bindEvents();
window.MapEditorApp = { open, close, reloadMap }; window.MapEditorApp = {
open,
close,
reloadMap,
paintOccupancyGrid,
paintOccupancyFromImage,
};
})(); })();

178
www/map-occupancy-canvas.js Normal file
View File

@@ -0,0 +1,178 @@
(() => {
/**
* RViz-style occupancy grid renderer for map view (Canvas 2D).
*
* ROS map_server / nav_msgs/OccupancyGrid:
* 0 = free
* 100 = occupied
* -1 = unknown
*
* Grid data is row-major with index 0 at the bottom-left cell (world +Y up).
* Canvas pixels use top-left origin — Y is flipped when painting.
*/
const DEFAULT_PALETTE = {
free: [254, 254, 254, 255],
occupied: [0, 0, 0, 255],
unknown: [180, 180, 180, 255],
};
const DEFAULT_THRESHOLDS = {
occupied_thresh: 0.65,
free_thresh: 0.196,
negate: 0,
};
function metaThresholds(meta) {
return {
occupied_thresh: Number(meta?.occupied_thresh ?? DEFAULT_THRESHOLDS.occupied_thresh),
free_thresh: Number(meta?.free_thresh ?? DEFAULT_THRESHOLDS.free_thresh),
negate: Number(meta?.negate ?? DEFAULT_THRESHOLDS.negate),
};
}
/** Trinary mode (Nav2/map_server): darkness or lightness vs thresholds in [0, 1]. */
function grayToOccValue(gray, meta) {
const { occupied_thresh, free_thresh, negate } = metaThresholds(meta);
const lightness = gray / 255;
const probability = negate ? lightness : 1 - lightness;
if (probability > occupied_thresh) return 100;
if (probability < free_thresh) return 0;
return -1;
}
function occToRgba(value, palette) {
if (value < 0) return palette.unknown;
if (value >= 100) return palette.occupied;
if (value === 0) return palette.free;
return value > 50 ? palette.occupied : palette.free;
}
function paletteFrom(opts) {
const p = opts?.palette || {};
return {
free: p.free || DEFAULT_PALETTE.free,
occupied: p.occupied || DEFAULT_PALETTE.occupied,
unknown: p.unknown || DEFAULT_PALETTE.unknown,
};
}
function ensureCanvasSize(canvas, width, height) {
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
function decodeGridData(data) {
if (data instanceof Int8Array || data instanceof Uint8Array) return data;
if (Array.isArray(data)) return data;
if (typeof data === "string" && data.length) {
try {
const binary = atob(data);
const out = new Int8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
} catch {
return [];
}
}
return [];
}
/**
* Paint ROS occupancy grid onto canvas.
* @param {HTMLCanvasElement} canvas
* @param {{ width: number, height: number, data: number[]|Int8Array|string }} grid
* @param {{ palette?: object }} [opts]
*/
function renderGrid(canvas, grid, opts = {}) {
if (!canvas || !grid) return false;
const width = Number(grid.width) | 0;
const height = Number(grid.height) | 0;
if (!width || !height) return false;
const data = decodeGridData(grid.data);
if (!data.length) return false;
ensureCanvasSize(canvas, width, height);
const ctx = canvas.getContext("2d");
if (!ctx) return false;
const palette = paletteFrom(opts);
const imageData = ctx.createImageData(width, height);
const px = imageData.data;
for (let row = 0; row < height; row++) {
const srcRow = height - 1 - row;
for (let col = 0; col < width; col++) {
const idx = srcRow * width + col;
const value = idx < data.length ? data[idx] : -1;
const rgba = occToRgba(value, palette);
const dst = (row * width + col) * 4;
px[dst] = rgba[0];
px[dst + 1] = rgba[1];
px[dst + 2] = rgba[2];
px[dst + 3] = rgba[3];
}
}
ctx.putImageData(imageData, 0, 0);
return true;
}
/**
* Convert map_server grayscale image to RViz-style occupancy colors.
* @param {HTMLCanvasElement} canvas
* @param {CanvasImageSource} source
* @param {object} [meta] — negate, occupied_thresh, free_thresh
* @param {{ palette?: object }} [opts]
*/
function renderFromImage(canvas, source, meta = {}, opts = {}) {
if (!canvas || !source) return false;
const width = source.naturalWidth || source.videoWidth || source.width;
const height = source.naturalHeight || source.videoHeight || source.height;
if (!width || !height) return false;
ensureCanvasSize(canvas, width, height);
const ctx = canvas.getContext("2d");
if (!ctx) return false;
const scratch = document.createElement("canvas");
scratch.width = width;
scratch.height = height;
const sctx = scratch.getContext("2d");
if (!sctx) return false;
sctx.drawImage(source, 0, 0, width, height);
const sampled = sctx.getImageData(0, 0, width, height);
const src = sampled.data;
const palette = paletteFrom(opts);
const imageData = ctx.createImageData(width, height);
const dst = imageData.data;
const thresholds = metaThresholds(meta);
for (let i = 0, p = 0; p < width * height; p++, i += 4) {
const gray = src[i];
const occ = grayToOccValue(gray, thresholds);
const rgba = occToRgba(occ, palette);
dst[i] = rgba[0];
dst[i + 1] = rgba[1];
dst[i + 2] = rgba[2];
dst[i + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
return true;
}
window.MapOccupancyCanvas = {
DEFAULT_PALETTE,
DEFAULT_THRESHOLDS,
grayToOccValue,
occToRgba,
decodeGridData,
renderGrid,
renderFromImage,
};
})();

View File

@@ -2227,7 +2227,7 @@ body.dashboard-widget-dragging .dashboardWidgetHeader {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background: #d8dde4; background: #d8dde4;
border-radius: 2px; border-radius: 0;
} }
.dashboardWidget--operate.dashboardWidget--map .dashboardWidgetBody, .dashboardWidget--operate.dashboardWidget--map .dashboardWidgetBody,
.dashboardWidget--operate.dashboardWidget--map_locked .dashboardWidgetBody { .dashboardWidget--operate.dashboardWidget--map_locked .dashboardWidgetBody {
@@ -3863,6 +3863,7 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
background: #b8b8b8; background: #b8b8b8;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-radius: 0;
} }
.mapEditorCanvasWrap.is-panning { .mapEditorCanvasWrap.is-panning {
@@ -3898,6 +3899,7 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
border-radius: 0;
} }
.mapEditorCanvasInner { .mapEditorCanvasInner {
@@ -3919,9 +3921,9 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
position: relative; position: relative;
min-width: 480px; min-width: 480px;
min-height: 360px; min-height: 360px;
background: #fff; background: transparent;
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.12);
user-select: none; user-select: none;
border-radius: 0;
} }
.mapEditorSheet--blank { .mapEditorSheet--blank {
@@ -3929,9 +3931,10 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
} }
.mapEditorSheet--hasImage { .mapEditorSheet--hasImage {
background: #fff; background: transparent;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
box-shadow: none;
} }
.mapEditorSheetGrid { .mapEditorSheetGrid {
@@ -3967,18 +3970,23 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
left: 0; left: 0;
top: 0; top: 0;
transform-origin: 0 0; transform-origin: 0 0;
pointer-events: none;
} }
.mapEditorOriginAxis--x { .mapEditorOriginAxis--x {
width: 36px; width: 36px;
height: 2px; height: 2px;
top: -1px;
left: 0;
background: #e74c3c; background: #e74c3c;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6);
} }
.mapEditorOriginAxis--y { .mapEditorOriginAxis--z {
width: 2px; width: 2px;
height: 36px; height: 36px;
left: -1px;
top: -36px;
background: #27ae60; background: #27ae60;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6);
} }
@@ -3993,12 +4001,41 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
background: #e67e22; background: #e67e22;
border: 2px solid #fff; border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
pointer-events: none;
}
.mapEditorOriginHit {
position: absolute;
left: -18px;
top: -18px;
width: 36px;
height: 36px;
padding: 0;
margin: 0;
border: none;
border-radius: 0;
background: transparent;
pointer-events: auto;
cursor: help;
z-index: 5;
appearance: none;
font: inherit;
color: inherit;
}
.mapEditorOriginHit:focus {
outline: none;
}
.mapEditorOriginHit:focus-visible {
outline: 2px solid rgba(230, 126, 34, 0.85);
outline-offset: 2px;
} }
.mapEditorOriginLabel { .mapEditorOriginLabel {
position: absolute; position: absolute;
left: 8px; left: 8px;
top: -22px; top: -48px;
padding: 2px 6px; padding: 2px 6px;
border-radius: 3px; border-radius: 3px;
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
@@ -4009,6 +4046,17 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
white-space: nowrap; white-space: nowrap;
line-height: 1.3; line-height: 1.3;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
pointer-events: none;
visibility: hidden;
opacity: 0;
transition: opacity 0.12s ease;
}
.mapEditorOrigin:has(.mapEditorOriginHit:hover) .mapEditorOriginLabel,
.mapEditorOrigin:has(.mapEditorOriginHit:focus-visible) .mapEditorOriginLabel,
.mapEditorOrigin.mapEditorOrigin--showLabel .mapEditorOriginLabel {
visibility: visible;
opacity: 1;
} }
.mapEditorOrigin--offMap .mapEditorOriginLabel { .mapEditorOrigin--offMap .mapEditorOriginLabel {
@@ -4016,17 +4064,27 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
border-color: #ccc; border-color: #ccc;
} }
.mapEditorImage { .mapEditorImageLoader {
position: absolute;
width: 0;
height: 0;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.mapEditorOccupancyCanvas {
display: block; display: block;
position: relative; position: relative;
z-index: 1; z-index: 1;
width: 100%; width: 100%;
height: 100%; height: 100%;
max-width: none; max-width: none;
border-radius: 0;
background: transparent;
image-rendering: pixelated; image-rendering: pixelated;
image-rendering: crisp-edges; image-rendering: crisp-edges;
user-select: none; user-select: none;
-webkit-user-drag: none;
pointer-events: none; pointer-events: none;
} }