pudu_map_gui/index.html

2365 lines
119 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pudu Map GUI — convert · preview · deploy</title>
<style>
:root {
--paper: #14161a; --ink: #e8e6e1; --panel: #1d2026; --line: #2e323a;
--muted: #8a919c; --accent: #e8a13d; --keepout: #e05a4f; --home: #4fa39c;
--track: #6b9be0; --ok: #5aa86b; --err: #e05a4f; --stage-bg: #0e1013; --chip-bg: #262a31;
--plan: #35d06a; --live: #35b6ff;
}
@media (prefers-color-scheme: light) {
:root {
--paper: #f5f4f0; --ink: #1a1d22; --panel: #ffffff; --line: #dcdad3;
--muted: #6d7480; --accent: #b97416; --keepout: #d1483e; --home: #2f7a74;
--track: #4a7fd4; --ok: #2e7d3d; --err: #c0392b; --stage-bg: #e3e1da; --chip-bg: #eceae4;
--plan: #1c9a44; --live: #0a6fb0;
}
}
* { box-sizing: border-box; }
body { background: var(--paper); color: var(--ink); font: 14.5px/1.5 system-ui, sans-serif; margin: 0; }
.mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; }
header { padding: 14px 20px; border-bottom: 1px solid var(--line); display: flex; align-items: baseline; gap: 14px; }
h1 { font-size: 17px; margin: 0; font-weight: 650; }
header .sub { color: var(--muted); font-size: 12.5px; }
.wrap { display: grid; grid-template-columns: 380px 1fr; gap: 0; height: calc(100vh - 55px); }
.side { border-right: 1px solid var(--line); overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; }
.step { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 13px 15px; }
.step h2 { font-size: 11.5px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 0 0 9px; }
.step h2 .n { display: inline-block; width: 17px; height: 17px; border-radius: 50%; background: var(--accent); color: var(--paper); text-align: center; line-height: 17px; font-size: 11px; margin-right: 6px; }
input[type=text] { width: 100%; background: var(--stage-bg); color: var(--ink); border: 1px solid var(--line); border-radius: 5px; padding: 7px 9px; font-size: 13px; font-family: ui-monospace, Menlo, monospace; }
button { background: var(--accent); color: var(--paper); border: 0; border-radius: 5px; padding: 7px 13px; font-size: 13px; font-weight: 600; cursor: pointer; }
button.sec { background: var(--chip-bg); color: var(--ink); border: 1px solid var(--line); }
button:disabled { opacity: .45; cursor: not-allowed; }
.row { display: flex; gap: 8px; margin-top: 8px; align-items: center; flex-wrap: wrap; }
.radio { display: flex; flex-direction: column; gap: 6px; }
.radio label { display: flex; gap: 8px; align-items: baseline; cursor: pointer; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; }
.radio label.on { border-color: var(--accent); background: var(--chip-bg); }
.radio input { accent-color: var(--accent); }
.radio small { color: var(--muted); display: block; font-size: 11.5px; }
select { background: var(--stage-bg); color: var(--ink); border: 1px solid var(--line); border-radius: 5px; padding: 6px; font-size: 13px; }
.hint { color: var(--muted); font-size: 12px; margin-top: 7px; }
#log { background: var(--stage-bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; font-size: 12px; height: 130px; overflow-y: auto; white-space: pre-wrap; }
#log .ok { color: var(--ok); } #log .err { color: var(--err); }
.loghdr { display: flex; align-items: center; gap: 6px; margin-bottom: 9px; }
.loghdr h2 { margin: 0; flex: 1; }
.logbtn { padding: 3px 9px; font-size: 11px; }
.maplist { display: flex; flex-direction: column; gap: 5px; max-height: 220px; overflow-y: auto; }
.maprow { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; }
.maprow.cur { border-color: var(--accent); }
.maprow .inf { flex: 1; min-width: 0; font-size: 12.5px; }
.maprow .inf small { color: var(--muted); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.maprow button { padding: 4px 10px; font-size: 12px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip { background: var(--chip-bg); border: 1px solid var(--line); border-radius: 4px; padding: 1px 8px; font-size: 11.5px; color: var(--muted); }
.chip b { color: var(--ink); }
.stagewrap { position: relative; background: var(--stage-bg); overflow: hidden; cursor: grab; touch-action: none; }
.stagewrap.dragging { cursor: grabbing; }
#stage { position: absolute; transform-origin: 0 0; }
#stage img, #stage svg { position: absolute; top: 0; left: 0; image-rendering: pixelated; user-select: none; -webkit-user-drag: none; }
.float { position: absolute; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,.25); }
.layers { top: 12px; left: 12px; padding: 9px 12px; display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
.layers label { display: flex; gap: 7px; align-items: center; cursor: pointer; }
.layers input { accent-color: var(--accent); }
.readout { bottom: 12px; right: 12px; padding: 6px 11px; font-size: 12.5px; pointer-events: none; min-width: 210px; text-align: right; }
.readout .dim { color: var(--muted); }
.goalbtn { top: 12px; right: 12px; padding: 6px 12px; font-size: 12.5px; font-weight: 600; cursor: pointer; user-select: none; color: var(--muted); }
.goalbtn.armed { color: var(--paper); background: var(--accent); border-color: var(--accent); }
/* live overlay indicator + cancel — same float-button family as 🎯 / 📍 */
.goalbtn.live-on { color: var(--live); border-color: var(--live); }
.goalbtn.live-wait { color: var(--accent); border-color: var(--accent); }
.goalbtn.live-bad { color: var(--err); border-color: var(--err); }
.goalbtn.cancel { color: var(--err); border-color: var(--err); }
.goalbtn.cancel:hover { background: var(--err); color: var(--paper); }
.goalbtn .dotlive { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: currentColor; margin-right: 6px; vertical-align: middle; }
.stagewrap.goalmode { cursor: crosshair; }
.empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 14px; }
.main { display: flex; flex-direction: column; min-height: 0; }
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); padding: 0 10px; background: var(--panel); }
.tab { background: transparent; color: var(--muted); border: 0; border-bottom: 2px solid transparent; border-radius: 0; padding: 9px 16px; font-size: 13px; font-weight: 600; position: relative; }
.tab.on { color: var(--ink); border-bottom-color: var(--accent); }
.tab .dot { position: absolute; top: 7px; right: 5px; width: 7px; height: 7px; border-radius: 50%; background: var(--err); display: none; }
.pane { flex: 1; min-height: 0; }
.logspane { overflow-y: auto; background: var(--stage-bg); padding: 14px 18px; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12.5px; line-height: 1.6; white-space: pre-wrap; }
.logbar { position: sticky; top: -14px; display: flex; gap: 7px; padding: 6px 0 10px; background: var(--stage-bg); border-bottom: 1px solid var(--line); margin-bottom: 8px; }
.logspane .ok { color: var(--ok); } .logspane .err { color: var(--err); }
.logspane .none { color: var(--muted); }
.statuspane { overflow-y: auto; background: var(--stage-bg); padding: 16px 20px; }
.strow { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; }
.strow h3 { margin: 0; font-size: 14.5px; flex: 0 0 auto; }
.stcard { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px 15px; margin-bottom: 14px; max-width: 980px; }
.stcard h4 { margin: 0 0 8px; font-size: 11.5px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
.sttbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.sttbl th, .sttbl td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
.sttbl td.wrapcell { white-space: normal; }
/* NOT ".wrap": that is the PAGE LAYOUT grid (display:grid; height:calc(100vh - 55px)).
A <td class="wrap"> inherited that height and blew the Status table open with a
viewport-tall empty row. Keep table-cell class names distinct from layout ones. */
.sttbl th { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
.okc { color: var(--ok); font-weight: 600; } .errc { color: var(--err); font-weight: 600; } .mutc { color: var(--muted); }
#connChip.up { border-color: var(--ok); color: var(--ok); }
#connChip.down { border-color: var(--err); color: var(--err); }
#connChip.busy { border-color: var(--muted); color: var(--muted); }
.use-on { background: var(--accent) !important; color: var(--paper) !important; border-color: var(--accent) !important; }
button.danger { background: transparent; color: var(--err); border: 1px solid var(--err); padding: 4px 11px; font-size: 12px; }
button.danger:hover { background: var(--err); color: var(--paper); }
#deadOverlay { display: none; position: fixed; inset: 0; z-index: 9999; background: var(--paper); align-items: center; justify-content: center; text-align: center; }
#deadOverlay.on { display: flex; }
.deadbox { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 30px 40px; max-width: 620px; }
/* toast: every ok/err line also flashes here — after the sidebar log square was
removed, errors were only visible in the Logs tab (silent failures) */
#toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%) translateY(12px);
z-index: 9998; max-width: 62vw; padding: 10px 16px; border-radius: 8px; font-size: 13px;
font-weight: 500; color: #fff; box-shadow: 0 4px 18px rgba(0,0,0,.45); opacity: 0;
pointer-events: none; transition: opacity .18s, transform .18s; }
#toast.on { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }
#toast.ok { background: #2e7d3d; } #toast.err { background: #b7362c; }
#toast .x { float: right; margin-left: 14px; cursor: pointer; opacity: .75; }
.gallerypane { overflow-y: auto; background: var(--stage-bg); padding: 18px 22px; }
.gwrap { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }
.gcard { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 10px; cursor: pointer; text-align: center; transition: border-color .12s; }
.gcard:hover { border-color: var(--accent); }
.gcard.cur { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.gcard .thumb { width: 100%; aspect-ratio: 1 / 1; object-fit: contain; background: var(--stage-bg); border-radius: 6px; image-rendering: pixelated; }
.gcard .gname { margin-top: 7px; font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gcard small { color: var(--muted); font-size: 11.5px; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* gallery section header: title + its own refresh button */
.ghdr { display: flex; align-items: center; gap: 9px; margin: 2px 0 4px; }
.ghdr h3 { margin: 0; font-size: 15px; }
.ghdr button { padding: 4px 10px; font-size: 12px; }
/* per-card actions — same look as the .maprow ✏️/🗑 buttons in the sidebar */
.gcard .gact { display: flex; justify-content: center; gap: 6px; margin-top: 7px; }
.gcard .gact button { padding: 4px 10px; font-size: 12px; }
.gcard.inuse { border-color: var(--ok); box-shadow: 0 0 0 1px var(--ok); }
.gbadge { display: inline-block; margin-top: 6px; padding: 1px 8px; border-radius: 4px;
font-size: 10.5px; font-weight: 700; letter-spacing: .06em;
background: var(--ok); color: var(--paper); }
</style>
</head>
<body>
<header>
<h1>Pudu Map GUI</h1>
<span class="sub">convert → preview live → pick robot → deploy over eth/wifi → RViz</span>
<span class="sub" id="buildTag" style="margin-left:auto"></span>
<button class="danger" id="btnStopRobot"
title="Stop the SELECTED robot's nav stack (LiDAR driver, localization, rosbridge). Software only — nothing else on the robot is touched.">⏹ Stop robot</button>
<button class="danger" id="btnShutdown"
title="Stop the local RViz / relay / map publisher and the GUI server itself. Robots are NOT touched.">⏻ Shutdown</button>
</header>
<div id="toast"></div>
<div id="deadOverlay">
<div class="deadbox">
<div style="font-size:34px"></div>
<h2 style="margin:10px 0 6px">Dashboard stopped</h2>
<div id="deadWhat" class="hint" style="margin:0 0 14px"></div>
<div class="mono" style="background:var(--stage-bg);border:1px solid var(--line);border-radius:6px;padding:10px 12px;display:inline-block">
cd ~/Robotics_workspace/yslootahtech/Project/Other/pudu_map_gui<br>./start.sh
</div>
<div class="hint" style="margin-top:12px">Robots were not touched — anything running on them keeps running.</div>
</div>
</div>
<div class="wrap">
<div class="side">
<div class="step">
<h2><span class="n">1</span>Convert a Pudu export</h2>
<input type="text" id="path" placeholder="/home/zedx/Downloads/yslootahtech_pudu"
value="/home/zedx/Downloads/yslootahtech_pudu">
<div class="row">
<button class="sec" id="btnBrowseDir" title="Native folder picker — converts right after choosing">📁 Browse folder…</button>
<button class="sec" id="btnBrowseZip" title="Native file picker (.zip) — unzips + converts right after choosing">🗜 Choose .zip…</button>
</div>
<div class="row">
<button id="btnConvert">Convert</button>
<span class="hint">or type/paste a path: folder with map.png+map.json, a .zip, or a folder of
zips (newest wins). Result is saved into the project map library below.</span>
</div>
<div class="chips" id="metaChips" style="display:none"></div>
</div>
<div class="step">
<div class="loghdr">
<h2>Map library — recent maps</h2>
<button class="sec logbtn" id="mapsRefresh" title="Rescan the project library + robot stage dirs + ~/Downloads">↻ Refresh</button>
</div>
<div id="mapList" class="maplist"><div class="hint">scanning…</div></div>
<div class="hint">converted + edited maps are saved in
<span class="mono">Project/Other/pudu_map_gui/maps/</span> (one folder per map);
robot stage dirs and ~/Downloads are scanned too. Newest first —
<b>Load</b> to preview/edit/stage/deploy, <b>🗑</b> deletes the set from disk.</div>
</div>
<div class="step">
<h2><span class="n">2</span>Robot destination</h2>
<div class="radio" id="robots"></div>
<div class="hint">your robots — one shared list everywhere; add/remove them in the <b>Status</b> tab</div>
<div class="row">
<button id="btnStage" disabled>Stage into workspace</button>
</div>
<div class="hint" id="robotNote"></div>
</div>
<div class="step">
<h2><span class="n">3</span>Connect &amp; deploy</h2>
<div class="row">
<button class="sec logbtn" id="useEth" title="connect via the wired eth0 address">eth0</button>
<button class="sec logbtn" id="useWifi" title="connect via the WiFi address">wifi</button>
<input type="text" id="ip" placeholder="robot ip" style="width:140px">
<button class="sec" id="btnPing" disabled>Ping</button>
</div>
<div class="row"><span class="chip" id="connChip">● not checked</span></div>
<div class="row">
<button class="sec logbtn" id="ipSuggest" style="display:none"
title="recommended wired address — click to fill (never applied automatically)"></button>
</div>
<div class="row">
<input type="text" id="dest" placeholder="dest dir on robot (default per robot)">
</div>
<div class="row">
<button id="btnDeploy" disabled>Deploy maps to robot</button>
<button class="sec" id="btnRobotMaps" title="List the map sets ALREADY on this robot — avoids deploying the same map twice">🔍 Maps on robot</button>
</div>
<div id="robotMapList" class="maplist" style="display:none;margin-top:8px"></div>
</div>
<div class="step">
<h2><span class="n">4</span>Nav stack on robot</h2>
<div class="hint" id="instStatus">not checked — pick a robot + IP, then Check</div>
<div class="row">
<button class="sec" id="btnInstCheck" title="ssh scan: files + docker image + is the stack running">🔍 Check</button>
<button id="btnInstall" title="One-shot installer: deploy + docker build ON the robot (G1: file sync). Streams into the Logs tab.">⬇ Install</button>
<button class="sec" id="btnUninstall" title="Removes ONLY this nav stack (its image, containers, and install folder). Nothing else on the robot is touched.">🗑 Uninstall</button>
</div>
<div class="hint">Install = fully-docker stack built on the robot (Go2/R1; ~1015 min first
time, needs robot internet). Refuses while the stack is live. Progress → Logs tab.</div>
</div>
<div class="step">
<h2><span class="n">5</span>View in RViz</h2>
<div class="row">
<button class="sec" id="btnRvizLocal" disabled>RViz: preview converted map</button>
</div>
<div class="row">
<button class="sec" id="btnRviz" disabled>RViz: live robot</button>
</div>
<div class="row">
<button class="sec" id="btnReseed" title="Re-send the last pose you set for this robot — AMCL forgets it on every restart">📍 Re-seed last pose</button>
</div>
<div class="row">
<button class="sec" id="btnRvizCombo" disabled
title="Local publisher serves the LOADED map; relay brings the live robot (TF/odom) and forwards 2D Pose Estimate + 2D Goal Pose. Robot must be running the same map in nav mode for poses to line up.">RViz: loaded map + live robot</button>
</div>
<span class="hint">Preview = no robot needed. The two live views <b>auto-start the robot's
nav stack</b> over ssh if it isn't running (G1/Go2) and wait for it — bring-up alone
never moves the robot; motion still needs a goal you click.</span>
</div>
</div>
<div class="main">
<div class="tabs">
<button class="tab on" id="tabMap" onclick="showTab('map')">Map</button>
<button class="tab" id="tabEdit" onclick="showTab('edit')">✏️ Edit</button>
<button class="tab" id="tabGallery" onclick="showTab('gallery')">🗺 Gallery</button>
<button class="tab" id="tabLogs" onclick="showTab('logs')">Logs<span class="dot" id="logDot"></span></button>
<button class="tab" id="tabStatus" onclick="showTab('status')">Status</button>
<button class="tab" id="tabLidar" onclick="showTab('lidar')">📡 Lidar</button>
</div>
<div class="stagewrap pane" id="viz">
<div id="stage"></div>
<div class="empty" id="empty">convert a map to preview it here</div>
<div class="float layers" id="layerbox" style="display:none"></div>
<div class="float goalbtn" id="goalBtn" style="display:none"
title="Arm, then click the map (drag = heading) to send /goal_pose to the robot at the IP in step 3. Robot moves only if you armed its gait + Nav2 runs.">🎯 Goal mode: off</div>
<div class="float goalbtn" id="locBtn" style="display:none; top:52px"
title="Localize: arm, then click where the robot ACTUALLY stands (drag = heading). Sends /initialpose — same as RViz 2D Pose Estimate. Never causes motion.">📍 Set location: off</div>
<div class="float goalbtn" id="liveBtn" style="display:none; top:92px"
title="Live overlay: subscribes to the robot's rosbridge (/amcl_pose or /tf, /goal_pose, /plan) and draws the robot, the goal and the green Nav2 route on this map — the same picture RViz shows. Click to connect/disconnect."><span class="dotlive"></span>Live: off</div>
<div class="float goalbtn cancel" id="cancelBtn" style="display:none; top:132px"
title="Cancel every active Nav2 goal (action /navigate_to_pose cancel_goal). The robot stops planning and stops walking — it does NOT disarm the gait.">✋ Cancel goal</div>
<div class="float goalbtn cancel" id="armBtn" style="display:none; top:172px"
title="ARM / DISARM the walking gait. The nav stack always comes up DISARMED: goals plan a route but the drive consumer zeroes every velocity until you arm. Arming expires by itself after 15 min; every command expires in 0.4 s, so a crash or a killed container stops the robot. The G1 must ALSO be in walking mode (FSM-200 + Start) on the remote.">🦿 checking…</div>
<div class="float readout mono" id="readout" style="display:none"><span class="dim">hover the map</span></div>
</div>
<div class="pane" id="editPane" style="display:none">
<iframe id="editorFrame" style="width:100%;height:100%;border:0;background:var(--stage-bg)"
title="Office Map Editor"></iframe>
</div>
<div class="pane gallerypane" id="galleryPane" style="display:none">
<div id="galleryBody"><div class="hint">open this tab to scan the map library</div></div>
</div>
<div class="pane logspane" id="logsPane" style="display:none">
<div class="logbar">
<button class="sec logbtn" onclick="copyLog(this)" title="Copy ALL log lines to the clipboard">Copy all</button>
<button class="sec logbtn" onclick="exportLog()" title="Download ALL log lines as a .log file">⬇ Export</button>
<button class="sec logbtn" onclick="clearLog()" title="Clear the log">Clear</button>
<button class="sec logbtn" onclick="openLogTab()" title="Mirror the log live in its own browser window">⤢ Pop out</button>
</div>
<div id="logsBody"><div class="none">no log lines yet</div></div>
</div>
<div class="pane statuspane" id="lidarPane" style="display:none">
<div class="strow">
<h3 id="lidarTitle">LiDAR</h3>
<button class="sec" id="lidarConnect">Connect</button>
<span class="hint mono" id="lidarStat">not connected</span>
<label class="hint" style="display:flex;gap:6px;align-items:center;margin:0">
<input type="checkbox" id="lidarOnMap" checked> overlay on the loaded map
</label>
</div>
<div id="lidarWrap" style="position:relative;background:var(--paper);border:1px solid var(--line);border-radius:8px;overflow:hidden">
<canvas id="lidarCanvas" style="display:block;width:100%;height:62vh"></canvas>
<img id="camView" alt="robot camera" style="display:none;width:100%;max-height:62vh;object-fit:contain;background:#000">
</div>
<div class="hint" id="lidarNote"></div>
</div>
<div class="pane statuspane" id="statusPane" style="display:none">
<div class="strow">
<h3>Fleet &amp; GUI status</h3>
<button class="sec" id="stRefresh">↻ Refresh</button>
<span class="hint" id="stTs"></span>
</div>
<div class="stcard">
<h4>Scan targets — your robots</h4>
<div id="stTargets"></div>
<div class="strow" style="margin:6px 0 0">
<button class="sec logbtn" id="stAdd">+ Add robot</button>
<span class="hint">any number of robots — name, type (g1/go2/r1), IP. Saved in this browser.</span>
</div>
</div>
<div id="stBody"><div class="hint">press ↻ Refresh to scan the targets</div></div>
</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
let ROBOTS = {}, robot = 'r1', MAP = null;
// BUILD marks which copy of the page your browser actually runs — if the tag in
// the top-right corner (and the first Log line) doesn't match the code on disk,
// the browser served a STALE cache: close the tab and reopen (VS Code's Simple
// Browser caches hard; external Chrome/Firefox revalidates with no-store).
const BUILD = 'build 0721-G';
// any runtime JS error lands in the dashboard Log — silent breakage is worse
window.addEventListener('error', e => {
try { log(`JS ERROR: ${e.message} @:${e.lineno}`, 'err'); } catch (_) {}
});
window.addEventListener('unhandledrejection', e => {
try { log('JS ERROR (async): ' + (e.reason && e.reason.message || e.reason), 'err'); } catch (_) {}
});
let LOG_LINES = []; // {time, msg, cls} — chronological
let LOG_WIN = null; // popped-out logs tab (live mirror)
function log(msg, cls) {
const time = new Date().toLocaleTimeString();
LOG_LINES.push({ time, msg, cls });
paneAppend({ time, msg, cls });
if (ACTIVE_TAB !== 'logs') $('logDot').style.display = 'block'; // new lines live in the Logs tab
if (LOG_WIN && !LOG_WIN.closed) tabAppend({ time, msg, cls });
if (cls === 'ok' || cls === 'err') toast(msg, cls);
}
let TOAST_T = null;
function toast(msg, cls) {
const t = $('toast');
t.className = 'on ' + cls;
t.innerHTML = '<span class="x" onclick="hideToast()">✕</span>';
t.appendChild(document.createTextNode(msg));
clearTimeout(TOAST_T);
TOAST_T = setTimeout(hideToast, cls === 'err' ? 12000 : 4500); // errors linger
}
function hideToast() { $('toast').className = ''; }
// ---------- Logs tab (inside the dashboard) ----------
let ACTIVE_TAB = 'map';
function paneAppend(l) {
const p = $('logsPane'), body = $('logsBody');
const ph = body.querySelector('.none'); if (ph) ph.remove();
const stick = p.scrollTop + p.clientHeight >= p.scrollHeight - 30;
const d = document.createElement('div');
if (l.cls) d.className = l.cls;
d.textContent = `${l.time} ${l.msg}`;
body.appendChild(d); // chronological, newest at the bottom
if (stick) p.scrollTop = p.scrollHeight;
}
let CONVERT_SEQ = 0, EDITOR_SEQ = -1;
function showTab(name) {
if (name === 'edit' && !MAP) { log('convert a map first, then edit it', 'err'); return; }
ACTIVE_TAB = name;
$('tabMap').classList.toggle('on', name === 'map');
$('tabEdit').classList.toggle('on', name === 'edit');
$('tabGallery').classList.toggle('on', name === 'gallery');
$('tabLogs').classList.toggle('on', name === 'logs');
$('tabStatus').classList.toggle('on', name === 'status');
$('tabLidar').classList.toggle('on', name === 'lidar');
$('viz').style.display = name === 'map' ? '' : 'none';
$('editPane').style.display = name === 'edit' ? '' : 'none';
$('galleryPane').style.display = name === 'gallery' ? '' : 'none';
$('logsPane').style.display = name === 'logs' ? '' : 'none';
$('statusPane').style.display = name === 'status' ? '' : 'none';
$('lidarPane').style.display = name === 'lidar' ? '' : 'none';
if (name === 'lidar') lidarEnter();
if (name === 'gallery') renderGallery();
if (name === 'edit' && EDITOR_SEQ !== CONVERT_SEQ) {
// (re)load the editor only when the underlying map changed — otherwise keep
// its in-progress edit state across tab switches
$('editorFrame').src = '/editor?ts=' + CONVERT_SEQ;
EDITOR_SEQ = CONVERT_SEQ;
}
if (name === 'logs') {
$('logDot').style.display = 'none';
const p = $('logsPane'); p.scrollTop = p.scrollHeight;
}
if (name === 'status' && Date.now() - STATUS_TS > 5000) refreshStatus();
if (name === 'map' && MAP) fit(); // rect was 0 while the pane was hidden
}
// ---------- deploy destination follows the loaded map ----------
// It stays overridable: we only replace a value WE put there (DEST_AUTO). If you
// type your own path it is left alone.
let DEST_AUTO = '';
async function refreshDest() {
const el = $('dest');
const cur = (el.value || '').trim();
if (cur && cur !== DEST_AUTO) return; // user-typed — never clobber
try {
const d = (await api('robot_dest', { robot })).dest || '';
el.value = d;
DEST_AUTO = d;
el.placeholder = d;
} catch (_) { /* no map loaded yet — leave the field alone */ }
}
// ---------- connection indicator (step 3) ----------
let CONN_T = null, CONN_BUSY = false;
async function refreshConn(force) {
const ip = ($('ip').value || '').trim(), rb = robot;
const chip = $('connChip');
if (!ip) { chip.className = 'chip'; chip.textContent = '● no IP'; return; }
if (CONN_BUSY && !force) return;
CONN_BUSY = true;
chip.className = 'chip busy'; chip.textContent = `● checking ${rb.toUpperCase()} @ ${ip}`;
try {
const c = await api('conn', { robot: rb, ip });
if (rb !== robot || ip !== ($('ip').value || '').trim()) return; // selection moved on
if (!c.ping) { chip.className = 'chip down'; chip.textContent = `${rb.toUpperCase()} @ ${ip} — NOT CONNECTED (no ping)`; }
else if (!c.ssh) { chip.className = 'chip down'; chip.textContent = `${rb.toUpperCase()} @ ${ip} — pings, no ssh`; }
else {
chip.className = 'chip up';
chip.textContent = `${rb.toUpperCase()} @ ${ip} — CONNECTED` +
(c.running ? ' · stack RUNNING' : ' · stack stopped') +
(c.rosbridge ? ' · rosbridge ✓' : '');
}
} catch (e) {
chip.className = 'chip down'; chip.textContent = '● check failed: ' + e.message;
} finally { CONN_BUSY = false; }
}
$('connChip').onclick = () => refreshConn(true);
setInterval(() => { if (!document.hidden) refreshConn(); }, 30000);
// ---------- ⏹ Stop robot ----------
$('btnStopRobot').onclick = async () => {
const ip = needIp(); if (!ip) return;
const rb = robot;
if (!confirm(`Stop the ${rb.toUpperCase()} nav stack on ${ip}?\n\n` +
'• stops the LiDAR driver, localization and rosbridge (all in our container)\n' +
'• nothing else on the robot is touched\n' +
'• the dashboard keeps running')) return;
$('btnStopRobot').disabled = true;
try {
const r = await api('stack_stop', { robot: rb, ip });
log(`${rb.toUpperCase()} @ ${ip}: ${r.note}`, 'ok');
lidarDisconnect();
refreshConn(true);
} catch (e) { log('stop robot failed: ' + e.message, 'err'); }
$('btnStopRobot').disabled = false;
};
// ---------- ⏻ Shutdown: stop the local viewers + this server ----------
$('btnShutdown').onclick = async () => {
const tg = (TARGETS || []).filter(t => effIp(t)).map(t => ({ robot: t.robot, ip: effIp(t) }));
if (!confirm('Shut down EVERYTHING?\n\n' +
'• stops each robot\'s nav stack — LiDAR driver, localization, rosbridge\n' +
(tg.length ? ' (' + tg.map(t => t.robot.toUpperCase() + ' @ ' + t.ip).join(', ') + ')\n' : '') +
'• stops RViz, the rosbridge relay and the local map publisher\n' +
'• stops the GUI server (its docker container exits)\n\n' +
'Nothing else on the robots is touched. Start again with: ./start.sh')) return;
let note = 'stopped the local viewers and the GUI server';
try {
const r = await api('shutdown', { targets: tg }); // the server exits right after replying
if (r.stopped && r.stopped.length) note = 'stopped: ' + r.stopped.join(', ') + ' + the GUI server';
else note = 'no viewers were running — GUI server stopped';
} catch (e) { /* connection drops as the server exits — that IS success */ }
$('deadWhat').textContent = note;
$('deadOverlay').classList.add('on');
};
// ---------- Gallery tab: every saved/modified map as a square card ----------
// One section per source (workstation library / the selected robot). Each section
// owns its own box element, so its ↻ button re-fetches and re-renders THAT box
// alone — the other section is left exactly as it was.
function gallerySection(box, title, hint, refresh) {
box.innerHTML = '';
const hdr = document.createElement('div');
hdr.className = 'ghdr';
const h = document.createElement('h3');
h.textContent = title;
const rf = document.createElement('button');
rf.className = 'sec';
rf.textContent = '↻ Refresh';
rf.title = 'Re-scan this section only and redraw its cards';
rf.onclick = async (e) => {
e.stopPropagation();
rf.disabled = true;
rf.textContent = '↻ refreshing…';
try { await refresh(); }
catch (err) { log('refresh failed: ' + err.message, 'err'); }
// refresh() rebuilds the box, so this button is usually gone by now —
// restore it only if it somehow survived, so it never stays stuck busy
finally { if (rf.isConnected) { rf.disabled = false; rf.textContent = '↻ Refresh'; } }
};
hdr.append(h, rf);
box.appendChild(hdr);
box.insertAdjacentHTML('beforeend', `<div class="hint" style="margin:0 0 10px">${hint}</div>`);
const body = document.createElement('div');
box.appendChild(body);
return body;
}
async function renderGallery() {
const pane = $('galleryBody');
pane.innerHTML = '';
const wsBox = document.createElement('div');
const rbBox = document.createElement('div');
rbBox.style.marginTop = '26px';
pane.append(wsBox, rbBox);
renderWorkstationGallery(wsBox); // section 1
renderRobotGallery(rbBox); // section 2 fills in asynchronously
}
// ── section 1: WORKSTATION ──────────────────────────────────────────────────
async function renderWorkstationGallery(box) {
const body = gallerySection(box, '🖥 Workstation',
'converted, edited and imported maps in the project library',
() => renderWorkstationGallery(box));
body.innerHTML = '<div class="hint">scanning the map library…</div>';
let r;
try { r = await api('maps', {}); }
catch (e) { body.innerHTML = `<div class="errc">gallery failed: ${e.message}</div>`; return; }
body.innerHTML = '';
if (!r.maps.length) {
body.innerHTML = '<div class="hint">no maps yet — convert a Pudu export first</div>';
return;
}
const grid = document.createElement('div');
grid.className = 'gwrap';
body.appendChild(grid);
for (const m of r.maps) {
const card = document.createElement('div');
card.className = 'gcard' + (m.yaml === CUR_YAML ? ' cur' : '');
card.title = `${m.dir} — click to open the full map`;
const img = document.createElement('img');
img.className = 'thumb';
img.alt = m.name;
const nm = document.createElement('div');
nm.className = 'gname';
nm.textContent = m.name;
const meta = document.createElement('small');
meta.textContent = `${m.W}×${m.H} px · ${(m.W * m.res).toFixed(1)}×${(m.H * m.res).toFixed(1)} m · ${m.age} ago`;
card.append(img, nm, meta);
// per-card actions — same buttons as the sidebar map rows. They MUST
// stopPropagation or the card's open action fires underneath them.
const act = document.createElement('div');
act.className = 'gact';
const ren = document.createElement('button');
ren.className = 'sec';
ren.textContent = '✏️';
ren.title = 'Rename this map set (renames its folder in the library)';
ren.onclick = async (e) => {
e.stopPropagation();
const nn = prompt('Rename this map set to:', m.name);
if (!nn || nn === m.name) return;
try {
const r2 = await api('map_rename', { yaml: m.yaml, new: nn });
log(`renamed ${r2.from} -> ${r2.to}`, 'ok');
// the loaded map may have moved with it — resync the pointer, but stay
// on the Gallery tab (no activateMap, that would switch tabs)
if (m.yaml === CUR_YAML) { try { CUR_YAML = (await api('current', {})).yaml || ''; } catch (_) {} }
loadMaps();
renderWorkstationGallery(box);
} catch (er) { log('rename failed: ' + er.message, 'err'); }
};
const del = document.createElement('button');
del.className = 'sec';
del.textContent = '🗑';
del.title = 'Delete this map set from disk (its pgm/yaml/keepout/annotations files)';
del.onclick = async (e) => {
e.stopPropagation();
if (!confirm(`Delete map set "${m.name}" from ${m.dir}?\nThis removes its files from disk.`)) return;
try {
const r3 = await api('map_delete', { yaml: m.yaml });
log(`deleted ${m.name}: ${r3.deleted.join(', ')}${r3.dir_removed ? ' (folder removed)' : ''}`, 'ok');
if (m.yaml === CUR_YAML) clearCurrent();
loadMaps();
renderWorkstationGallery(box);
} catch (er) { log('delete failed: ' + er.message, 'err'); }
};
act.append(ren, del);
card.appendChild(act);
card.onclick = async () => {
try {
const j = await api('load', { yaml: m.yaml });
CUR_YAML = m.yaml;
activateMap(j, `opened ${m.name} — full map view`); // switches to the Map tab
loadMaps();
} catch (e) { log('open failed: ' + e.message, 'err'); }
};
grid.appendChild(card);
api('map_thumb', { yaml: m.yaml })
.then(t => { img.src = 'data:image/png;base64,' + t.png_b64; })
.catch(() => { img.replaceWith(Object.assign(document.createElement('div'),
{ className: 'thumb', textContent: 'no preview' })); });
}
}
// ---------- 📡 Lidar tab: live /scan over the map (R1 = camera, it has no LiDAR) ----------
// Talks the rosbridge protocol straight from the browser over a plain WebSocket —
// no external library (the page must stay self-contained/offline).
let LID = { ws: null, ip: '', robot: '', scan: null, pose: null, n: 0, t0: 0, hz: 0, mapImg: null, mapSeq: -1 };
function lidarSetStat(txt, cls) {
const el = $('lidarStat');
el.textContent = txt;
el.style.color = cls === 'err' ? 'var(--err)' : (cls === 'ok' ? 'var(--ok)' : 'var(--muted)');
}
function lidarDisconnect() {
if (LID.ws) { try { LID.ws.close(); } catch (_) {} }
LID.ws = null; LID.scan = null; LID.pose = null;
$('lidarConnect').textContent = 'Connect';
lidarSetStat('not connected');
lidarDraw();
}
async function lidarConnect() {
const ip = needIp(); if (!ip) return;
lidarDisconnect();
const rb = robot; // capture (see ensureStack)
const port = (ROBOTS[rb] || {}).rosbridge_port || 9090;
// same auto-start as the live RViz views — no point showing "cannot reach"
// when one ssh command fixes it
$('lidarConnect').disabled = true;
try {
lidarSetStat(`checking the ${rb.toUpperCase()} nav stack …`);
if (!(await ensureStack(rb, ip))) {
lidarSetStat(`the ${rb.toUpperCase()} nav stack is not up — see the Logs tab`, 'err');
return;
}
} catch (e) {
lidarSetStat('cannot start the stack: ' + e.message, 'err');
log('lidar: ' + e.message, 'err');
return;
} finally { $('lidarConnect').disabled = false; }
LID.ip = ip; LID.robot = robot; LID.n = 0; LID.t0 = performance.now();
lidarSetStat(`connecting ws://${ip}:${port}`);
let ws;
try { ws = new WebSocket(`ws://${ip}:${port}`); } catch (e) { lidarSetStat('bad address: ' + e.message, 'err'); return; }
LID.ws = ws;
ws.onopen = () => {
lidarSetStat(`connected ws://${ip}:${port} — waiting for /scan …`, 'ok');
$('lidarConnect').textContent = 'Disconnect';
const sub = (topic, type, rate) => ws.send(JSON.stringify(
{ op: 'subscribe', topic, type, throttle_rate: rate, queue_length: 1 }));
sub('/scan', 'sensor_msgs/LaserScan', 100);
sub('/amcl_pose', 'geometry_msgs/PoseWithCovarianceStamped', 200); // nav mode
sub('/odom', 'nav_msgs/Odometry', 200); // fallback
};
ws.onmessage = (ev) => {
let m; try { m = JSON.parse(ev.data); } catch (_) { return; }
if (m.op !== 'publish') return;
if (m.topic === '/scan') {
LID.scan = m.msg; LID.n++;
const dt = (performance.now() - LID.t0) / 1000;
if (dt > 1) { LID.hz = LID.n / dt; LID.n = 0; LID.t0 = performance.now(); }
lidarDraw();
} else if (m.topic === '/amcl_pose' || (m.topic === '/odom' && !LID.poseFromAmcl)) {
const p = m.msg.pose.pose || m.msg.pose;
if (m.topic === '/amcl_pose') LID.poseFromAmcl = true;
const q = p.orientation;
LID.pose = { x: p.position.x, y: p.position.y,
yaw: Math.atan2(2 * (q.w * q.z + q.x * q.y), 1 - 2 * (q.y * q.y + q.z * q.z)),
src: m.topic };
}
};
ws.onerror = () => lidarSetStat(`cannot reach ws://${ip}:${port} — is the nav stack running?`, 'err');
ws.onclose = () => { if (LID.ws === ws) { LID.ws = null; $('lidarConnect').textContent = 'Connect'; } };
}
$('lidarConnect').onclick = () => (LID.ws ? lidarDisconnect() : lidarConnect());
$('lidarOnMap').onchange = lidarDraw;
function lidarDraw() {
const cv = $('lidarCanvas'), wrap = $('lidarWrap');
if (!cv || cv.style.display === 'none') return;
const dpr = window.devicePixelRatio || 1;
const w = wrap.clientWidth, h = Math.round(window.innerHeight * 0.62);
if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
const g = cv.getContext('2d');
g.setTransform(dpr, 0, 0, dpr, 0, 0);
const css = getComputedStyle(document.documentElement), C = v => css.getPropertyValue(v).trim();
g.fillStyle = C('--stage-bg'); g.fillRect(0, 0, w, h);
const s = LID.scan;
if (!s) {
g.fillStyle = C('--muted'); g.font = '14px system-ui'; g.textAlign = 'center';
g.fillText(LID.ws ? 'connected — no /scan yet' : 'press Connect to see the live LiDAR', w / 2, h / 2);
return;
}
// scan -> points in the robot frame
const pts = [];
for (let i = 0; i < s.ranges.length; i++) {
const r = s.ranges[i];
if (!(r > (s.range_min || 0.01) && r < (s.range_max || 30) && isFinite(r))) continue;
const a = s.angle_min + i * s.angle_increment;
pts.push([r * Math.cos(a), r * Math.sin(a)]);
}
const onMap = $('lidarOnMap').checked && MAP && LID.pose;
if (onMap) {
const m = MAP.meta;
if (LID.mapSeq !== CONVERT_SEQ) { // (re)load the map bitmap
LID.mapImg = new Image();
LID.mapImg.onload = lidarDraw;
LID.mapImg.src = 'data:image/png;base64,' + MAP.layers.base;
LID.mapSeq = CONVERT_SEQ;
}
const sc = Math.min(w / m.W, h / m.H) * 0.95;
const ox = (w - m.W * sc) / 2, oy = (h - m.H * sc) / 2;
if (LID.mapImg && LID.mapImg.complete) {
g.imageSmoothingEnabled = false;
g.drawImage(LID.mapImg, ox, oy, m.W * sc, m.H * sc);
}
const toPx = (X, Y) => [ox + ((X - m.ox) / m.res) * sc, oy + (m.H - (Y - m.oy) / m.res) * sc];
const co = Math.cos(LID.pose.yaw), si = Math.sin(LID.pose.yaw);
g.fillStyle = C('--err');
for (const [px_, py_] of pts) { // robot frame -> map frame
const [cx, cy] = toPx(LID.pose.x + co * px_ - si * py_, LID.pose.y + si * px_ + co * py_);
g.fillRect(cx - 1, cy - 1, 2.2, 2.2);
}
const [rx, ry] = toPx(LID.pose.x, LID.pose.y); // robot
g.strokeStyle = C('--home'); g.fillStyle = C('--home'); g.lineWidth = 2;
g.beginPath(); g.arc(rx, ry, 6, 0, 7); g.fill();
g.beginPath(); g.moveTo(rx, ry); g.lineTo(rx + 22 * co, ry - 22 * si); g.stroke();
} else {
// robot-centric polar view + range rings
const maxR = Math.min(s.range_max || 12, 12);
const sc = Math.min(w, h) / 2 * 0.92 / maxR;
const cx = w / 2, cy = h / 2;
g.strokeStyle = C('--line'); g.fillStyle = C('--muted'); g.font = '11px ui-monospace';
for (let r = 1; r <= maxR; r++) {
g.beginPath(); g.arc(cx, cy, r * sc, 0, 7); g.stroke();
if (r % 2 === 0) g.fillText(r + ' m', cx + r * sc + 3, cy - 3);
}
g.fillStyle = C('--err');
for (const [X, Y] of pts) g.fillRect(cx + X * sc - 1.2, cy - Y * sc - 1.2, 2.4, 2.4);
g.strokeStyle = C('--home'); g.fillStyle = C('--home'); g.lineWidth = 2;
g.beginPath(); g.arc(cx, cy, 5, 0, 7); g.fill();
g.beginPath(); g.moveTo(cx, cy); g.lineTo(cx + 26, cy); g.stroke();
}
const rng = pts.length ? Math.min(...pts.map(p => Math.hypot(p[0], p[1]))).toFixed(2) : '';
lidarSetStat(`${pts.length} pts · ${LID.hz ? LID.hz.toFixed(1) : ''} Hz · nearest ${rng} m` +
(onMap ? ` · on map @ ${LID.pose.x.toFixed(2)}, ${LID.pose.y.toFixed(2)} (${LID.pose.src})`
: (LID.pose ? '' : ' · robot frame (no pose yet)')), 'ok');
}
window.addEventListener('resize', () => { if (ACTIVE_TAB === 'lidar') lidarDraw(); });
function lidarEnter() {
const isR1 = robot === 'r1';
const ip = ($('ip').value || '').trim();
// title = the robot YOU named in the list (+ its type/IP), not a hardcoded label
const t = (TARGETS && TARGETS[SEL_I]) || {};
const nm = (t.name || '').trim() || (robot || '?').toUpperCase();
const who = `${nm} · ${(robot || '?').toUpperCase()}${ip ? ' @ ' + ip : ''}`;
$('lidarCanvas').style.display = isR1 ? 'none' : 'block';
$('camView').style.display = isR1 ? 'block' : 'none';
$('lidarConnect').style.display = isR1 ? 'none' : '';
$('lidarOnMap').parentElement.style.display = isR1 ? 'none' : '';
if (isR1) {
$('lidarTitle').textContent = who + ' — camera (no LiDAR: stereo VSLAM)';
$('lidarNote').textContent = `MJPEG from http://${ip || '<ip>'}:8088/stream — served by the r1-cam container while the R1 stack runs.`;
if (ip) {
$('camView').src = `http://${ip}:8088/stream?t=` + Date.now();
lidarSetStat(`streaming http://${ip}:8088/stream`, 'ok');
$('camView').onerror = () => lidarSetStat(`no camera at ${ip}:8088 — is the R1 stack running?`, 'err');
} else lidarSetStat('enter the R1 IP in step 3', 'err');
} else {
$('camView').removeAttribute('src');
$('lidarTitle').textContent = who + ' — live /scan';
$('lidarNote').textContent = 'Live /scan from the robot\'s rosbridge. With a map loaded and a pose ' +
'(AMCL or odom) the scan is drawn ON the map — if the red points line up with the walls, the robot ' +
'is localized correctly on that map.';
lidarDraw();
}
}
// ── section 2: THE SELECTED ROBOT ───────────────────────────────────────────
async function renderRobotGallery(box) {
const rb = robot, ip = ($('ip').value || '').trim();
const t = (TARGETS && TARGETS[SEL_I]) || {};
const who = `${(t.name || rb || '?')} · ${(rb || '?').toUpperCase()}${ip ? ' @ ' + ip : ''}`;
const wrap = gallerySection(box, `🤖 Robot — ${who}`,
'maps that live on the robot · click one to pull it into the workstation library and open it',
() => renderRobotGallery(box));
if (!ip) { wrap.insertAdjacentHTML('beforeend', '<div class="hint">no IP for this robot</div>'); return; }
const info = document.createElement('div');
info.className = 'hint'; info.textContent = 'scanning the robot…';
wrap.appendChild(info);
let r;
try { r = await api('robot_maps', { robot: rb, ip }); }
catch (e) { info.textContent = 'robot scan failed: ' + e.message; return; }
info.remove();
if (!r.maps.length) {
wrap.insertAdjacentHTML('beforeend', `<div class="hint">no maps on the robot (${r.dir}) — Deploy puts one there</div>`);
return;
}
const grid = document.createElement('div');
grid.className = 'gwrap';
wrap.appendChild(grid);
for (const m of r.maps) {
// the name the robot's own tooling uses for this SET (folder, else file stem)
const key = (m.dir && m.dir !== '.') ? m.dir : m.name;
// IN USE = what nav2's map_server is serving right now. The server computes
// it (G1: <maps>/active.env, Go2: the canonical map.yaml/map.pgm pair).
const inUse = !!m.in_use;
const card = document.createElement('div');
card.className = 'gcard' + (m.same_as_loaded ? ' cur' : '') + (inUse ? ' inuse' : '');
card.title = `${r.dir}/${m.yaml} — click to import + open` +
(inUse ? `\n${rb.toUpperCase()}'s nav stack is serving this map right now` : '');
const img = document.createElement('img');
img.className = 'thumb'; img.alt = m.name;
const nm = document.createElement('div');
nm.className = 'gname'; nm.textContent = m.name;
const meta = document.createElement('small');
meta.textContent = `${m.dims} · ${(m.size / 1024).toFixed(0)} KB · ${m.age} ago` +
(m.same_as_loaded ? ' · = loaded' : '');
card.append(img, nm, meta);
if (inUse) {
const badge = document.createElement('span');
badge.className = 'gbadge';
badge.textContent = 'IN USE';
badge.title = `${rb.toUpperCase()}'s nav2 map_server is serving this map`;
card.appendChild(badge);
}
// per-card actions — same buttons as the robot map rows in the sidebar.
// stopPropagation, or the card's import+open action fires underneath them.
const act = document.createElement('div');
act.className = 'gact';
// USE — point nav2 at a map ALREADY on the robot. With several maps on board
// this is what you want instead of re-deploying the same files every time;
// it is also the ONLY safe way to free a map you want to delete, since the
// served map is guarded.
const use = document.createElement('button');
use.className = 'sec';
use.textContent = inUse ? '✓ in use' : 'Use';
use.disabled = !!inUse;
use.title = inUse
? 'nav2 is already serving this map'
: `Make "${key}" the map ${rb.toUpperCase()}'s nav2 serves. Takes effect on the next `
+ 'stack restart (the running map_server keeps the map it already loaded).';
use.onclick = async (e) => {
e.stopPropagation();
if (inUse) return;
use.disabled = true; use.textContent = 'switching…';
try {
const r = await api('robot_use_map', { robot: rb, ip, name: key });
log(`${rb.toUpperCase()} use-map ${key}: ${r.note || 'selected'}`, 'ok');
if (!r.already) log('restart the stack for it to take effect', 'ok');
} catch (err) {
log(`use-map ${key} FAILED — ${err.message}`, 'err');
}
renderRobotGallery(box); // same refresh the ✏️/🗑 handlers use
};
act.appendChild(use);
const ren = document.createElement('button');
ren.className = 'sec';
ren.textContent = '✏️';
ren.title = 'Rename this map ON the robot';
ren.onclick = async (e) => {
e.stopPropagation();
const nn = prompt(`Rename "${key}" on ${rb.toUpperCase()} to:`, key);
if (!nn || nn === key) return;
if (inUse && !confirm(
`⚠ "${key}" is the map ${rb.toUpperCase()} is SERVING right now.\n\n` +
`Renaming it moves the files nav2's map_server points at. ` +
`Re-select it with "Use" afterwards.\n\nRename anyway?`)) return;
try {
const u = await api('robot_map_rename', { robot: rb, ip, name: key, new: nn });
log(`${rb.toUpperCase()}: ${u.note}`, 'ok');
renderRobotGallery(box);
} catch (er) { log('rename on robot failed: ' + er.message, 'err'); }
};
const del = document.createElement('button');
del.className = 'sec';
del.textContent = '🗑';
del.title = 'Delete this map FROM the robot';
del.onclick = async (e) => {
e.stopPropagation();
if (!confirm(`Delete "${key}" from ${rb.toUpperCase()} @ ${ip}?\nThis removes it from the robot's disk.`)) return;
// IN-USE GUARD: deleting the served map kills the nav stack until someone
// picks another with Use. Warn hard — then obey. force:true is what the
// server requires before it will touch the active map.
let force = false;
if (inUse) {
force = confirm(
`⚠ STOP — "${key}" is the map ${rb.toUpperCase()} is ACTIVELY SERVING.\n\n` +
`nav2's map_server is reading this map. If you delete it the navigation ` +
`stack will FAIL TO START and stay dead until you select another map ` +
`with the "Use" button.\n\n` +
`Delete the map the robot is navigating with anyway?`);
if (!force) { log(`kept ${key} — it is the map ${rb.toUpperCase()} is serving`, 'ok'); return; }
}
try {
const u = await api('robot_map_delete', { robot: rb, ip, name: key, force });
log(`${rb.toUpperCase()}: ${u.note}` +
(force ? ` — pick another map with Use before starting nav` : ''), force ? 'err' : 'ok');
renderRobotGallery(box);
} catch (er) { log('delete on robot failed: ' + er.message, 'err'); }
};
act.append(ren, del);
card.appendChild(act);
card.onclick = async () => {
nm.textContent = 'importing…';
try {
const j = await api('robot_map_import', { robot: rb, ip, yaml: m.yaml });
CUR_YAML = j.yaml || '';
activateMap(j, `imported ${m.name} from ${rb.toUpperCase()} -> ${shortPath(j.imported || '')}`);
loadMaps();
} catch (e) { log('import failed: ' + e.message, 'err'); nm.textContent = m.name; }
};
grid.appendChild(card);
api('robot_map_thumb', { robot: rb, ip, yaml: m.yaml, md5: m.md5 })
.then(t2 => { img.src = 'data:image/png;base64,' + t2.png_b64; })
.catch(() => { img.replaceWith(Object.assign(document.createElement('div'),
{ className: 'thumb', textContent: 'no preview' })); });
}
}
// ---------- Status tab: DYNAMIC robot list + fleet scan ----------
let STATUS_TS = 0, STATUS_BUSY = false, TARGETS = null;
function defaultTargets() {
// NO static IPs: seed only with what YOU typed before (wifi or eth) — else empty
return Object.keys(ROBOTS).map(k => ({ name: k.toUpperCase(), robot: k,
eth: (localStorage.getItem('pudu_ip_' + k) || '').trim(), wifi: '', use: 'eth' }));
}
function ensureTargets() {
if (TARGETS) return;
const raw = localStorage.getItem('pudu_status_targets');
if (raw !== null) {
try { TARGETS = JSON.parse(raw); } catch (e) { TARGETS = null; }
if (!Array.isArray(TARGETS)) TARGETS = null;
}
if (TARGETS === null) { // FIRST RUN (or corrupt storage)
if (!Object.keys(ROBOTS).length) return; // types not loaded yet — retry later
TARGETS = defaultTargets();
}
// a deliberately-emptied list ([]) persists as empty — no default resurrection.
// Coerce unknown types (hand-edited storage) to a known one, and MIGRATE old
// single-ip entries to the eth/wifi pair model.
TARGETS.forEach(t => {
if (Object.keys(ROBOTS).length && !ROBOTS[t.robot]) t.robot = Object.keys(ROBOTS)[0];
if (t.eth === undefined) t.eth = (t.ip || '').trim();
if (t.wifi === undefined) t.wifi = '';
t.use = t.use === 'wifi' ? 'wifi' : 'eth';
delete t.ip;
});
renderTargets();
}
const saveTargets = () => localStorage.setItem('pudu_status_targets', JSON.stringify(TARGETS));
function renderTargets() {
const box = $('stTargets');
box.innerHTML = '';
TARGETS.forEach((t, i) => {
const row = document.createElement('div');
row.className = 'strow';
const nm = document.createElement('input');
nm.type = 'text'; nm.value = t.name || ''; nm.placeholder = 'name'; nm.style.width = '130px';
nm.oninput = () => { t.name = nm.value; saveTargets(); syncSel(); };
const sel = document.createElement('select');
for (const k of Object.keys(ROBOTS)) {
const o = document.createElement('option');
o.value = k; o.textContent = k;
if (k === t.robot) o.selected = true;
sel.appendChild(o);
}
sel.onchange = () => { t.robot = sel.value; saveTargets(); syncSel(); };
// syncSel (NOT renderTargets): re-rendering the row being typed in would eat focus
const mkip = (key, ph) => {
const i = document.createElement('input');
i.type = 'text'; i.value = t[key] || ''; i.placeholder = ph;
i.style.width = '125px'; i.className = 'mono';
i.oninput = () => { t[key] = i.value.trim(); saveTargets(); syncSel(); };
return i;
};
const ipEth = mkip('eth', 'eth0 ip');
const ipWifi = mkip('wifi', 'wifi ip');
const use = document.createElement('select');
for (const u of ['eth', 'wifi']) {
const o = document.createElement('option');
o.value = u; o.textContent = u === 'eth' ? 'use eth0' : 'use wifi';
if ((t.use === 'wifi') === (u === 'wifi')) o.selected = true;
use.appendChild(o);
}
use.onchange = () => { t.use = use.value; saveTargets(); syncSel(); };
const del = document.createElement('button');
del.className = 'sec logbtn'; del.textContent = '✕'; del.title = 'Remove this robot from the list';
del.onclick = () => { TARGETS.splice(i, 1); saveTargets(); renderTargets(); };
row.append(nm, sel, ipEth, ipWifi, use, del);
box.appendChild(row);
});
// ONE registry everywhere: keep the step-2 robot list in sync (by identity)
syncSel();
}
$('stAdd').onclick = () => {
ensureTargets();
TARGETS.push({ name: 'ROBOT' + (TARGETS.length + 1), robot: 'go2',
eth: '', wifi: '', use: 'eth' });
saveTargets();
renderTargets();
};
async function refreshStatus() {
if (STATUS_BUSY) return;
ensureTargets();
STATUS_BUSY = true;
$('stRefresh').disabled = true;
$('stTs').textContent = `scanning ${TARGETS.length} robot(s) (ping + ssh + rosbridge)…`;
try {
const s = await api('status',
{ targets: TARGETS.map(t => ({ name: t.name, robot: t.robot, ip: effIp(t) })) });
STATUS_TS = Date.now();
const esc = t => String(t).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const Y = v => v ? '<span class="okc">✓</span>' : '<span class="errc">✗</span>';
let h = '';
h += '<div class="stcard"><h4>Current map</h4>';
if (s.map) {
const m = s.map.meta;
h += `<div><b>${esc(shortPath(s.map.out_dir || ''))}</b> — ${m.W}×${m.H}px · ` +
`${m.size_m[0]}×${m.size_m[1]} m · ${m.res} m/px · origin ${m.ox.toFixed(2)}, ${m.oy.toFixed(2)}</div>` +
`<div class="hint mono">${esc(s.map.yaml)}</div>`;
} else h += '<div class="mutc">none loaded — convert or Load one</div>';
h += '</div>';
h += '<div class="stcard"><h4>Connected now</h4>';
if (s.connected.length) {
h += '<table class="sttbl"><tr><th>robot</th><th>type</th><th>ip</th><th>host</th>' +
'<th>nav stack</th><th>running</th><th>rosbridge :9090</th><th>detail</th></tr>';
for (const r of s.connected) {
h += `<tr><td><b>${esc(r.name)}</b>${r.identified ? '' : ' <span class="mutc">(unverified)</span>'}</td>` +
`<td>${esc(r.robot)}</td><td class="mono">${esc(r.ip)}</td><td class="mono">${esc(r.hostname || '—')}</td>` +
`<td>${r.installed ? '<span class="okc">installed</span>' : '<span class="errc">not installed</span>'}</td>` +
`<td>${r.running ? '<span class="okc">RUNNING</span>' : '<span class="mutc">stopped</span>'}</td>` +
`<td>${Y(r.rosbridge)}</td><td class="wrapcell mutc">${esc(r.detail || '')}</td></tr>`;
}
h += '</table>';
} else {
h += '<div class="errc">no robot connected — plug the eth cable / check the IPs in the scan targets</div>';
}
if (s.offline.length) {
h += '<div class="hint" style="margin-top:8px">' + s.offline.map(o =>
`offline: <b>${esc(o.targets)}</b>${o.ip ? ' @ <span class="mono">' + esc(o.ip) + '</span>' : ''}${esc(o.reason)}`
).join('<br>') + '</div>';
}
h += '<div class="hint" style="margin-top:7px">the machine on each IP is IDENTIFIED before reporting ' +
'(G1 &amp; R1 share 192.168.123.164 — only the robot actually on the wire is shown).</div></div>';
const v = s.viewers || {};
const V = x => x === null ? '<span class="mutc">?</span>' : Y(x);
h += `<div class="stcard"><h4>Workstation viewers (this container)</h4>` +
`<div>local /map publisher ${V(v.local_map_pub)} &nbsp;·&nbsp; rosbridge relay ${V(v.relay)} ` +
`&nbsp;·&nbsp; RViz ${V(v.rviz2)}</div></div>`;
$('stBody').innerHTML = h;
$('stTs').textContent = `scanned at ${s.ts}`;
} catch (e) {
$('stBody').innerHTML = `<div class="errc">status scan failed: ${e.message}</div>`;
$('stTs').textContent = '';
}
$('stRefresh').disabled = false;
STATUS_BUSY = false;
}
$('stRefresh').onclick = refreshStatus;
window.addEventListener('message', async (e) => {
if (e.data !== 'pudu-map-saved') return;
try {
const r = await fetch('/api/current', { method: 'POST', body: '{}' });
const j = await r.json();
if (j.ok === false) throw new Error(j.error);
const seq = CONVERT_SEQ;
activateMap(j, 'map edits saved — map + baked variant rebuilt; preview refreshed');
CONVERT_SEQ = seq; // keep the editor's state (same map, just saved)
EDITOR_SEQ = seq;
loadMaps();
} catch (err) { log('edit refresh failed: ' + err.message, 'err'); }
});
function copyText(txt) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(txt);
return new Promise((res, rej) => { // http/LAN fallback
const ta = document.createElement('textarea');
ta.value = txt; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); res(); } catch (e) { rej(e); }
document.body.removeChild(ta);
});
}
function copyLog(btn) {
if (!LOG_LINES.length) return;
const txt = LOG_LINES.map(l => `${l.time} ${l.msg}`).join('\n'); // oldest→newest
copyText(txt).then(() => {
if (btn) { const o = btn.textContent; btn.textContent = `copied ${LOG_LINES.length}`; setTimeout(() => btn.textContent = o, 1300); }
}).catch(() => log('copy failed', 'err'));
}
function exportLog() {
if (!LOG_LINES.length) { log('nothing to export yet', 'err'); return; }
const txt = LOG_LINES.map(l => `${l.time} ${l.msg}`).join('\n'); // oldest→newest
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([txt], { type: 'text/plain' }));
a.download = 'pudu_gui_' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.log';
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 500);
log(`exported ${LOG_LINES.length} log lines to ${a.download}`, 'ok');
}
function clearLog() {
LOG_LINES = [];
$('logsBody').innerHTML = '<div class="none">no log lines yet</div>';
$('logDot').style.display = 'none';
if (LOG_WIN && !LOG_WIN.closed) { const L = LOG_WIN.document.getElementById('L'); if (L) L.innerHTML = ''; }
}
function tabAppend(l) {
const L = LOG_WIN.document.getElementById('L'); if (!L) return;
const d = LOG_WIN.document.createElement('div');
if (l.cls) d.className = l.cls;
d.textContent = `${l.time} ${l.msg}`;
L.appendChild(d);
LOG_WIN.scrollTo(0, LOG_WIN.document.body.scrollHeight);
}
function openLogTab() {
LOG_WIN = window.open('', 'pudu_logs', 'width=760,height=560');
if (!LOG_WIN) { log('popup blocked — allow popups for this page', 'err'); return; }
LOG_WIN.document.open();
LOG_WIN.document.write(
'<!doctype html><meta charset="utf-8"><title>Pudu Map GUI — Logs</title>' +
'<style>body{background:#0e1013;color:#e8e6e1;font:13px/1.55 ui-monospace,Menlo,Consolas,monospace;margin:0;padding:0}' +
'.bar{position:sticky;top:0;background:#0e1013;border-bottom:1px solid #2e323a;padding:10px 14px;display:flex;gap:8px;align-items:center}' +
'#L{padding:12px 14px}#L .ok{color:#5aa86b}#L .err{color:#e05a4f}h3{margin:0;flex:1;font:600 13px system-ui,sans-serif}' +
'button{background:#262a31;color:#e8e6e1;border:1px solid #2e323a;border-radius:5px;padding:4px 10px;font:inherit;cursor:pointer}' +
'@media(prefers-color-scheme:light){body{background:#f5f4f0;color:#1a1d22}.bar{background:#f5f4f0;border-color:#dcdad3}button{background:#eceae4;color:#1a1d22;border-color:#dcdad3}#L .ok{color:#2e7d3d}#L .err{color:#c0392b}}</style>' +
'<div class="bar"><h3>Pudu Map GUI — Logs</h3>' +
'<button onclick="var t=document.getElementById(\'L\').innerText;navigator.clipboard&&navigator.clipboard.writeText(t)">Copy all</button>' +
'<button onclick="document.getElementById(\'L\').innerHTML=\'\'">Clear</button></div><div id="L"></div>');
LOG_WIN.document.close();
LOG_LINES.forEach(tabAppend); // seed with existing history (chronological)
}
async function api(ep, body) {
const r = await fetch('/api/' + ep, { method: 'POST', body: JSON.stringify(body || {}) });
const j = await r.json();
if (!r.ok || j.ok === false) throw new Error(j.error || r.statusText);
return j;
}
// ---------- robots: ONE dynamic registry (shared with the Status tab) ----------
let SEL_I = 0, SEL_REF = null; // SEL_REF = the selected ENTRY (identity, not index)
async function loadRobots() {
try {
ROBOTS = await (await fetch('/api/robots')).json();
} catch (e) {
$('robots').innerHTML = '<div class="hint">robot types failed to load: ' + e.message + ' — reload the page</div>';
log('robot types load failed: ' + e.message, 'err');
return;
}
ensureTargets();
renderRobotList();
selectTarget(0);
}
// re-derive the selection BY IDENTITY after any list mutation (add/delete/edit):
// a raw index would silently re-target step 2/3 to a different robot
function syncSel() {
if (!TARGETS || !Object.keys(ROBOTS).length) return;
const i = SEL_REF ? TARGETS.indexOf(SEL_REF) : -1;
if (SEL_REF && i === -1 && TARGETS.length)
log(`selected robot was removed — now targeting ${TARGETS[0].name || TARGETS[0].robot}`, 'err');
SEL_I = i >= 0 ? i : 0;
renderRobotList();
selectTarget(SEL_I);
}
function renderRobotList() {
if (!Object.keys(ROBOTS).length) return;
const box = $('robots');
box.innerHTML = '';
if (!TARGETS || !TARGETS.length) {
box.innerHTML = '<div class="hint">no robots in the list — add one in the Status tab</div>';
return;
}
TARGETS.forEach((t, i) => {
const v = ROBOTS[t.robot] || {};
const l = document.createElement('label');
if (i === SEL_I) l.classList.add('on');
const inp = document.createElement('input');
inp.type = 'radio'; inp.name = 'robot'; inp.checked = i === SEL_I;
const sp = document.createElement('span');
const b = document.createElement('b');
b.textContent = t.name || (t.robot || '?').toUpperCase();
sp.appendChild(b);
sp.appendChild(document.createTextNode(
`${(t.robot || '?').toUpperCase()} · ${t.use === 'wifi' ? 'wifi' : 'eth0'}: ${effIp(t) || 'no IP'}`));
const sm = document.createElement('small');
sm.textContent = v.label || `unknown type '${t.robot}'`;
sp.appendChild(sm);
l.append(inp, sp);
l.addEventListener('change', () => selectTarget(i));
box.appendChild(l);
});
}
// each robot carries BOTH addresses; t.use picks which one is active
const effIp = t => ((t.use === 'wifi' ? t.wifi : t.eth) || '').trim();
function selectTarget(i) {
if (!TARGETS || !TARGETS.length) {
SEL_REF = null;
$('robotNote').textContent = 'no robots — add one in the Status tab';
$('ipSuggest').style.display = 'none';
if (document.activeElement !== $('ip')) $('ip').value = '';
return;
}
SEL_I = Math.max(0, Math.min(i, TARGETS.length - 1));
const t = TARGETS[SEL_I];
SEL_REF = t;
if (ROBOTS[t.robot]) robot = t.robot;
document.querySelectorAll('#robots label').forEach((l, j) =>
l.classList.toggle('on', j === SEL_I));
const v = ROBOTS[robot] || {};
$('robotNote').textContent = v.note || '';
$('dest').placeholder = v.deploy_dir || '';
refreshDest(); // per-robot maps root + this map's name
$('useEth').classList.toggle('use-on', t.use !== 'wifi');
$('useWifi').classList.toggle('use-on', t.use === 'wifi');
const sug = (v.presets && v.presets.eth) || '';
const sb = $('ipSuggest');
if (sug && sug !== t.eth) {
sb.style.display = '';
sb.textContent = '💡 recommended eth0: ' + sug;
sb.onclick = () => { t.eth = sug; t.use = 'eth'; saveTargets(); renderTargets(); };
} else sb.style.display = 'none';
// never yank the caret out from under the user mid-typing
if (document.activeElement !== $('ip')) $('ip').value = effIp(t);
clearTimeout(CONN_T);
CONN_T = setTimeout(() => refreshConn(true), 400); // debounced: typing an IP
}
// the IP field edits the SELECTED robot's ACTIVE (eth0/wifi) address
$('ip').oninput = () => {
const t = TARGETS && TARGETS[SEL_I];
if (t) { t[t.use === 'wifi' ? 'wifi' : 'eth'] = $('ip').value.trim(); saveTargets(); renderTargets(); }
};
function setUse(u) {
const t = TARGETS && TARGETS[SEL_I];
if (!t) return;
t.use = u; saveTargets(); renderTargets();
// empty slot? ask the robot for its own address over whatever link works now
if (!((u === 'wifi' ? t.wifi : t.eth) || '').trim()) discoverIps(t, u);
}
async function discoverIps(t, want) {
const via = (t.eth || t.wifi || '').trim();
if (!via) { log(`enter one address for ${t.name || t.robot} first — then ${want} can be discovered`, 'err'); return; }
try {
const r = await api('robot_ips', { ip: via });
let got = '';
if (want === 'wifi' && r.wifi) { t.wifi = r.wifi; got = r.wifi; }
if (want === 'eth' && r.eth) { t.eth = r.eth; got = r.eth; }
if (got) {
saveTargets(); renderTargets();
log(`${(t.name || t.robot).toUpperCase()} ${want} address discovered: ${got}`, 'ok');
} else {
log(`the robot reports no ${want} address` + (r.other && r.other.length ? ' (other: ' + r.other.join(', ') + ')' : ''), 'err');
}
} catch (e) { log('address discovery failed: ' + e.message, 'err'); }
}
$('useEth').onclick = () => setUse('eth');
$('useWifi').onclick = () => setUse('wifi');
// ---------- convert ----------
function applyMeta(m) {
$('metaChips').style.display = 'flex';
$('metaChips').innerHTML =
`<span class="chip"><b>${m.W}×${m.H}</b> px</span>` +
`<span class="chip"><b>${m.size_m[0]}×${m.size_m[1]}</b> m</span>` +
`<span class="chip">origin <b>${m.ox.toFixed(2)}, ${m.oy.toFixed(2)}</b></span>` +
`<span class="chip">free <b>${m.free_m2} m²</b></span>` +
`<span class="chip">keepout zones <b>${m.n_forbidden}</b></span>`;
}
async function doConvert() {
$('btnConvert').disabled = true;
try {
log('converting ' + $('path').value + ' ...');
const j = await api('convert', { path: $('path').value });
const m = j.meta;
activateMap(j, `converted: ${m.W}x${m.H}px = ${m.size_m[0]}x${m.size_m[1]}m, free ${m.free_m2}m², ` +
`${m.n_forbidden} forbidden zones — saved to ${shortPath(j.out_dir)}`);
loadMaps();
} catch (e) { log('convert failed: ' + e.message, 'err'); }
$('btnConvert').disabled = false;
}
$('btnConvert').onclick = doConvert;
// native OS picker (server-side dialog on this same desktop) -> fill path -> convert
async function browse(mode) {
try {
const r = await api('pick', { mode, start: $('path').value.trim() });
if (r.cancelled) return;
$('path').value = r.path;
await doConvert();
} catch (e) { log('browse failed: ' + e.message, 'err'); }
}
$('btnBrowseDir').onclick = () => browse('dir');
$('btnBrowseZip').onclick = () => browse('zip');
// ---------- recent maps library ----------
let CUR_YAML = '';
const shortPath = p => p.replace(/^\/home\/[^/]+/, '~').replace(/^~\/Robotics_workspace\/yslootahtech\//, '');
function activateMap(j, label) {
MAP = j;
CONVERT_SEQ++;
if (j.yaml) CUR_YAML = j.yaml;
applyMeta(j.meta);
if (j.out_dir) $('metaChips').insertAdjacentHTML('beforeend',
`<span class="chip" title="${j.out_dir}">📁 <b>${shortPath(j.out_dir)}</b></span>`);
['btnStage', 'btnDeploy', 'btnPing', 'btnRviz', 'btnRvizLocal', 'btnRvizCombo'].forEach(b => $(b).disabled = false);
refreshDest(); // this map's name -> the deploy path
showTab('map');
render();
if (label) log(label, 'ok');
}
function clearCurrent() { // the loaded map was deleted from disk
MAP = null; CUR_YAML = '';
$('stage').innerHTML = '';
$('empty').style.display = '';
$('layerbox').style.display = 'none';
$('readout').style.display = 'none';
$('goalBtn').style.display = 'none';
$('liveBtn').style.display = 'none';
$('cancelBtn').style.display = 'none';
$('armBtn').style.display = 'none';
LIVE.g = {}; // the SVG groups died with the stage
$('metaChips').style.display = 'none';
['btnStage', 'btnDeploy', 'btnRviz', 'btnRvizLocal', 'btnRvizCombo'].forEach(b => $(b).disabled = true);
showTab('map');
}
async function loadMaps() {
try {
const r = await api('maps', {});
const box = $('mapList');
box.innerHTML = '';
if (!r.maps.length) { box.innerHTML = '<div class="hint">no map sets found</div>'; return; }
for (const m of r.maps) {
const row = document.createElement('div');
row.className = 'maprow' + (m.yaml === CUR_YAML ? ' cur' : '');
row.innerHTML = `<div class="inf"><b>${m.name}</b> · ${m.W}×${m.H} · ${(m.W * m.res).toFixed(1)}×${(m.H * m.res).toFixed(1)} m · ${m.age} ago<small>${m.dir}</small></div>`;
const b = document.createElement('button');
b.className = 'sec';
b.textContent = m.yaml === CUR_YAML ? 'Loaded' : 'Load';
b.onclick = async () => {
try {
const j = await api('load', { yaml: m.yaml });
CUR_YAML = m.yaml;
$('path').value = m.yaml;
activateMap(j, `loaded ${m.name} (${m.dir})`);
loadMaps();
} catch (e) { log('load failed: ' + e.message, 'err'); }
};
row.appendChild(b);
const ren = document.createElement('button');
ren.className = 'sec';
ren.textContent = '✏️';
ren.title = 'Rename this map set (renames its folder in the library)';
ren.onclick = async () => {
const nn = prompt('Rename this map set to:', m.name);
if (!nn || nn === m.name) return;
try {
const r2 = await api('map_rename', { yaml: m.yaml, new: nn });
log(`renamed ${r2.from} -> ${r2.to}`, 'ok');
if (m.yaml === CUR_YAML) { const j = await api('current', {}); CUR_YAML = j.yaml || ''; activateMap(j, ''); }
loadMaps();
} catch (e) { log('rename failed: ' + e.message, 'err'); }
};
row.appendChild(ren);
const del = document.createElement('button');
del.className = 'sec';
del.textContent = '🗑';
del.title = 'Delete this map set from disk (its pgm/yaml/keepout/annotations files)';
del.onclick = async () => {
if (!confirm(`Delete map set "${m.name}" from ${m.dir}?\nThis removes its files from disk.`)) return;
try {
const r = await api('map_delete', { yaml: m.yaml });
log(`deleted ${m.name}: ${r.deleted.join(', ')}${r.dir_removed ? ' (folder removed)' : ''}`, 'ok');
if (m.yaml === CUR_YAML) clearCurrent();
loadMaps();
} catch (e) { log('delete failed: ' + e.message, 'err'); }
};
row.appendChild(del);
box.appendChild(row);
}
} catch (e) { $('mapList').innerHTML = '<div class="hint">scan failed: ' + e.message + '</div>'; }
}
$('mapsRefresh').onclick = loadMaps;
loadMaps();
// ---------- actions ----------
$('btnStage').onclick = async () => {
try {
const r = await api('stage', { robot });
log(r.noop ? `${robot.toUpperCase()}: ${r.note}`
: `staged for ${robot.toUpperCase()}: ${r.files.join(', ')} -> ${shortPath(r.staged)}`, 'ok');
loadMaps();
} catch (e) { log('stage failed: ' + e.message, 'err'); }
};
$('btnPing').onclick = async () => {
const ip = $('ip').value.trim();
if (!ip) return log('enter an IP first', 'err');
try {
const r = await api('ping', { ip });
log(`ping ${ip}: ${r.ok ? 'OK' : 'NO RESPONSE'}`, r.ok ? 'ok' : 'err');
} catch (e) { log('ping failed: ' + e.message, 'err'); }
};
$('btnDeploy').onclick = async () => {
const ip = $('ip').value.trim();
if (!ip) return log('enter the robot IP', 'err');
refreshConn(true); // show connected / not connected for this deploy
await refreshDest(); // deploy under THIS map's name unless you typed one
if (robot === 'g1' && !confirm('Standing hold: deploys to the new G1 are held. Deploy anyway?')) return;
const scan = await scanRobotMaps(true); // don't send a map that is already there
if (scan && scan.duplicate) {
const dupName = (scan.maps.find(m => m.same_as_loaded) || {}).yaml || '';
if (!confirm(`This exact map is ALREADY on ${robot.toUpperCase()} (${dupName}).\n\n` +
'Deploy again anyway?')) { log('deploy skipped — identical map already on the robot', 'ok'); return; }
}
$('btnDeploy').disabled = true;
try {
log(`deploying to ${robot.toUpperCase()} @ ${ip} ...`);
const r = await api('deploy', { robot, ip, dest: $('dest').value.trim() });
log(`deployed: ${r.files.join(', ')} -> ${r.deployed}`, 'ok');
refreshConn(true);
scanRobotMaps(true); // show the new folder immediately
} catch (e) { log('deploy failed: ' + e.message, 'err'); }
$('btnDeploy').disabled = false;
};
// ---------- nav-stack install / uninstall / detect ----------
let JOB_N = 0, JOB_POLLING = false, JOB_CTX = null;
function jobLine(l, cls) { // job output -> Logs tab (+ copy/pop-out), not the small side log
const time = new Date().toLocaleTimeString();
LOG_LINES.push({ time, msg: l, cls });
paneAppend({ time, msg: l, cls });
if (cls === 'err' && ACTIVE_TAB !== 'logs') $('logDot').style.display = 'block';
if (LOG_WIN && !LOG_WIN.closed) tabAppend({ time, msg: l, cls });
}
async function pollJob() {
if (JOB_POLLING) return;
JOB_POLLING = true;
let errors = 0;
try {
while (true) {
let j;
try {
j = await api('job', { since: JOB_N });
errors = 0;
} catch (e) { // transient fetch error must not kill the stream
if (++errors >= 5) { log('job stream lost after retries: ' + e.message + ' — job continues server-side', 'err'); break; }
await new Promise(r => setTimeout(r, 2000));
continue;
}
j.lines.forEach(l => jobLine(l, /error|failed|denied/i.test(l) ? 'err' : ''));
JOB_N = j.n;
if (!j.running) {
log(`${j.kind} ${j.robot}: ${j.rc === 0 ? 'DONE ✓' : 'FAILED (rc=' + j.rc + ') — see Logs tab'}`,
j.rc === 0 ? 'ok' : 'err');
const ctx = JOB_CTX; JOB_CTX = null;
if (ctx) checkInstall(ctx.robot, ctx.ip); // re-scan the JOB's robot, not the current selection
break;
}
await new Promise(r => setTimeout(r, 1500));
}
} finally { JOB_POLLING = false; }
}
function needIp() {
const ip = $('ip').value.trim();
if (!ip) { log('enter the robot IP in step 3 first', 'err'); return null; }
return ip;
}
async function checkInstall(r, i) {
const rb = r || robot;
const ip = i || needIp(); if (!ip) return;
const el = $('instStatus');
const mine = () => rb === robot && ip === $('ip').value.trim(); // selection may change mid-scan
if (mine()) el.textContent = `checking ${rb.toUpperCase()} @ ${ip}`;
try {
const s = await api('install_status', { robot: rb, ip });
const state = !s.reachable ? `UNREACHABLE (${s.detail})`
: s.installed ? `INSTALLED ✓ — ${s.detail}` : `NOT installed — ${s.detail}`;
if (mine()) el.textContent = `${rb.toUpperCase()} @ ${ip}: ${state}`;
log(`nav-stack scan ${rb.toUpperCase()} @ ${ip}: ${state}`,
s.reachable && s.installed ? 'ok' : (s.reachable ? '' : 'err'));
} catch (e) {
if (mine()) el.textContent = 'check failed: ' + e.message;
log('check failed: ' + e.message, 'err');
}
}
$('btnInstCheck').onclick = () => checkInstall();
$('btnInstall').onclick = async () => {
const ip = needIp(); if (!ip) return;
if (robot === 'g1' && !confirm('Standing hold: deploys to the new G1 are held. Install anyway?')) return;
if (!confirm(`Install the ${robot.toUpperCase()} nav stack on ${ip}?\n` +
'Go2/R1: rsync + docker build ON the robot (~10-15 min first time, robot needs internet).\n' +
'G1: file sync only. Nothing is started, no motion. Progress streams to the Logs tab.')) return;
try {
const r = await api('install', { robot, ip });
log(`install ${r.robot} started — watch the Logs tab`, 'ok');
showTab('logs');
JOB_N = 0; // new job = fresh server-side line list
JOB_CTX = { robot, ip };
pollJob();
} catch (e) { log('install: ' + e.message, 'err'); }
};
$('btnUninstall').onclick = async () => {
const ip = needIp(); if (!ip) return;
const what = { g1: '~/nav_g1 (file tree only)',
go2: 'go2-nav2 image + containers + ~/nav_go2',
r1: 'r1-nav2 + r1-vslam images, r1-* containers, ~/nav_r1 + ~/nav_r1_vslam' }[robot];
if (!confirm(`Uninstall the ${robot.toUpperCase()} nav stack from ${ip}?\nRemoves: ${what}\n` +
'Nothing else on the robot is touched. Refuses if the stack is running.')) return;
try {
const r = await api('uninstall', { robot, ip });
log(`uninstall ${r.robot} started — watch the Logs tab`, 'ok');
showTab('logs');
JOB_N = 0; // new job = fresh server-side line list
JOB_CTX = { robot, ip };
pollJob();
} catch (e) { log('uninstall: ' + e.message, 'err'); }
};
// Anything that needs the LIVE robot (RViz live views, the Lidar tab) goes
// through here: if the robot's nav stack is down, start it over ssh and wait
// for its rosbridge. Bring-up alone never moves the robot.
// rb = the robot this operation targets, CAPTURED once. Re-reading the global
// mid-wait let a selection change re-target the flow: it waited for the G1's
// :9091, then reported "up on :9090" — the other service on that shared IP —
// and attached RViz to a bridge with no TF (no robot on the map).
async function ensureStack(rb, ip) {
const guard = () => {
if (rb !== robot) throw new Error(
`robot selection changed (${rb.toUpperCase()}${robot.toUpperCase()}) — aborted`);
};
const u0 = await api('rosbridge_up', { robot: rb, ip });
if (u0.up) return true;
log(`${rb.toUpperCase()} nav stack is down — starting it on ${ip}`, 'ok');
const s = await api('stack_start', { robot: rb, ip }); // refuses on wrong robot/IP
if (!s.already) log(s.note || 'stack starting', 'ok');
for (let i = 0; i < 60; i++) { // up to ~3 min: bring-up is slow
await new Promise(r => setTimeout(r, 3000));
guard();
const u = await api('rosbridge_up', { robot: rb, ip });
if (u.up) { log(`${rb.toUpperCase()} stack is up on :${u.port}`, 'ok'); return true; }
// FAIL FAST: read the robot's own start log — a bring-up that aborted (missing
// dependency, bad flag) must not cost 3 minutes of silent waiting
if (i === 2 || i === 6 || i === 14) {
let lg = '';
try { lg = (await api('stack_log', { robot: rb, ip })).log; } catch (_) {}
const bad = lg.split('\n').filter(l => /ERROR|FATAL|Traceback|No module named/i.test(l));
if (bad.length) {
log(`${rb.toUpperCase()} bring-up FAILED on the robot: ${bad.slice(-2).join(' | ')}`, 'err');
return false;
}
if (i === 6) log(`still waiting for :${u.port} on ${ip}`);
}
}
let tail = '';
try { tail = (await api('stack_log', { robot: rb, ip })).log.split('\n').slice(-3).join(' | '); } catch (_) {}
log(`${rb.toUpperCase()} stack did not come up in ~3 min. Robot log: ${tail || '(empty)'}`, 'err');
return false;
}
async function openLive(ep, label) {
const ip = needIp(); if (!ip) return;
const rb = robot; // capture the target for the whole flow
const btns = ['btnRviz', 'btnRvizCombo'];
btns.forEach(b => $(b).disabled = true);
try {
if (!(await ensureStack(rb, ip))) return;
const r = await api(ep, { robot: rb, ip });
log(label + ' (' + rb.toUpperCase() + '): ' + r.note, 'ok');
// The #1 cause of "Frame [map] does not exist": AMCL is not seeded. It
// forgets the pose on EVERY stack restart, so check and say so plainly.
try {
const L = await api('localized', { robot: rb, ip });
if (L.seeded === false) {
const lp = L.last_pose;
log('RViz will show "Frame [map] does not exist": the robot has no place on the map yet — ' +
'press 📍 Set location where it stands' +
(lp ? `, or 📍 Re-seed last pose (${lp.x.toFixed(2)}, ${lp.y.toFixed(2)} from ${lp.at})` : ''), 'err');
} else if (L.seeded) {
log(`${rb.toUpperCase()} is localized on the map (map->odom present)`, 'ok');
}
} catch (_) {}
} catch (e) {
log(label + ' failed: ' + e.message, 'err');
} finally {
btns.forEach(b => $(b).disabled = false);
}
}
// ---------- maps already ON the robot (don't deploy the same map twice) ----------
let ROBOT_MAPS = null; // last scan, for the deploy duplicate check
async function scanRobotMaps(quiet) {
const ip = needIp(); if (!ip) return null;
const rb = robot, box = $('robotMapList');
box.style.display = '';
box.innerHTML = '<div class="hint">scanning ' + rb.toUpperCase() + ' @ ' + ip + ' …</div>';
try {
const r = await api('robot_maps', { robot: rb, ip });
ROBOT_MAPS = r;
box.innerHTML = '';
if (!r.maps.length) {
box.innerHTML = `<div class="hint">no maps on the robot yet (${r.dir}) — Deploy puts one there</div>`;
if (!quiet) log(`${rb.toUpperCase()}: no maps on the robot (${r.dir})`, 'ok');
return r;
}
for (const m of r.maps) {
const row = document.createElement('div');
row.className = 'maprow' + (m.same_as_loaded ? ' cur' : '');
const same = m.same_as_loaded ? ' · <b>= loaded map</b>' : '';
const act = m.in_use ? ' · <b>IN USE</b> (nav2 is serving it)'
: ((r.active && (m.name === r.active || m.dir === r.active)) ? ' · active' : '');
// one row per SET; a deployed set is raw + keepout-baked (Foxy has no
// KeepoutFilter), which is why the file count is higher than the row count
const vars = (m.variants && m.variants.length > 1)
? ` · ${m.variants.length} files (${m.variants.join(', ')})` : '';
row.innerHTML = `<div class="inf"><b>${m.name}</b> · ` +
`${m.dims} · ${(m.size / 1024).toFixed(0)} KB · ${m.age} ago${same}${act}${vars}` +
`<small>${r.dir}/${m.yaml}</small></div>`;
if (rb === 'g1' || rb === 'go2') {
const b = document.createElement('button');
b.className = 'sec'; b.textContent = 'Use';
b.title = 'Point the robot\'s nav mode at this map (no transfer)';
b.onclick = async () => {
const nm = m.dir === '.' ? m.name : m.dir;
if (!confirm(`Make "${nm}" the map ${rb.toUpperCase()} navigates?`)) return;
try {
const u = await api('robot_use_map', { robot: rb, ip, name: nm });
log(`${rb.toUpperCase()}: ${u.note}`, 'ok');
scanRobotMaps(true);
// show WHAT the robot will now navigate, in the Map tab
try {
const j = await api('robot_map_import', { robot: rb, ip, yaml: m.yaml });
CUR_YAML = j.yaml || '';
activateMap(j, j.reused ? `showing ${nm} (already in the library)`
: `pulled ${nm} from ${rb.toUpperCase()} -> ${shortPath(j.imported || '')}`);
loadMaps();
} catch (e) { log('could not open the robot map: ' + e.message, 'err'); }
} catch (e) { log('use-map failed: ' + e.message, 'err'); }
};
row.appendChild(b);
}
const nm0 = m.dir === '.' ? m.name : m.dir;
const ren = document.createElement('button');
ren.className = 'sec'; ren.textContent = '✏️'; ren.title = 'Rename this map ON the robot';
ren.onclick = async () => {
const nn = prompt(`Rename "${nm0}" on ${rb.toUpperCase()} to:`, nm0);
if (!nn || nn === nm0) return;
try {
const u = await api('robot_map_rename', { robot: rb, ip, name: nm0, new: nn });
log(`${rb.toUpperCase()}: ${u.note}`, 'ok'); scanRobotMaps(true);
} catch (e) { log('rename on robot failed: ' + e.message, 'err'); }
};
const del = document.createElement('button');
del.className = 'sec'; del.textContent = '🗑'; del.title = 'Delete this map FROM the robot';
del.onclick = async () => {
if (!confirm(`Delete "${nm0}" from ${rb.toUpperCase()} @ ${ip}?\nThis removes it from the robot's disk.`)) return;
// IN-USE GUARD (same as the Gallery): the served map takes a second,
// explicit confirmation, and only that sets force:true for the server.
let force = false;
if (m.in_use) {
force = confirm(
`⚠ STOP — "${nm0}" is the map ${rb.toUpperCase()} is ACTIVELY SERVING.\n\n` +
`nav2's map_server is reading this map. If you delete it the navigation ` +
`stack will FAIL TO START and stay dead until you select another map ` +
`with the "Use" button.\n\n` +
`Delete the map the robot is navigating with anyway?`);
if (!force) { log(`kept ${nm0} — it is the map ${rb.toUpperCase()} is serving`, 'ok'); return; }
}
try {
const u = await api('robot_map_delete', { robot: rb, ip, name: nm0, force });
log(`${rb.toUpperCase()}: ${u.note}` +
(force ? ' — pick another map with Use before starting nav' : ''), force ? 'err' : 'ok');
scanRobotMaps(true);
} catch (e) { log('delete on robot failed: ' + e.message, 'err'); }
};
row.append(ren, del);
box.appendChild(row);
}
const dup = r.duplicate ? ' — the loaded map is ALREADY there (no need to deploy)' : '';
box.insertAdjacentHTML('beforeend',
`<div class="hint">${r.maps.length} map set(s)` +
(r.files && r.files !== r.maps.length ? ` (${r.files} yaml files: each set ships a raw map for AMCL + a keepout-baked one for the costmap)` : '') +
` in ${r.dir}${dup}</div>`);
if (!quiet) log(`${rb.toUpperCase()} has ${r.maps.length} map set(s)${dup}`, 'ok');
return r;
} catch (e) {
box.innerHTML = `<div class="hint">scan failed: ${e.message}</div>`;
if (!quiet) log('robot map scan failed: ' + e.message, 'err');
return null;
}
}
$('btnRobotMaps').onclick = () => scanRobotMaps(false);
$('btnReseed').onclick = async () => {
const ip = needIp(); if (!ip) return;
const rb = robot;
let p;
try { p = (await api('last_pose', { robot: rb })).pose; }
catch (e) { log('no stored pose: ' + e.message, 'err'); return; }
if (!p) { log(`no pose stored for ${rb.toUpperCase()} yet — set one with 📍 Set location`, 'err'); return; }
if (!confirm(`Re-seed ${rb.toUpperCase()} at the last pose you set?\n\n` +
`x=${p.x.toFixed(2)} y=${p.y.toFixed(2)} yaw=${p.yaw.toFixed(2)} (set ${p.at})\n\n` +
'Only correct if the robot has not moved since. Never causes motion.')) return;
try {
const r = await api('initialpose', { robot: rb, ip, x: p.x, y: p.y, yaw: p.yaw });
log(`re-seeded ${rb.toUpperCase()} at x=${r.x} y=${r.y} yaw=${r.yaw}`, 'ok');
if (GOAL_DRAW) GOAL_DRAW(p.x, p.y, p.yaw, false, 'loc');
} catch (e) { log('re-seed failed: ' + e.message, 'err'); }
};
$('btnRviz').onclick = () => openLive('rviz', 'rviz live');
$('btnRvizLocal').onclick = async () => {
try {
const r = await api('rviz_local', {});
log('rviz: ' + r.note, 'ok');
} catch (e) { log('rviz preview failed: ' + e.message, 'err'); }
};
$('btnRvizCombo').onclick = () => openLive('rviz_combo', 'rviz loaded+live');
// ---------- 🔴 LIVE map overlay: robot pose + goal + Nav2 route, straight off rosbridge ----------
// Same technique as the Lidar tab (see lidarConnect): one plain WebSocket speaking the
// rosbridge JSON protocol. NO server-side polling — the page talks to the robot directly,
// so what you see here is exactly what RViz sees, with the same latency.
//
// Everything is drawn in the SVG of the STATIC map with the SAME world->pixel transform the
// rest of the stage uses: px = (x - ox)/res , py = H - (y - oy)/res (see render()).
const NS = 'http://www.w3.org/2000/svg';
let LIVE = {
ws: null, ip: '', port: 0, want: true, status: 'off', err: '',
pose: null, poseT: 0, // {x,y,yaw,src}
goal: null, goalT: 0, // {x,y,yaw}
plan: null, planT: 0, // [[x,y], ...]
tf: Object.create(null), // child frame -> {parent,x,y,yaw}
amclT: 0, msgs: 0,
g: {}, px: null, s: 1, // SVG groups + the transform captured by render()
raf: 0, cancelBusy: false
};
const stripFrame = f => String(f || '').replace(/^\//, '');
const quatYaw = q => Math.atan2(2 * (q.w * q.z + q.x * q.y), 1 - 2 * (q.y * q.y + q.z * q.z));
// compose the TF chain source -> target (e.g. base_link -> map = map->odom ∘ odom->base_link).
// Returns {x,y,yaw} of `source`'s origin expressed in `target`, or null if the chain is broken.
function tfLookup(target, source) {
let cur = stripFrame(source), p = { x: 0, y: 0, yaw: 0 };
for (let hop = 0; hop < 12; hop++) {
if (cur === stripFrame(target)) return p;
const t = LIVE.tf[cur];
if (!t) return null;
const c = Math.cos(t.yaw), s = Math.sin(t.yaw);
p = { x: t.x + c * p.x - s * p.y, y: t.y + s * p.x + c * p.y, yaw: t.yaw + p.yaw };
cur = t.parent;
}
return null;
}
function livePort() { return (ROBOTS[robot] || {}).rosbridge_port || 9090; }
function liveSetStatus(st, err) {
LIVE.status = st; LIVE.err = err || '';
const b = $('liveBtn');
if (!b) return;
b.classList.toggle('live-on', st === 'live');
b.classList.toggle('live-wait', st === 'connecting' || st === 'nodata');
b.classList.toggle('live-bad', st === 'error');
const label = { off: 'Live: off', connecting: 'Live: connecting …', nodata: 'Live: connected — no data',
live: 'Live: on', error: 'Live: unreachable' }[st] || st;
const age = LIVE.poseT ? ((Date.now() - LIVE.poseT) / 1000).toFixed(1) + 's' : '';
const detail = st === 'live'
? ` · pose ${LIVE.pose ? LIVE.pose.src : ''} ${age} ago${LIVE.plan ? ' · route ' + LIVE.plan.length + ' pts' : ''}`
: '';
b.innerHTML = `<span class="dotlive"></span>${label}${detail}`;
b.title = (st === 'error' ? (err || 'cannot reach the robot rosbridge') + ' — ' : '') +
`rosbridge ws://${LIVE.ip || $('ip').value.trim() || '<ip>'}:${LIVE.port || livePort()} ` +
'(/amcl_pose, /tf, /goal_pose, /plan). Click to connect/disconnect.';
}
function liveDisconnect(quiet) {
LIVE.want = false;
if (LIVE.ws) { const w = LIVE.ws; LIVE.ws = null; try { w.close(); } catch (_) {} }
LIVE.pose = LIVE.goal = LIVE.plan = null;
LIVE.tf = Object.create(null);
liveSetStatus('off');
liveDraw();
if (!quiet) log('live map overlay: disconnected', 'ok');
}
function liveConnect() {
const ip = ($('ip').value || '').trim();
if (!ip) { liveSetStatus('error', 'no robot IP (step 3)'); return; }
if (LIVE.ws) { try { LIVE.ws.close(); } catch (_) {} LIVE.ws = null; }
LIVE.want = true; LIVE.ip = ip; LIVE.port = livePort(); LIVE.msgs = 0;
liveSetStatus('connecting');
let ws;
try { ws = new WebSocket(`ws://${ip}:${LIVE.port}`); }
catch (e) { liveSetStatus('error', e.message); return; }
LIVE.ws = ws;
ws.onopen = () => {
if (LIVE.ws !== ws) { try { ws.close(); } catch (_) {} return; }
liveSetStatus('nodata');
const sub = (topic, type, rate) => ws.send(JSON.stringify(
{ op: 'subscribe', topic, type, throttle_rate: rate, queue_length: 1 }));
sub('/amcl_pose', 'geometry_msgs/PoseWithCovarianceStamped', 100); // best pose (localized)
sub('/tf', 'tf2_msgs/TFMessage', 100); // fallback map->odom->base
sub('/tf_static', 'tf2_msgs/TFMessage', 0);
sub('/goal_pose', 'geometry_msgs/PoseStamped', 100); // goals set here OR in RViz
sub('/plan', 'nav_msgs/Path', 300); // the green Nav2 route
};
ws.onmessage = ev => {
if (LIVE.ws !== ws) return;
let m; try { m = JSON.parse(ev.data); } catch (_) { return; }
if (m.op === 'service_response') return liveServiceReply(m);
if (m.op !== 'publish') return;
LIVE.msgs++;
const now = Date.now();
if (m.topic === '/amcl_pose') {
const p = m.msg.pose.pose;
LIVE.pose = { x: p.position.x, y: p.position.y, yaw: quatYaw(p.orientation), src: '/amcl_pose' };
LIVE.poseT = now; LIVE.amclT = now;
} else if (m.topic === '/tf' || m.topic === '/tf_static') {
for (const t of (m.msg.transforms || [])) {
const tr = t.transform;
LIVE.tf[stripFrame(t.child_frame_id)] = {
parent: stripFrame(t.header.frame_id),
x: tr.translation.x, y: tr.translation.y, yaw: quatYaw(tr.rotation)
};
}
// AMCL wins while it is fresh; /tf keeps the arrow alive when it is not publishing
if (now - LIVE.amclT > 3000) {
const p = tfLookup('map', 'base_link') || tfLookup('map', 'base_footprint');
if (p) { LIVE.pose = { x: p.x, y: p.y, yaw: p.yaw, src: '/tf' }; LIVE.poseT = now; }
}
} else if (m.topic === '/goal_pose') {
const p = m.msg.pose;
LIVE.goal = { x: p.position.x, y: p.position.y, yaw: quatYaw(p.orientation) };
LIVE.goalT = now;
} else if (m.topic === '/plan') {
const ps = m.msg.poses || [];
LIVE.plan = ps.map(q => [q.pose.position.x, q.pose.position.y]);
LIVE.planT = now;
} else return;
if (LIVE.status !== 'live') liveSetStatus('live');
liveDraw();
};
ws.onerror = () => { if (LIVE.ws === ws) liveSetStatus('error', `cannot reach ws://${ip}:${LIVE.port}`); };
ws.onclose = () => {
if (LIVE.ws !== ws) return;
LIVE.ws = null;
liveSetStatus(LIVE.want ? 'error' : 'off', 'connection closed');
};
}
function liveToggle() { (LIVE.ws || LIVE.status === 'connecting') ? liveDisconnect() : liveConnect(); }
// watchdog: (re)connect while the overlay is wanted, and follow IP / robot changes
setInterval(() => {
if (!MAP || !LIVE.want) return;
const ip = ($('ip').value || '').trim();
if (!ip) return;
if (!LIVE.ws) { liveConnect(); return; }
if (LIVE.ws.readyState === 1 && (ip !== LIVE.ip || livePort() !== LIVE.port)) {
log(`live overlay: retargeting ${LIVE.ip}:${LIVE.port} -> ${ip}:${livePort()}`, 'ok');
liveConnect();
return;
}
if (LIVE.status === 'live' && Date.now() - Math.max(LIVE.poseT, LIVE.planT, LIVE.goalT) > 12000)
liveSetStatus('nodata');
else if (LIVE.status === 'live') liveSetStatus('live'); // refresh the age readout
}, 3000);
// ---------- draw the live layers into the map SVG ----------
function liveDraw() { // coalesce bursts of /tf into one repaint
if (LIVE.raf) return;
LIVE.raf = requestAnimationFrame(() => { LIVE.raf = 0; liveDrawNow(); });
}
function liveDrawNow() {
if (!MAP || !LIVE.px || !LIVE.g.robot) return;
const px = LIVE.px, s = LIVE.s;
const css = getComputedStyle(document.documentElement), C = v => css.getPropertyValue(v).trim();
const stale = LIVE.poseT && Date.now() - LIVE.poseT > 12000;
// 1. the planned route — GREEN polyline, same line RViz draws for /plan
const gp = LIVE.g.plan; gp.innerHTML = '';
if (LIVE.plan && LIVE.plan.length > 1) {
const step = Math.max(1, Math.ceil(LIVE.plan.length / 1500)); // huge paths -> sane DOM
const pts = [];
for (let i = 0; i < LIVE.plan.length; i += step) pts.push(px(LIVE.plan[i][0], LIVE.plan[i][1]).join(','));
const last = LIVE.plan[LIVE.plan.length - 1];
pts.push(px(last[0], last[1]).join(','));
const pl = document.createElementNS(NS, 'polyline');
pl.setAttribute('points', pts.join(' '));
pl.setAttribute('fill', 'none');
pl.setAttribute('stroke', C('--plan'));
pl.setAttribute('stroke-width', 1.4 * s);
pl.setAttribute('stroke-linecap', 'round');
pl.setAttribute('stroke-linejoin', 'round');
pl.setAttribute('opacity', '.95');
gp.appendChild(pl);
}
// 2. the live goal (/goal_pose — appears the instant someone sets it, RViz included)
const gg = LIVE.g.goal; gg.innerHTML = '';
if (LIVE.goal) {
const [cx, cy] = px(LIVE.goal.x, LIVE.goal.y), deg = -LIVE.goal.yaw * 180 / Math.PI, col = C('--accent');
const el = document.createElementNS(NS, 'g');
el.setAttribute('transform', `translate(${cx},${cy})`);
el.innerHTML =
`<circle r="${3.4 * s}" fill="${col}" fill-opacity=".18" stroke="${col}" stroke-width="${.8 * s}" stroke-dasharray="${1.6 * s} ${1.2 * s}"/>` +
`<path d="M 0 0 L ${8 * s} 0" stroke="${col}" stroke-width="${s}" transform="rotate(${deg})"/>` +
`<path d="M ${8 * s} 0 l ${-2.4 * s} ${-1.6 * s} l 0 ${3.2 * s} z" fill="${col}" transform="rotate(${deg})"/>` +
`<text x="0" y="${6 * s}" text-anchor="middle" font-size="${4.6 * s}" font-weight="700" fill="${col}"
font-family="ui-monospace,Menlo,monospace" paint-order="stroke" stroke="rgba(0,0,0,.45)" stroke-width="${s}">GOAL</text>`;
gg.appendChild(el);
}
// 3. the robot itself
const gr = LIVE.g.robot; gr.innerHTML = '';
if (LIVE.pose) {
const p = LIVE.pose, [cx, cy] = px(p.x, p.y), deg = -p.yaw * 180 / Math.PI;
const col = stale ? C('--muted') : C('--live');
const el = document.createElementNS(NS, 'g');
el.setAttribute('transform', `translate(${cx},${cy})`);
el.innerHTML =
`<circle r="${4.6 * s}" fill="${col}" fill-opacity=".16"/>` +
`<circle r="${2.2 * s}" fill="${col}" stroke="rgba(0,0,0,.5)" stroke-width="${.4 * s}"/>` +
`<path d="M 0 0 L ${9 * s} 0" stroke="${col}" stroke-width="${1.2 * s}" transform="rotate(${deg})"/>` +
`<path d="M ${9 * s} 0 l ${-3 * s} ${-2 * s} l 0 ${4 * s} z" fill="${col}" stroke="rgba(0,0,0,.5)" stroke-width="${.3 * s}" transform="rotate(${deg})"/>` +
`<text x="0" y="${-5.5 * s}" text-anchor="middle" font-size="${4.6 * s}" font-weight="700" fill="${col}"
font-family="ui-monospace,Menlo,monospace" paint-order="stroke" stroke="rgba(0,0,0,.45)" stroke-width="${s}"
>${stale ? 'ROBOT (stale)' : 'ROBOT'}</text>`;
gr.appendChild(el);
}
}
// ---------- ✋ cancel every Nav2 goal (action_msgs/srv/CancelGoal) ----------
// ---------- 🦿 arm / disarm the gait ----------
// Deliberately NOT part of stack bring-up: the stack always starts disarmed, and
// arming is the one act that lets a planned route reach the robot's legs.
let ARM = { armed: false, estop: false, consumer: false, busy: false };
async function armRefresh() {
const ip = $('ip').value.trim(); const b = $('armBtn');
if (!b || !ip) return;
try {
const r = await api('drive_state', { robot: robot, ip });
ARM = Object.assign(ARM, r, { busy: false });
} catch (e) { b.textContent = '🦿 unknown'; b.classList.add('cancel'); return; }
// NOT EVERY ROBOT HAS AN ARM GATE. The G1's consumer holds one; the Go2's
// SportClient consumer has none at all, so a goal moves the dog immediately.
// Showing a fake "disarmed" state there would be a dangerous lie.
if (ARM.gate === false && ARM.consumer) {
b.textContent = '⚠ no arm gate — a goal MOVES it';
b.title = (ARM.note || 'This robot has no arm gate.')
+ ' There is nothing to arm or disarm: the moment Nav2 publishes a velocity the '
+ 'robot moves. Your only stops are Cancel goal or run.sh estop.';
b.classList.add('cancel');
return;
}
if (!ARM.consumer) {
b.textContent = '🦿 no drive consumer';
b.title = 'This stack has no /cmd_vel consumer, so goals plan but never move the robot. '
+ 'Restart it from the dashboard — the G1 starts in nav-drive mode.';
} else if (ARM.estop) {
b.textContent = '🛑 E-STOP latched — click to clear';
} else if (ARM.armed) {
b.textContent = '🦿 ARMED — click to disarm';
} else {
b.textContent = '🦿 disarmed — click to arm';
}
b.classList.toggle('cancel', !ARM.armed || ARM.estop);
}
async function armToggle() {
const ip = $('ip').value.trim(); const b = $('armBtn');
if (!ip || ARM.busy) return;
let action;
if (ARM.estop) action = 'clear-estop';
else if (ARM.armed) action = 'disarm';
else {
if (!confirm('ARM the walking gait?\n\n'
+ 'After this, any goal you place will make the G1 WALK — provided it is in '
+ 'walking mode (FSM-200 + Start) on the remote.\n\n'
+ 'Stand clear, keep the remote in hand. Arming expires after 15 min; '
+ 'press the button again (or Cancel goal) to stop.')) return;
action = 'arm';
}
ARM.busy = true; b.textContent = '🦿 ' + action + '…';
try {
const r = await api('arm', { robot: robot, ip, action });
log('drive ' + action + ': ' + (r.note || 'ok'), r.ok ? 'ok' : 'err');
} catch (e) { log('drive ' + action + ' FAILED: ' + e.message, 'err'); }
ARM.busy = false;
setTimeout(armRefresh, 600);
}
// goal_id = 16 zero bytes + stamp 0 means "cancel ALL goals of this action server".
let CANCEL_ID = 0;
function liveServiceReply(m) {
if (!String(m.id || '').startsWith('cancel:')) return;
const b = $('cancelBtn');
LIVE.cancelBusy = false;
const v = m.values || {};
const rc = v.return_code;
const n = (v.goals_canceling || []).length;
if (m.result === false) { // rosbridge itself failed the call
b.textContent = '✋ cancel FAILED';
log('cancel goal FAILED — ' + (v.message || m.values || 'rosbridge rejected the call'), 'err');
} else if (rc === 0 || rc === undefined) {
b.textContent = `✋ cancelled ${n || ''} goal${n === 1 ? '' : 's'}`.replace(' ', ' ');
log(`cancel goal: ACCEPTED — ${n} goal(s) cancelling`, 'ok');
LIVE.plan = null; LIVE.goal = null; liveDraw(); // Nav2 stops publishing /plan, so clear it
} else if (rc === 1 && n === 0) {
// rcl_action answers "cancel all" with ERROR_REJECTED when nothing was running —
// that is not a failure, the robot simply had no active goal
b.textContent = '✋ nothing to cancel';
log('cancel goal: the robot had no active navigation goal', 'ok');
LIVE.plan = null; liveDraw();
} else {
const why = { 1: 'rejected', 2: 'unknown goal id', 3: 'goal already terminated' }[rc] || ('return_code ' + rc);
b.textContent = '✋ cancel FAILED';
log('cancel goal FAILED — ' + why, 'err');
}
setTimeout(() => { b.textContent = '✋ Cancel goal'; }, 3500);
}
function liveCancelGoal() {
const b = $('cancelBtn');
if (LIVE.cancelBusy) return;
const send = () => {
LIVE.cancelBusy = true;
b.textContent = '✋ cancelling …';
LIVE.ws.send(JSON.stringify({
op: 'call_service',
id: 'cancel:' + (++CANCEL_ID),
service: '/navigate_to_pose/_action/cancel_goal',
type: 'action_msgs/srv/CancelGoal',
args: { goal_info: { goal_id: { uuid: new Array(16).fill(0) }, stamp: { sec: 0, nanosec: 0 } } }
}));
log('cancel goal: sent /navigate_to_pose/_action/cancel_goal (all goals) …', 'ok');
setTimeout(() => { // no reply => say so, never leave it spinning
if (!LIVE.cancelBusy) return;
LIVE.cancelBusy = false;
b.textContent = '✋ no reply';
log('cancel goal: no service reply in 6 s — is Nav2 (navigate_to_pose) running?', 'err');
setTimeout(() => { b.textContent = '✋ Cancel goal'; }, 3500);
}, 6000);
};
if (LIVE.ws && LIVE.ws.readyState === 1) return send();
// not connected yet: bring the overlay up, then fire once it opens
b.textContent = '✋ connecting …';
liveConnect();
if (!LIVE.ws) { b.textContent = '✋ Cancel goal'; log('cancel goal: enter the robot IP in step 3 first', 'err'); return; }
const t0 = Date.now();
const wait = setInterval(() => {
if (LIVE.ws && LIVE.ws.readyState === 1) { clearInterval(wait); send(); }
else if (Date.now() - t0 > 5000 || !LIVE.ws) {
clearInterval(wait);
b.textContent = '✋ Cancel goal';
log('cancel goal: no rosbridge connection — is the nav stack up?', 'err');
}
}, 200);
}
$('liveBtn').onclick = liveToggle;
$('cancelBtn').onclick = liveCancelGoal;
$('armBtn').onclick = armToggle;
// keep the gait state honest: arming expires on its own after 15 min, and estop can
// be latched from the robot side, so poll rather than trust the last click.
setInterval(() => { if ($('armBtn').style.display !== 'none') armRefresh(); }, 10000);
liveSetStatus('off');
// ---------- live preview (pan/zoom viewer) ----------
let scale = 1, tx = 0, ty = 0;
function render() {
const { meta: m, layers, ann } = MAP;
const stage = $('stage');
stage.innerHTML = '';
$('empty').style.display = 'none';
$('layerbox').style.display = 'flex';
$('readout').style.display = 'block';
const px = (x, y) => [(x - m.ox) / m.res, m.H - (y - m.oy) / m.res];
const css = getComputedStyle(document.documentElement);
const C = v => css.getPropertyValue(v).trim();
const groups = {};
const addImg = (b64, id) => {
const img = document.createElement('img');
img.src = 'data:image/png;base64,' + b64;
img.width = m.W; img.height = m.H; img.id = id;
stage.appendChild(img);
groups[id] = img;
return img;
};
addImg(layers.base, 'lyBase');
if (layers.keepout) addImg(layers.keepout, 'lyKeepout');
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('width', m.W); svg.setAttribute('height', m.H);
svg.setAttribute('viewBox', `0 0 ${m.W} ${m.H}`);
stage.appendChild(svg);
const s = Math.max(m.W, m.H) / 300; // marker scale relative to map size
const g = name => { const el = document.createElementNS(NS, 'g'); svg.appendChild(el); groups[name] = el; return el; };
if (ann.areas.length) {
const ga = g('lyAreas');
for (const poly of ann.areas) {
const p = document.createElementNS(NS, 'polygon');
p.setAttribute('points', poly.map(([x, y]) => px(x, y).join(',')).join(' '));
p.setAttribute('fill', 'none'); p.setAttribute('stroke', C('--track'));
p.setAttribute('stroke-width', 1.2 * s); p.setAttribute('stroke-dasharray', `${4 * s} ${2.5 * s}`);
ga.appendChild(p);
}
}
if (ann.tracks.length || ann.nodes.length) {
const gt = g('lyTracks');
for (const [x1, y1, x2, y2] of ann.tracks) {
const [c1, r1] = px(x1, y1), [c2, r2] = px(x2, y2);
const l = document.createElementNS(NS, 'line');
l.setAttribute('x1', c1); l.setAttribute('y1', r1); l.setAttribute('x2', c2); l.setAttribute('y2', r2);
l.setAttribute('stroke', C('--track')); l.setAttribute('stroke-width', .8 * s);
l.setAttribute('stroke-dasharray', `${2.5 * s} ${1.5 * s}`);
gt.appendChild(l);
}
for (const [x, y] of ann.nodes) {
const [cx, cy] = px(x, y);
const c = document.createElementNS(NS, 'circle');
c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', 1.6 * s);
c.setAttribute('fill', C('--track'));
gt.appendChild(c);
}
}
const gm = g('lyMarks');
const pose = (pt, color, label) => {
const [cx, cy] = px(pt.x, pt.y);
const deg = -pt.yaw * 180 / Math.PI;
const el = document.createElementNS(NS, 'g');
el.setAttribute('transform', `translate(${cx},${cy})`);
el.innerHTML =
`<circle r="${3.2 * s}" fill="${color}" fill-opacity=".25" stroke="${color}" stroke-width="${.8 * s}"/>` +
`<path d="M 0 0 L ${7 * s} 0" stroke="${color}" stroke-width="${s}" transform="rotate(${deg})"/>` +
`<path d="M ${7 * s} 0 l ${-2.2 * s} ${-1.5 * s} l 0 ${3 * s} z" fill="${color}" transform="rotate(${deg})"/>` +
`<text x="0" y="${-5 * s}" text-anchor="middle" font-size="${5.5 * s}" font-weight="600" fill="${color}"
font-family="ui-monospace,Menlo,monospace" paint-order="stroke" stroke="rgba(0,0,0,.45)" stroke-width="${s}">${label}</text>`;
gm.appendChild(el);
};
for (const h of ann.homes) pose(h, C('--home'), h.name || 'home');
for (const c of ann.chargers) pose(c, C('--accent'), c.name || 'charge');
// live layers (rosbridge): route under goal under robot. They reuse THIS px()/s so the
// overlay can never drift from the static map.
LIVE.px = px; LIVE.s = s;
LIVE.g = { plan: g('lyLivePlan'), goal: g('lyLiveGoal'), robot: g('lyLiveRobot') };
// goal/locate layer (click-to-goal, click-to-localize) — lives on top
GOAL_G = document.createElementNS(NS, 'g');
svg.appendChild(GOAL_G);
GOAL_DRAW = (x, y, yaw, preview, kind, failed) => {
GOAL_G.innerHTML = '';
const el = document.createElementNS(NS, 'g');
const [cx, cy] = px(x, y);
const deg = -yaw * 180 / Math.PI;
const col = failed ? C('--muted') : (kind === 'loc' ? C('--home') : C('--err'));
const label = (kind === 'loc' ? 'HERE' : 'GOAL') + (failed ? ' ✗ not sent' : '');
el.setAttribute('transform', `translate(${cx},${cy})`);
el.setAttribute('opacity', failed ? '.75' : (preview ? '.6' : '1'));
el.innerHTML =
`<circle r="${3.2 * s}" fill="${col}" fill-opacity=".2" stroke="${col}" stroke-width="${.8 * s}"/>` +
`<path d="M 0 0 L ${8 * s} 0" stroke="${col}" stroke-width="${s}" transform="rotate(${deg})"/>` +
`<path d="M ${8 * s} 0 l ${-2.4 * s} ${-1.6 * s} l 0 ${3.2 * s} z" fill="${col}" transform="rotate(${deg})"/>` +
`<text x="0" y="${-5 * s}" text-anchor="middle" font-size="${5.5 * s}" font-weight="700" fill="${col}"
font-family="ui-monospace,Menlo,monospace" paint-order="stroke" stroke="rgba(0,0,0,.45)" stroke-width="${s}">${label}</text>`;
GOAL_G.appendChild(el);
};
$('goalBtn').style.display = 'block';
$('locBtn').style.display = 'block';
$('liveBtn').style.display = 'block';
$('cancelBtn').style.display = 'block';
$('armBtn').style.display = 'block';
armRefresh();
// layer toggles
const lb = $('layerbox');
lb.innerHTML = '<div style="font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)">Layers</div>';
const names = { lyBase: 'Occupancy', lyKeepout: 'Keepout zones', lyAreas: 'Area polygons', lyTracks: 'Route graph', lyMarks: 'Poses',
lyLivePlan: '🟢 Nav2 route (live)', lyLiveGoal: '🎯 Goal (live)', lyLiveRobot: '🤖 Robot (live)' };
for (const [id, el] of Object.entries(groups)) {
const l = document.createElement('label');
const on = LAYER_OFF[id] !== true;
l.innerHTML = `<input type="checkbox" ${on ? 'checked' : ''}> ${names[id] || id}`;
if (!on) el.style.display = 'none';
l.querySelector('input').addEventListener('change', e => {
el.style.display = e.target.checked ? '' : 'none';
LAYER_OFF[id] = !e.target.checked; // survive the next render()
});
lb.appendChild(l);
}
fit();
liveDrawNow(); // repaint the live layers into the fresh SVG
if (LIVE.want && !LIVE.ws) liveConnect();
}
const LAYER_OFF = {}; // layer id -> hidden? (remembered across re-renders)
// ---------- armed click modes: 🎯 goal (/goal_pose) + 📍 localize (/initialpose) ----------
let GOAL_G = null, GOAL_DRAW = null, ARMED = null, goalDrag = null;
function setArmed(mode) { // 'goal' | 'loc' | null (disarm)
ARMED = mode;
$('goalBtn').classList.toggle('armed', mode === 'goal');
$('goalBtn').textContent = mode === 'goal' ? '🎯 Goal mode: ARMED — click the map' : '🎯 Goal mode: off';
$('locBtn').classList.toggle('armed', mode === 'loc');
$('locBtn').textContent = mode === 'loc' ? '📍 Set location: ARMED — click where the robot IS' : '📍 Set location: off';
$('viz').classList.toggle('goalmode', !!mode);
}
$('goalBtn').onclick = () => setArmed(ARMED === 'goal' ? null : 'goal');
$('locBtn').onclick = () => setArmed(ARMED === 'loc' ? null : 'loc');
// CRITICAL: these buttons sit INSIDE #viz. A real press would bubble pointerdown
// into viz's pan handler, which calls setPointerCapture — and captured pointers
// re-target the click to viz, so the button's onclick NEVER fires (the "button
// does nothing" bug). Stop the pointerdown from ever reaching viz.
for (const el of document.querySelectorAll('#viz .float'))
el.addEventListener('pointerdown', e => e.stopPropagation());
function clientToWorld(e) {
const m = MAP.meta, r = viz.getBoundingClientRect();
const col = (e.clientX - r.left - tx) / scale, row = (e.clientY - r.top - ty) / scale;
return [m.ox + col * m.res, m.oy + (m.H - row) * m.res];
}
async function sendInitial(x, y, yaw) {
const ip = $('ip').value.trim();
if (!ip) { log('location not sent: enter the robot IP in step 3', 'err'); return; }
try {
const r = await api('initialpose', { robot, ip, x, y, yaw });
log(`LOCATION set @ ${ip}: x=${r.x} y=${r.y} yaw=${r.yaw} (/initialpose — localizer snaps the robot here)`, 'ok');
} catch (e) {
// KEEP the marker (greyed, "✗ not sent") — erasing it made a failed send
// look like the click did nothing at all
if (GOAL_DRAW) GOAL_DRAW(x, y, yaw, false, 'loc', true);
log('set location NOT sent — ' + e.message, 'err');
}
}
async function sendGoal(x, y, yaw) {
const ip = $('ip').value.trim();
if (!ip) { log('goal not sent: enter the robot IP in step 3', 'err'); return; }
// GOALS SENT BEFORE bt_navigator IS ACTIVE ARE SILENTLY DISCARDED — no error is
// raised anywhere, the robot just never moves. Bring-up takes 2-5 min on these
// robots and this exact trap wasted an hour on both the G1 and the Go2, so ask
// the robot whether it can accept a goal before throwing one at it.
try {
const nr = await api('nav_ready', { robot, ip });
if (!nr.ready) {
if (GOAL_DRAW) GOAL_DRAW(x, y, yaw, false, 'goal', true); // keep it, greyed
log('goal NOT sent — ' + nr.why + '. Wait for the stack to finish and click again.', 'err');
return;
}
} catch (e) {
log('could not check nav readiness (' + e.message + ') — sending anyway', 'err');
}
try {
const r = await api('goal', { robot, ip, x, y, yaw });
log(`GOAL sent to ${ip}: x=${r.x} y=${r.y} yaw=${r.yaw} (robot walks only if gait armed + Nav2 up)`, 'ok');
} catch (e) {
if (GOAL_DRAW) GOAL_DRAW(x, y, yaw, false, 'goal', true); // keep it, greyed
log('goal NOT sent — ' + e.message, 'err');
}
}
const viz = $('viz'), stageEl = $('stage');
function fit() {
if (!MAP) return;
const r = viz.getBoundingClientRect(), m = MAP.meta;
scale = Math.min(r.width / m.W, r.height / m.H) * .95;
tx = (r.width - m.W * scale) / 2; ty = (r.height - m.H * scale) / 2;
apply();
}
const apply = () => stageEl.style.transform = `translate(${tx}px,${ty}px) scale(${scale})`;
viz.addEventListener('wheel', e => {
if (!MAP) return;
e.preventDefault();
const r = viz.getBoundingClientRect();
const mx = e.clientX - r.left, my = e.clientY - r.top;
const ns = Math.min(Math.max(scale * Math.exp(-e.deltaY * .0016), .1), 80);
tx = mx - (mx - tx) * (ns / scale); ty = my - (my - ty) * (ns / scale);
scale = ns; apply();
}, { passive: false });
let drag = null;
viz.addEventListener('pointerdown', e => {
if (ARMED && MAP && GOAL_DRAW) {
const [wx, wy] = clientToWorld(e);
goalDrag = { x: wx, y: wy, yaw: 0, mode: ARMED };
GOAL_DRAW(wx, wy, 0, true, ARMED);
try { viz.setPointerCapture(e.pointerId); } catch (_) {}
return; // armed mode swallows the pan
}
drag = { x: e.clientX - tx, y: e.clientY - ty }; viz.classList.add('dragging');
try { viz.setPointerCapture(e.pointerId); } catch (_) {}
});
viz.addEventListener('pointermove', e => {
if (goalDrag && GOAL_DRAW) {
const [wx, wy] = clientToWorld(e);
const dx = wx - goalDrag.x, dy = wy - goalDrag.y;
if (Math.hypot(dx, dy) > MAP.meta.res * 2) goalDrag.yaw = Math.atan2(dy, dx);
GOAL_DRAW(goalDrag.x, goalDrag.y, goalDrag.yaw, true, goalDrag.mode);
}
if (drag) { tx = e.clientX - drag.x; ty = e.clientY - drag.y; apply(); }
if (!MAP) return;
const m = MAP.meta, r = viz.getBoundingClientRect();
const col = (e.clientX - r.left - tx) / scale, row = (e.clientY - r.top - ty) / scale;
const ro = $('readout');
if (col >= 0 && col < m.W && row >= 0 && row < m.H) {
const wx = m.ox + col * m.res, wy = m.oy + (m.H - row) * m.res;
ro.innerHTML = `x <b>${wx.toFixed(2)}</b>&nbsp; y <b>${wy.toFixed(2)}</b> m &nbsp;<span class="dim">px ${col | 0}, ${row | 0}</span>`;
} else ro.innerHTML = '<span class="dim">outside map</span>';
});
const endDrag = (e) => {
if (goalDrag) {
const g = goalDrag; goalDrag = null;
GOAL_DRAW(g.x, g.y, g.yaw, false, g.mode);
setArmed(null); // one action per arming — no accidental repeats
if (g.mode === 'loc') sendInitial(g.x, g.y, g.yaw);
else sendGoal(g.x, g.y, g.yaw);
return;
}
drag = null; viz.classList.remove('dragging');
};
viz.addEventListener('pointerup', endDrag);
viz.addEventListener('pointercancel', endDrag);
viz.addEventListener('dblclick', fit);
window.addEventListener('resize', fit);
$('buildTag').textContent = BUILD;
log(`dashboard loaded — ${BUILD}`, 'ok');
loadRobots();
// the SERVER remembers the loaded map across restarts — pick it up on every page
// load too, otherwise a reload left the map blank and the live buttons greyed
(async () => {
try {
const j = await api('current', {});
if (j && j.ok !== false && j.meta) {
CUR_YAML = j.yaml || '';
activateMap(j, `current map restored: ${shortPath(j.out_dir || '')}`);
loadMaps();
}
} catch (_) { /* nothing loaded server-side — normal on a fresh start */ }
})();
</script>
</body>
</html>