diff --git a/umdloop_gui_web/app/GUI functions/DeliveryMissionPanel.js b/umdloop_gui_web/app/GUI functions/DeliveryMissionPanel.js
new file mode 100644
index 00000000..ae091490
--- /dev/null
+++ b/umdloop_gui_web/app/GUI functions/DeliveryMissionPanel.js
@@ -0,0 +1,562 @@
+"use client";
+
+import React, { useState, useEffect, useCallback, useRef } from "react";
+import MiniMapHUD, { euclideanMeters } from "./MiniMapHUD";
+import { getApiBaseUrl } from "../config";
+
+const MAX_SPEED_MPS = 1.0;
+
+function formatETA(seconds) {
+ if (seconds < 60) return `${Math.round(seconds)}s`;
+ const m = Math.floor(seconds / 60);
+ const s = Math.round(seconds % 60);
+ return `${m}m ${s}s`;
+}
+
+// Delete button that requires two taps to confirm (resets after 2.5 s)
+function DeleteButton({ onDelete }) {
+ const [armed, setArmed] = useState(false);
+ const timerRef = useRef(null);
+
+ const handleClick = () => {
+ if (armed) {
+ onDelete();
+ setArmed(false);
+ clearTimeout(timerRef.current);
+ } else {
+ setArmed(true);
+ timerRef.current = setTimeout(() => setArmed(false), 2500);
+ }
+ };
+
+ useEffect(() => () => clearTimeout(timerRef.current), []);
+
+ return (
+
+ );
+}
+
+function WaypointRow({ wp, idx, isNext, onDelete, onEdit, onMoveUp, onMoveDown, canMoveUp, canMoveDown, roverPosition }) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState({ name: wp.name, lat: String(wp.latitude), lon: String(wp.longitude) });
+
+ const distM = roverPosition ? euclideanMeters(roverPosition, wp) : null;
+
+ const commit = () => {
+ const lat = parseFloat(draft.lat);
+ const lon = parseFloat(draft.lon);
+ if (isNaN(lat) || isNaN(lon)) return;
+ onEdit(wp.id, { name: draft.name.trim() || wp.name, latitude: lat, longitude: lon });
+ setEditing(false);
+ };
+
+ const cancel = () => {
+ setDraft({ name: wp.name, lat: String(wp.latitude), lon: String(wp.longitude) });
+ setEditing(false);
+ };
+
+ return (
+
+ {editing ? (
+
+ ) : (
+
+ {/* Index badge */}
+
+ {idx + 1}
+
+
+ {/* Info */}
+
+
+ {wp.name || `WP ${idx + 1}`}
+
+
+ {wp.latitude.toFixed(5)}, {wp.longitude.toFixed(5)}
+ {distM !== null && (
+
+ {distM < 1000 ? `${distM.toFixed(0)} m` : `${(distM / 1000).toFixed(2)} km`}
+
+ )}
+
+
+
+ {/* Reorder */}
+
+
+
+
+
+ {/* Edit */}
+
+
+ {/* Delete (two-tap) */}
+
onDelete(wp.id)} />
+
+ )}
+
+ );
+}
+
+export default function DeliveryMissionPanel({ waypoints, setWaypoints, roverPosition, roverHeading, onClose, portrait }) {
+ const [sortByDistance, setSortByDistance] = useState(true);
+ const [addForm, setAddForm] = useState({ name: "", lat: "", lon: "" });
+ const [addError, setAddError] = useState("");
+ const [showAddForm, setShowAddForm] = useState(false);
+ const [showTileCache, setShowTileCache] = useState(false);
+ const [centerForm, setCenterForm] = useState({ lat: "", lon: "", radiusKm: "2" });
+ const [dlStatus, setDlStatus] = useState(null);
+ const [dlPolling, setDlPolling] = useState(false);
+
+ const sortedWaypoints = sortByDistance && roverPosition
+ ? [...waypoints].sort((a, b) => euclideanMeters(roverPosition, a) - euclideanMeters(roverPosition, b))
+ : waypoints;
+
+ const nextWaypoint = sortedWaypoints[0] ?? null;
+ const distToNext = nextWaypoint && roverPosition ? euclideanMeters(roverPosition, nextWaypoint) : null;
+ const etaSeconds = distToNext !== null ? distToNext / MAX_SPEED_MPS : null;
+
+ // ── CRUD ────────────────────────────────────────────────────────────────────
+
+ const addWaypoint = () => {
+ const lat = parseFloat(addForm.lat);
+ const lon = parseFloat(addForm.lon);
+ const name = addForm.name.trim() || `WP ${waypoints.length + 1}`;
+ if (isNaN(lat) || isNaN(lon)) { setAddError("Invalid coordinates"); return; }
+ if (lat < -90 || lat > 90 || lon < -180 || lon > 180) { setAddError("Coordinates out of range"); return; }
+ setWaypoints((prev) => [...prev, { id: Date.now(), name, latitude: lat, longitude: lon }]);
+ setAddForm({ name: "", lat: "", lon: "" });
+ setAddError("");
+ setShowAddForm(false);
+ };
+
+ const deleteWaypoint = useCallback((id) => setWaypoints((prev) => prev.filter((wp) => wp.id !== id)), [setWaypoints]);
+
+ const editWaypoint = useCallback((id, updates) => {
+ setWaypoints((prev) => prev.map((wp) => (wp.id === id ? { ...wp, ...updates } : wp)));
+ }, [setWaypoints]);
+
+ const moveWaypoint = useCallback((id, dir) => {
+ setWaypoints((prev) => {
+ const i = prev.findIndex((wp) => wp.id === id);
+ if (i === -1) return prev;
+ const j = i + dir;
+ if (j < 0 || j >= prev.length) return prev;
+ const next = [...prev];
+ [next[i], next[j]] = [next[j], next[i]];
+ return next;
+ });
+ setSortByDistance(false);
+ }, [setWaypoints]);
+
+ // ── Tile download ────────────────────────────────────────────────────────────
+
+ const startDownload = async (body) => {
+ try {
+ const res = await fetch(`${getApiBaseUrl()}/tiles/download`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const data = await res.json();
+ if (!data.ok) { setDlStatus({ error: data.error }); return; }
+ setDlPolling(true);
+ } catch (e) {
+ setDlStatus({ error: String(e) });
+ }
+ };
+
+ const downloadArea = async () => {
+ const lat = parseFloat(centerForm.lat) || roverPosition?.latitude;
+ const lon = parseFloat(centerForm.lon) || roverPosition?.longitude;
+ const r = parseFloat(centerForm.radiusKm) || 2;
+ if (!lat || !lon) { setDlStatus({ error: "No coordinates — enter lat/lon or wait for GPS fix" }); return; }
+ await startDownload({ center: { lat, lon }, radius_km: r, min_zoom: 12, max_zoom: 18 });
+ };
+
+ useEffect(() => {
+ if (!dlPolling) return;
+ const poll = async () => {
+ try {
+ const res = await fetch(`${getApiBaseUrl()}/tiles/download/status`);
+ const data = await res.json();
+ setDlStatus(data);
+ if (!data.running) setDlPolling(false);
+ } catch { setDlPolling(false); }
+ };
+ poll();
+ const id = setInterval(poll, 600);
+ return () => clearInterval(id);
+ }, [dlPolling]);
+
+ // ── Layout ───────────────────────────────────────────────────────────────────
+ // Portrait: horizontal strip at bottom of screen
+ // Landscape: vertical panel on the right
+
+ const panelStyle = portrait
+ ? { width: "100%", height: 340, borderTop: "1px solid #2a2a2a", flexDirection: "row" }
+ : { width: 300, minWidth: 300, borderLeft: "1px solid #2a2a2a", flexDirection: "column" };
+
+ return (
+
+
+ {portrait ? (
+ // ── Portrait: two-column layout ─────────────────────────────────────
+ <>
+ {/* Left col: minimap + ETA */}
+
+
+ {nextWaypoint ? (
+
+
+ ▶ {nextWaypoint.name}
+
+ {distToNext !== null && (
+
+ {distToNext.toFixed(0)} m · {formatETA(etaSeconds)}
+
+ )}
+
+ ) : (
+
No waypoints
+ )}
+ {/* Close button at bottom of left col */}
+
+
+
+ {/* Right col: scrollable waypoint list + controls */}
+
+ {/* Sort + action toolbar */}
+
+
+
+
+
+
+
+ {/* Add form (collapsible) */}
+ {showAddForm && (
+
+ )}
+
+ {/* Tile cache form (collapsible) */}
+ {showTileCache && (
+
+ )}
+
+ {/* Waypoint list */}
+
+ {sortedWaypoints.length === 0 && (
+
+ No waypoints — click the map or tap +
+
+ )}
+ {sortedWaypoints.map((wp, idx) => (
+
moveWaypoint(wp.id, -1)} onMoveDown={() => moveWaypoint(wp.id, 1)}
+ canMoveUp={idx > 0} canMoveDown={idx < sortedWaypoints.length - 1}
+ roverPosition={roverPosition}
+ />
+ ))}
+ {waypoints.length > 0 && (
+
+ )}
+
+
+ >
+ ) : (
+ // ── Landscape: vertical panel ────────────────────────────────────────
+ <>
+ {/* Header */}
+
+ Delivery Mission
+
+
+
+
+
+ {/* Minimap + ETA */}
+
+
+ {nextWaypoint ? (
+
+
▶ {nextWaypoint.name}
+ {distToNext !== null && (
+
+ {distToNext.toFixed(0)} m · ETA {formatETA(etaSeconds)}
+
+ )}
+
+ ) : (
+
No waypoints set
+ )}
+
+
+ {/* Sort toggle */}
+
+
+
+
+
+ {/* Waypoint list */}
+
+ {sortedWaypoints.length === 0 && (
+
+ No waypoints — click the map or add below
+
+ )}
+ {sortedWaypoints.map((wp, idx) => (
+
moveWaypoint(wp.id, -1)} onMoveDown={() => moveWaypoint(wp.id, 1)}
+ canMoveUp={idx > 0} canMoveDown={idx < sortedWaypoints.length - 1}
+ roverPosition={roverPosition}
+ />
+ ))}
+
+
+ {/* Add waypoint */}
+
+
+ {showAddForm && (
+ <>
+
setAddForm((f) => ({ ...f, name: e.target.value }))} placeholder="Name (optional)" style={{ ...inputStyle, width: "100%", marginBottom: 6, boxSizing: "border-box" }} />
+
+ setAddForm((f) => ({ ...f, lat: e.target.value }))} placeholder="Latitude" style={{ ...inputStyle, flex: 1 }} />
+ setAddForm((f) => ({ ...f, lon: e.target.value }))} placeholder="Longitude" style={{ ...inputStyle, flex: 1 }} />
+
+ {addError &&
{addError}
}
+
+
or click anywhere on the map
+ >
+ )}
+
+
+ {/* Clear all */}
+ {waypoints.length > 0 && (
+
+ )}
+
+ {/* Tile cache */}
+
+
+
+ >
+ )}
+
+ );
+}
+
+function DownloadStatus({ status }) {
+ if (!status) return null;
+ if (status.error) return Error: {status.error}
;
+ const pct = status.total ? Math.round(((status.downloaded + status.skipped) / status.total) * 100) : 0;
+ return (
+
+ {status.running ? (
+ <>
+
+
+ {status.downloaded + status.skipped} / {status.total} tiles ({status.downloaded} new)
+
+ >
+ ) : (
+
{status.message}
+ )}
+
+ );
+}
+
+// ── Shared styles ──────────────────────────────────────────────────────────────
+
+const actionBtn = {
+ border: "none",
+ borderRadius: 8,
+ color: "white",
+ cursor: "pointer",
+ padding: "10px 14px",
+ fontSize: 13,
+ fontWeight: 700,
+ minHeight: 40,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+};
+
+const reorderBtn = {
+ width: 28,
+ height: 20,
+ background: "#1f2937",
+ border: "1px solid #374151",
+ borderRadius: 4,
+ color: "#9ca3af",
+ cursor: "pointer",
+ fontSize: 10,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: 0,
+};
+
+const inputStyle = {
+ background: "#0d1117",
+ border: "1px solid #374151",
+ borderRadius: 6,
+ color: "white",
+ fontSize: 13,
+ padding: "8px 10px",
+ outline: "none",
+ minHeight: 38,
+ boxSizing: "border-box",
+};
diff --git a/umdloop_gui_web/app/GUI functions/MapDeliveryView.js b/umdloop_gui_web/app/GUI functions/MapDeliveryView.js
new file mode 100644
index 00000000..0e1054e3
--- /dev/null
+++ b/umdloop_gui_web/app/GUI functions/MapDeliveryView.js
@@ -0,0 +1,200 @@
+"use client";
+
+import React, { useState, useEffect, useRef, useCallback } from "react";
+import ROSLIB from "roslib";
+import MapView from "./MapView";
+import DeliveryMissionPanel from "./DeliveryMissionPanel";
+import { getApiBaseUrl, getRosbridgeUrl, GUI_REQUIRED_TOPICS } from "../config";
+
+const STORAGE_KEY = "delivery-waypoints";
+
+function loadWaypoints() {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ return raw ? JSON.parse(raw) : [];
+ } catch {
+ return [];
+ }
+}
+
+function usePortrait() {
+ const [portrait, setPortrait] = useState(() =>
+ typeof window !== "undefined" ? window.innerHeight > window.innerWidth : false
+ );
+ useEffect(() => {
+ const update = () => setPortrait(window.innerHeight > window.innerWidth);
+ window.addEventListener("resize", update);
+ return () => window.removeEventListener("resize", update);
+ }, []);
+ return portrait;
+}
+
+export default function MapDeliveryView({ selectedSubsystem }) {
+ const [waypoints, setWaypoints] = useState(loadWaypoints);
+ const [roverPosition, setRoverPosition] = useState(null);
+ const [roverHeading, setRoverHeading] = useState(null);
+ const [roverStatus, setRoverStatus] = useState("no fix");
+ const [panelOpen, setPanelOpen] = useState(true);
+ const [tileMissing, setTileMissing] = useState(false);
+ const rosHeadingRef = useRef(false);
+ const portrait = usePortrait();
+
+ useEffect(() => {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(waypoints));
+ }, [waypoints]);
+
+ // GPS polling
+ useEffect(() => {
+ const poll = async () => {
+ try {
+ const res = await fetch(`${getApiBaseUrl()}/navigation/rover-position`);
+ const data = await res.json();
+ if (data.fix) {
+ setRoverPosition({ latitude: data.latitude, longitude: data.longitude });
+ setRoverStatus("fix");
+ } else {
+ setRoverStatus("no fix");
+ }
+ } catch {
+ setRoverStatus("unreachable");
+ }
+ };
+ poll();
+ const id = setInterval(poll, 1000);
+ return () => clearInterval(id);
+ }, []);
+
+ // Heading — ROSLIB primary, REST fallback for dev stub
+ useEffect(() => {
+ let ros, headingTopic, cleanup = false;
+ try {
+ ros = new ROSLIB.Ros({ url: getRosbridgeUrl() });
+ ros.on("connection", () => {
+ if (cleanup) return;
+ headingTopic = new ROSLIB.Topic({
+ ros,
+ name: GUI_REQUIRED_TOPICS.heading.name,
+ messageType: GUI_REQUIRED_TOPICS.heading.messageType,
+ });
+ headingTopic.subscribe((msg) => {
+ if (msg?.heading !== undefined) {
+ rosHeadingRef.current = true;
+ setRoverHeading(msg.heading);
+ }
+ });
+ });
+ } catch { /* rosbridge not available */ }
+ return () => {
+ cleanup = true;
+ headingTopic?.unsubscribe();
+ ros?.close();
+ };
+ }, []);
+
+ useEffect(() => {
+ const poll = async () => {
+ if (rosHeadingRef.current) return;
+ try {
+ const res = await fetch(`${getApiBaseUrl()}/navigation/rover-heading`);
+ const data = await res.json();
+ if (data.heading !== undefined) setRoverHeading(data.heading);
+ } catch { /* not available on production server */ }
+ };
+ const id = setInterval(poll, 500);
+ return () => clearInterval(id);
+ }, []);
+
+ const handleAddWaypoint = useCallback(({ lat, lng }) => {
+ setWaypoints((prev) => [
+ ...prev,
+ { id: Date.now(), name: `WP ${prev.length + 1}`, latitude: lat, longitude: lng },
+ ]);
+ }, []);
+
+ const handleTileMissing = useCallback(() => setTileMissing(true), []);
+
+ return (
+
+ {/* Map — takes remaining space */}
+
+
+
+
+ {/* Collapsed toggle button */}
+ {!panelOpen && (
+
+ )}
+
+ {/* Tile missing toast (when panel is closed) */}
+ {tileMissing && !panelOpen && (
+
+ ⚠ Tiles missing
+
+
+ )}
+
+ {/* Mission panel */}
+ {panelOpen && (
+
setPanelOpen(false)}
+ portrait={portrait}
+ />
+ )}
+
+ );
+}
diff --git a/umdloop_gui_web/app/GUI functions/MapView.js b/umdloop_gui_web/app/GUI functions/MapView.js
index cfd56424..f2d5dd99 100644
--- a/umdloop_gui_web/app/GUI functions/MapView.js
+++ b/umdloop_gui_web/app/GUI functions/MapView.js
@@ -1,89 +1,111 @@
"use client";
import React, { useState, useRef, useCallback, useEffect } from "react";
-import { Map, Marker } from "react-map-gl/maplibre";
+import { Map, Marker, Source, Layer } from "react-map-gl/maplibre";
import "maplibre-gl/dist/maplibre-gl.css";
-import { useLocalTiles } from "../config";
+import { getApiBaseUrl } from "../config";
+
+// heading (radians, ROS: 0=East CCW) → CSS rotate degrees for an up-pointing arrow
+function headingToCssRotate(rad) {
+ return -(rad * 180 / Math.PI) + 90;
+}
+
+export default function MapView({
+ selectedSubsystem,
+ titleOverride,
+ // Controlled props — when provided, MapView is a pure display component
+ waypoints: externalWaypoints,
+ onAddWaypoint,
+ roverPosition: externalRoverPos,
+ roverStatus: externalRoverStatus,
+ roverHeading, // radians, ROS convention (0=East, CCW) — null when unknown
+ onTileMissing, // called when a tile fails to load (404 / offline)
+}) {
+ const isControlled = externalWaypoints !== undefined;
-export default function MapView({ selectedSubsystem, titleOverride }) {
const [viewState, setViewState] = useState({
longitude: -76.9378,
latitude: 38.9897,
zoom: 13,
});
- const [waypoints, setWaypoints] = useState([]);
- const [roverPosition, setRoverPosition] = useState(null);
- const [rosStatus, setRosStatus] = useState("no fix");
+
+ // Internal state (self-contained mode — used by Drone tab)
+ const [internalWaypoints, setInternalWaypoints] = useState([]);
+ const [internalRoverPos, setInternalRoverPos] = useState(null);
+ const [internalRosStatus, setInternalRosStatus] = useState("no fix");
const [followRover, setFollowRover] = useState(false);
+ const [tileMissingShown, setTileMissingShown] = useState(false);
+
const mapRef = useRef();
- // Poll Flask backend for latest /gps/fix data (sourced from ROS via ros_bridge.py)
+ const displayedWaypoints = isControlled ? externalWaypoints : internalWaypoints;
+ const displayedRoverPos = externalRoverPos ?? internalRoverPos;
+ const displayedRosStatus = externalRoverStatus ?? internalRosStatus;
+
+ // GPS polling (only in self-contained mode)
useEffect(() => {
+ if (isControlled) return;
const poll = async () => {
try {
const res = await fetch("http://127.0.0.1:5000/navigation/rover-position");
const data = await res.json();
if (data.fix) {
const pos = { latitude: data.latitude, longitude: data.longitude };
- setRoverPosition(pos);
- setRosStatus("fix");
- // Keep map centered on rover when follow mode is active
+ setInternalRoverPos(pos);
+ setInternalRosStatus("fix");
setFollowRover((prev) => {
- if (prev) {
- setViewState((vs) => ({ ...vs, latitude: pos.latitude, longitude: pos.longitude }));
- }
+ if (prev) setViewState((vs) => ({ ...vs, latitude: pos.latitude, longitude: pos.longitude }));
return prev;
});
} else {
- setRosStatus("no fix");
+ setInternalRosStatus("no fix");
}
} catch {
- setRosStatus("unreachable");
+ setInternalRosStatus("unreachable");
}
};
-
poll();
const id = setInterval(poll, 1000);
return () => clearInterval(id);
- }, []);
+ }, [isControlled]);
+
+ // Attach tile-error listener once map loads
+ const handleMapLoad = useCallback(() => {
+ const map = mapRef.current?.getMap();
+ if (!map) return;
+ map.on("error", (e) => {
+ // MapLibre fires error events for tile load failures
+ if (e.sourceId || e.tile || (e.error && e.error.status === 404)) {
+ setTileMissingShown(true);
+ onTileMissing?.();
+ }
+ });
+ }, [onTileMissing]);
- // Snap to rover and enable follow mode
const centerOnRover = () => {
- if (!roverPosition) return;
+ if (!displayedRoverPos) return;
setFollowRover(true);
- setViewState((vs) => ({
- ...vs,
- latitude: roverPosition.latitude,
- longitude: roverPosition.longitude,
- }));
+ setViewState((vs) => ({ ...vs, latitude: displayedRoverPos.latitude, longitude: displayedRoverPos.longitude }));
};
- // Any manual drag breaks follow mode
- const handleDragStart = useCallback(() => {
- setFollowRover(false);
- }, []);
+ const handleDragStart = useCallback(() => setFollowRover(false), []);
- const handleMapClick = useCallback(
- (event) => {
- const { lngLat } = event;
- setWaypoints((prev) => [
+ const handleMapClick = useCallback((event) => {
+ const { lngLat } = event;
+ if (isControlled) {
+ onAddWaypoint?.({ lng: lngLat.lng, lat: lngLat.lat });
+ } else {
+ setInternalWaypoints((prev) => [
...prev,
- { id: Date.now(), longitude: lngLat.lng, latitude: lngLat.lat },
+ { id: Date.now(), name: `WP ${prev.length + 1}`, longitude: lngLat.lng, latitude: lngLat.lat },
]);
- },
- []
- );
+ }
+ }, [isControlled, onAddWaypoint]);
- const deleteWaypoint = (id) => {
- setWaypoints((prev) => prev.filter((wp) => wp.id !== id));
- };
+ const deleteWaypoint = (id) => setInternalWaypoints((prev) => prev.filter((wp) => wp.id !== id));
+ const deleteAllWaypoints = () => setInternalWaypoints([]);
- const deleteAllWaypoints = () => setWaypoints([]);
-
- const MAPTILER_KEY = "DDQqKsPBfdOZOVxgcoy5";
- const tileUrl = useLocalTiles()
- ? "/tiles/{z}/{x}/{y}.jpg"
- : `https://api.maptiler.com/tiles/satellite/{z}/{x}/{y}.jpg?key=${MAPTILER_KEY}`;
+ const tileUrl = `${getApiBaseUrl()}/tiles/{z}/{x}/{y}.jpg`;
const mapStyle = {
version: 8,
@@ -92,157 +114,145 @@ export default function MapView({ selectedSubsystem, titleOverride }) {
type: "raster",
tiles: [tileUrl],
tileSize: 256,
- attribution: useLocalTiles()
- ? "Offline tiles"
- : "© MapTiler © OpenStreetMap contributors",
+ attribution: "© MapTiler © OpenStreetMap contributors",
},
},
- layers: [
- {
- id: "satellite",
- type: "raster",
- source: "satellite",
- minzoom: 0,
- maxzoom: 22,
- },
- ],
+ layers: [{ id: "satellite", type: "raster", source: "satellite", minzoom: 0, maxzoom: 22 }],
+ };
+
+ const routeGeoJSON = {
+ type: "Feature",
+ geometry: {
+ type: "LineString",
+ coordinates: displayedWaypoints.map((wp) => [wp.longitude, wp.latitude]),
+ },
};
return (
- {/* Header bar */}
-
-
{titleOverride || `${selectedSubsystem} - Map View`}
+ {/* Header */}
+
+
{titleOverride || `${selectedSubsystem} — Map`}
-
- GPS:
{rosStatus}
- {roverPosition && (
-
- {roverPosition.latitude.toFixed(6)}, {roverPosition.longitude.toFixed(6)}
+
+ GPS: {displayedRosStatus}
+ {displayedRoverPos && (
+
+ {displayedRoverPos.latitude.toFixed(6)}, {displayedRoverPos.longitude.toFixed(6)}
+
+ )}
+ {roverHeading !== null && roverHeading !== undefined && (
+
+ hdg: {(roverHeading * 180 / Math.PI).toFixed(1)}°
)}
- {/* Center on Rover button */}
- {/* Waypoint controls */}
-
- Waypoints: {waypoints.length}
- {waypoints.length > 0 && (
-
- )}
-
-
+ {/* Tile missing badge */}
+ {tileMissingShown && (
+
+ ⚠ Tiles not cached — use side panel to download
+
+ )}
- {/* Waypoint list (collapsible) */}
- {waypoints.length > 0 && (
-
- {waypoints.map((wp, idx) => (
-
-
- #{idx + 1}: ({wp.latitude.toFixed(6)}, {wp.longitude.toFixed(6)})
-
+ {/* Self-contained waypoint controls (Drone mode) */}
+ {!isControlled && (
+
+ Waypoints: {displayedWaypoints.length}
+ {displayedWaypoints.length > 0 && (
+ )}
+
+ )}
+
+
+ {/* Self-contained waypoint list (Drone mode) */}
+ {!isControlled && displayedWaypoints.length > 0 && (
+
+ {displayedWaypoints.map((wp, idx) => (
+
+ #{idx + 1} {wp.name}: ({wp.latitude.toFixed(5)}, {wp.longitude.toFixed(5)})
+
))}
)}
{/* Map */}
-
+