2365 lines
119 KiB
HTML
2365 lines
119 KiB
HTML
<!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 & 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; ~10–15 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 & 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 => ({'&':'&','<':'<','>':'>','"':'"'}[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 & 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)} · rosbridge relay ${V(v.relay)} ` +
|
||
` · 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> y <b>${wy.toFixed(2)}</b> m <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>
|