This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -15,6 +15,10 @@
|
||||
build/
|
||||
|
||||
# Runtime data (SQLite + media)
|
||||
maps/
|
||||
RBS.db
|
||||
RBS.db-wal
|
||||
RBS.db-shm
|
||||
data/RBS.db
|
||||
data/RBS.db-wal
|
||||
data/RBS.db-shm
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
@@ -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 |
@@ -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
|
||||
@@ -21,6 +21,24 @@
|
||||
|
||||
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,
|
||||
std::filesystem::path www_root,
|
||||
std::filesystem::path data_path)
|
||||
@@ -30,6 +48,7 @@ LidarManagerApp::LidarManagerApp(int port,
|
||||
|
||||
int LidarManagerApp::run()
|
||||
{
|
||||
data_path_ = resolveDataPath(data_path_);
|
||||
const std::filesystem::path data_dir = data_path_.parent_path();
|
||||
Database database(data_dir);
|
||||
std::string db_err;
|
||||
|
||||
@@ -193,6 +193,48 @@ bool Database::ensureDataDirs(std::string& err)
|
||||
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)
|
||||
{
|
||||
return execSql(db_, kSchemaSql, err);
|
||||
@@ -494,6 +536,8 @@ bool Database::init(std::string& err)
|
||||
return false;
|
||||
if (!ensureDataDirs(err))
|
||||
return false;
|
||||
if (!migrateLegacyMapsDir(err))
|
||||
return false;
|
||||
if (!migrateFromJsonIfNeeded(err))
|
||||
return false;
|
||||
if (!getMeta("schema_version"))
|
||||
|
||||
@@ -45,6 +45,7 @@ private:
|
||||
bool applySchemaMigrations(std::string& err);
|
||||
bool migrateFromJsonIfNeeded(std::string& err);
|
||||
bool ensureDataDirs(std::string& err);
|
||||
bool migrateLegacyMapsDir(std::string& err);
|
||||
std::optional<std::string> getMeta(const std::string& key) const;
|
||||
bool setMeta(const std::string& key, const std::string& value);
|
||||
};
|
||||
|
||||
@@ -389,6 +389,7 @@
|
||||
"maps.editor.tool.save": "Lưu map",
|
||||
"maps.editor.tool.pan": "Pan — di chuyển vùng nhìn",
|
||||
"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.lidar": "Hiển thị LiDAR",
|
||||
"maps.editor.tool.waypoints": "Vị trí / waypoint",
|
||||
@@ -882,7 +883,7 @@
|
||||
"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.title": "Map metadata",
|
||||
"maps.uploadMeta.hint": "Enter origin, resolution, and occupancy thresholds — or import a .yaml file.",
|
||||
"maps.uploadMeta.importYaml": "Import YAML file…",
|
||||
"maps.uploadMeta.negate": "Negate",
|
||||
@@ -908,6 +909,7 @@
|
||||
"maps.editor.tool.save": "Save map",
|
||||
"maps.editor.tool.pan": "Pan — move view",
|
||||
"maps.editor.tool.crosshair": "Crosshair",
|
||||
"maps.editor.tool.origin": "Show map origin",
|
||||
"maps.editor.tool.center": "Center view",
|
||||
"maps.editor.tool.lidar": "LiDAR overlay",
|
||||
"maps.editor.tool.waypoints": "Positions",
|
||||
|
||||
@@ -1016,6 +1016,9 @@
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
@@ -1045,12 +1048,14 @@
|
||||
<div class="mapEditorImageLayer" id="mapEditorImageLayer">
|
||||
<div class="mapEditorSheet" id="mapEditorSheet">
|
||||
<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">
|
||||
<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="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 id="mapEditorEmpty" class="mapEditorEmpty" hidden data-i18n="maps.editor.noData">No map data — open ⋮ menu to upload a PNG.</div>
|
||||
</div>
|
||||
@@ -1723,6 +1728,7 @@ GET /api/v2.0.0/status</pre>
|
||||
<script src="/nav.js"></script>
|
||||
<script src="/missions.js"></script>
|
||||
<script src="/map-geo.js"></script>
|
||||
<script src="/map-occupancy-canvas.js"></script>
|
||||
<script src="/map-yaml.js"></script>
|
||||
<script src="/maps.js"></script>
|
||||
<script src="/map-editor.js"></script>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
const Geo = () => window.MapGeo;
|
||||
const Occ = () => window.MapOccupancyCanvas;
|
||||
|
||||
const state = {
|
||||
mapId: null,
|
||||
@@ -14,6 +15,9 @@
|
||||
view: Geo()?.createView(1, 0, 0) || { scale: 1, panX: 0, panY: 0 },
|
||||
panning: null,
|
||||
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). */
|
||||
uploadMeta: null,
|
||||
};
|
||||
@@ -27,7 +31,10 @@
|
||||
const sheetEl = el("mapEditorSheet");
|
||||
const gridEl = el("mapEditorSheetGrid");
|
||||
const imageEl = el("mapEditorImage");
|
||||
const occupancyCanvasEl = el("mapEditorOccupancyCanvas");
|
||||
const originEl = el("mapEditorOrigin");
|
||||
const originHitEl = el("mapEditorOriginHit");
|
||||
const originLabelEl = el("mapEditorOriginLabel");
|
||||
const emptyEl = el("mapEditorEmpty");
|
||||
const tipEl = el("mapEditorCanvasTip");
|
||||
const statusViewEl = el("mapEditorStatusView");
|
||||
@@ -94,7 +101,58 @@
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -204,7 +262,6 @@
|
||||
gridEl.hidden = true;
|
||||
}
|
||||
|
||||
if (originEl) originEl.hidden = !has;
|
||||
updateOriginMarker();
|
||||
updateStatusBar();
|
||||
}
|
||||
@@ -224,11 +281,25 @@
|
||||
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() {
|
||||
if (!originEl) return;
|
||||
const geo = Geo();
|
||||
const { width, height } = floorPlanSize();
|
||||
if (!geo || !hasFloorPlan() || !width || !height) {
|
||||
if (!state.showOrigin || !geo || !hasFloorPlan() || !width || !height) {
|
||||
originEl.hidden = true;
|
||||
originEl.setAttribute("aria-hidden", "true");
|
||||
return;
|
||||
@@ -249,18 +320,23 @@
|
||||
originEl.style.top = `${pt.y}px`;
|
||||
originEl.style.transform = `rotate(${yawDeg}deg)`;
|
||||
|
||||
const labelEl = el("mapEditorOriginLabel");
|
||||
if (labelEl) {
|
||||
labelEl.textContent = t("maps.editor.originLabelShort", {
|
||||
if (originLabelEl) {
|
||||
originLabelEl.textContent = t("maps.editor.originLabelShort", {
|
||||
x: ox.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),
|
||||
y: oy.toFixed(3),
|
||||
yaw: ((oyaw * 180) / Math.PI).toFixed(1),
|
||||
});
|
||||
if (originHitEl) {
|
||||
originHitEl.title = tooltip;
|
||||
originHitEl.setAttribute("aria-label", tooltip);
|
||||
}
|
||||
setOriginLabelVisible(false);
|
||||
}
|
||||
|
||||
function updateStatusBar(pointerClient) {
|
||||
@@ -311,11 +387,13 @@
|
||||
imageEl.src = url;
|
||||
imageEl.hidden = false;
|
||||
if (emptyEl) emptyEl.hidden = true;
|
||||
setOccupancyCanvasVisible(false);
|
||||
} else {
|
||||
if (imageEl) {
|
||||
imageEl.hidden = true;
|
||||
imageEl.removeAttribute("src");
|
||||
}
|
||||
setOccupancyCanvasVisible(false);
|
||||
if (emptyEl) emptyEl.hidden = false;
|
||||
}
|
||||
updateMenuActionsUi();
|
||||
@@ -323,6 +401,7 @@
|
||||
imageEl?.addEventListener(
|
||||
"load",
|
||||
() => {
|
||||
paintOccupancyFromImage();
|
||||
updateImageLayer();
|
||||
fitToView();
|
||||
},
|
||||
@@ -381,6 +460,7 @@
|
||||
async function reloadMap() {
|
||||
if (!state.mapId) return;
|
||||
state.map = await api(`/api/maps/${encodeURIComponent(state.mapId)}`);
|
||||
await loadYamlMeta();
|
||||
updateHeader();
|
||||
renderMapImage();
|
||||
fillSettingsForm();
|
||||
@@ -392,6 +472,7 @@
|
||||
state.readOnly = !!callbacks.readOnly || !callbacks.canWrite;
|
||||
state.dirty = false;
|
||||
state.tipVisible = true;
|
||||
state.showOrigin = true;
|
||||
state.activeTool = "pan";
|
||||
state.view = Geo()?.createView(1, 0, 0) || { scale: 1, panX: 0, panY: 0 };
|
||||
if (tipEl) {
|
||||
@@ -399,6 +480,7 @@
|
||||
tipEl.textContent = t("maps.editor.canvasTip");
|
||||
}
|
||||
setActiveTool("pan");
|
||||
setShowOrigin(true);
|
||||
setDirty(false);
|
||||
applyReadOnlyUi();
|
||||
reloadMap().catch((e) => alert(e.message));
|
||||
@@ -409,6 +491,7 @@
|
||||
state.map = null;
|
||||
state.callbacks = {};
|
||||
state.uploadMeta = null;
|
||||
state.yamlMeta = null;
|
||||
menuDialogEl?.close();
|
||||
settingsDialogEl?.close();
|
||||
activateDialogEl?.close();
|
||||
@@ -584,6 +667,7 @@
|
||||
state.callbacks.onMapUpdated?.(updated);
|
||||
setDirty(false);
|
||||
fillSettingsForm();
|
||||
await loadYamlMeta();
|
||||
renderMapImage();
|
||||
menuDialogEl?.close();
|
||||
uploadMetaDialogEl?.close();
|
||||
@@ -689,6 +773,11 @@
|
||||
}
|
||||
|
||||
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", () => {
|
||||
if (state.dirty && !confirm(t("maps.editor.unsavedLeave"))) return;
|
||||
state.callbacks.onClose?.();
|
||||
@@ -714,6 +803,7 @@
|
||||
saveMap().catch((e) => alert(e.message));
|
||||
});
|
||||
el("mapEditorPanBtn")?.addEventListener("click", () => setActiveTool("pan"));
|
||||
el("mapEditorOriginBtn")?.addEventListener("click", () => setShowOrigin(!state.showOrigin));
|
||||
el("mapEditorFitBtn")?.addEventListener("click", fitToView);
|
||||
el("mapEditorCenterBtn")?.addEventListener("click", () => {
|
||||
dismissCanvasTip();
|
||||
@@ -818,6 +908,9 @@
|
||||
Object.values(uploadMetaFields).forEach((node) => {
|
||||
node?.addEventListener("input", () => {
|
||||
updateOriginMarker();
|
||||
if (uploadMetaDialogEl?.open && imageEl?.naturalWidth) {
|
||||
paintOccupancyFromImage();
|
||||
}
|
||||
if (node === uploadMetaFields.resolution && uploadMetaDialogEl?.open) {
|
||||
updateImageLayer();
|
||||
}
|
||||
@@ -835,5 +928,11 @@
|
||||
bindCanvasPanZoom();
|
||||
bindEvents();
|
||||
|
||||
window.MapEditorApp = { open, close, reloadMap };
|
||||
window.MapEditorApp = {
|
||||
open,
|
||||
close,
|
||||
reloadMap,
|
||||
paintOccupancyGrid,
|
||||
paintOccupancyFromImage,
|
||||
};
|
||||
})();
|
||||
|
||||
178
www/map-occupancy-canvas.js
Normal file
178
www/map-occupancy-canvas.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
@@ -2227,7 +2227,7 @@ body.dashboard-widget-dragging .dashboardWidgetHeader {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #d8dde4;
|
||||
border-radius: 2px;
|
||||
border-radius: 0;
|
||||
}
|
||||
.dashboardWidget--operate.dashboardWidget--map .dashboardWidgetBody,
|
||||
.dashboardWidget--operate.dashboardWidget--map_locked .dashboardWidgetBody {
|
||||
@@ -3863,6 +3863,7 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
||||
background: #b8b8b8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.mapEditorCanvasWrap.is-panning {
|
||||
@@ -3898,6 +3899,7 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.mapEditorCanvasInner {
|
||||
@@ -3919,9 +3921,9 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
||||
position: relative;
|
||||
min-width: 480px;
|
||||
min-height: 360px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.12);
|
||||
background: transparent;
|
||||
user-select: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.mapEditorSheet--blank {
|
||||
@@ -3929,9 +3931,10 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
||||
}
|
||||
|
||||
.mapEditorSheet--hasImage {
|
||||
background: #fff;
|
||||
background: transparent;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mapEditorSheetGrid {
|
||||
@@ -3967,18 +3970,23 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mapEditorOriginAxis--x {
|
||||
width: 36px;
|
||||
height: 2px;
|
||||
top: -1px;
|
||||
left: 0;
|
||||
background: #e74c3c;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.mapEditorOriginAxis--y {
|
||||
.mapEditorOriginAxis--z {
|
||||
width: 2px;
|
||||
height: 36px;
|
||||
left: -1px;
|
||||
top: -36px;
|
||||
background: #27ae60;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
@@ -3993,12 +4001,41 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
||||
background: #e67e22;
|
||||
border: 2px solid #fff;
|
||||
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 {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: -22px;
|
||||
top: -48px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
@@ -4009,6 +4046,17 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
||||
white-space: nowrap;
|
||||
line-height: 1.3;
|
||||
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 {
|
||||
@@ -4016,17 +4064,27 @@ body.auth-readonly-maps-page .mapsMirMapMenuCancelBtn {
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
.mapEditorImage {
|
||||
.mapEditorImageLoader {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mapEditorOccupancyCanvas {
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user