diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..8a2c33ae --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +umdloop_gui_web/public/regions/*.pmtiles filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index 438bcb17..026c709e 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,10 @@ umdloop_gui_web/.vercel # typescript umdloop_gui_web/*.tsbuildinfo umdloop_gui_web/next-env.d.ts + +# Raw Z/X/Y tiles are a build-staging area for scripts/build_pmtiles.py. +# The committed artifact is umdloop_gui_web/public/regions/*.pmtiles (LFS). +umdloop_gui_web/public/tiles/ + +# add_region.py MBTiles cache (source-of-truth for region builds; .pmtiles is the artifact) +umdloop_gui_web/public/regions/.cache/ diff --git a/umdloop_gui_web/app/components/layout/DeliveryMissionPanel.js b/umdloop_gui_web/app/components/layout/DeliveryMissionPanel.js new file mode 100644 index 00000000..6b396380 --- /dev/null +++ b/umdloop_gui_web/app/components/layout/DeliveryMissionPanel.js @@ -0,0 +1,639 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useRef } from "react"; +import MiniMapHUD, { euclideanMeters } from "./MiniMapHUD"; +import { getApiBaseUrl } from "../../config"; +import { COORD_FORMATS, parseCoord } from "../../lib/coords"; + +const COORD_FORMAT_STORAGE_KEY = "delivery-coord-format"; + +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, coordFormat }) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState({ name: wp.name, lat: String(wp.latitude), lon: String(wp.longitude) }); + const [editError, setEditError] = useState(""); + + const distM = roverPosition ? euclideanMeters(roverPosition, wp) : null; + const fmt = COORD_FORMATS[coordFormat] || COORD_FORMATS.DD; + + const commit = () => { + const lat = parseCoord(draft.lat, { axis: "lat", format: coordFormat }); + const lon = parseCoord(draft.lon, { axis: "lon", format: coordFormat }); + if (!lat.ok) { setEditError(`Lat: ${lat.error}`); return; } + if (!lon.ok) { setEditError(`Lon: ${lon.error}`); return; } + onEdit(wp.id, { name: draft.name.trim() || wp.name, latitude: lat.value, longitude: lon.value }); + setEditError(""); + setEditing(false); + }; + + const cancel = () => { + setDraft({ name: wp.name, lat: String(wp.latitude), lon: String(wp.longitude) }); + setEditing(false); + }; + + return ( +
+ {editing ? ( +
+ setDraft((d) => ({ ...d, name: e.target.value }))} + placeholder="Name" + autoFocus + style={inputStyle} + /> +
+ setDraft((d) => ({ ...d, lat: e.target.value }))} placeholder={`Lat (${fmt.placeholderLat})`} style={{ ...inputStyle, flex: 1 }} /> + setDraft((d) => ({ ...d, lon: e.target.value }))} placeholder={`Lon (${fmt.placeholderLon})`} style={{ ...inputStyle, flex: 1 }} /> +
+ {editError &&
{editError}
} +
+ + +
+
+ ) : ( +
+ {/* 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, size }) { + // sortMode is purely a visual indicator for which button looks "active". + // "By distance" is an action: it sorts waypoints once and writes that order + // back into state. The list/route then stay put until the user re-clicks it. + const [sortMode, setSortMode] = useState("manual"); + const [coordFormat, setCoordFormatState] = useState("DD"); + 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); + + useEffect(() => { + const saved = typeof window !== "undefined" ? localStorage.getItem(COORD_FORMAT_STORAGE_KEY) : null; + if (saved && COORD_FORMATS[saved]) setCoordFormatState(saved); + }, []); + + const setCoordFormat = (next) => { + if (!COORD_FORMATS[next]) return; + setCoordFormatState(next); + if (typeof window !== "undefined") localStorage.setItem(COORD_FORMAT_STORAGE_KEY, next); + }; + + const fmt = COORD_FORMATS[coordFormat]; + + const coordFormatSelector = ( +
+
+ Coord format: + {Object.entries(COORD_FORMATS).map(([key, f]) => ( + + ))} +
+
+ {fmt.name} + {fmt.hint && — {fmt.hint}} +
e.g. lat {fmt.examplesLat[0]}, lon {fmt.examplesLon[0]}
+
+
+ ); + + const sortByDistance = useCallback(() => { + if (!roverPosition) return; + setWaypoints((prev) => + [...prev].sort((a, b) => euclideanMeters(roverPosition, a) - euclideanMeters(roverPosition, b)) + ); + setSortMode("distance"); + }, [roverPosition, setWaypoints]); + + const sortedWaypoints = 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 = parseCoord(addForm.lat, { axis: "lat", format: coordFormat }); + const lon = parseCoord(addForm.lon, { axis: "lon", format: coordFormat }); + const name = addForm.name.trim() || `WP ${waypoints.length + 1}`; + if (!lat.ok) { setAddError(`Lat: ${lat.error}`); return; } + if (!lon.ok) { setAddError(`Lon: ${lon.error}`); return; } + setWaypoints((prev) => [...prev, { id: Date.now(), name, latitude: lat.value, longitude: lon.value }]); + 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; + }); + setSortMode("manual"); + }, [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 () => { + let lat, lon; + if (centerForm.lat || centerForm.lon) { + const latP = parseCoord(centerForm.lat, { axis: "lat", format: coordFormat }); + const lonP = parseCoord(centerForm.lon, { axis: "lon", format: coordFormat }); + if (!latP.ok || !lonP.ok) { + setDlStatus({ error: `Invalid center: ${latP.error || lonP.error}` }); + return; + } + lat = latP.value; + lon = lonP.value; + } else { + lat = roverPosition?.latitude; + lon = roverPosition?.longitude; + } + const r = parseFloat(centerForm.radiusKm) || 2; + if (lat == null || lon == null) { 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: size ?? 340, flexDirection: "row" } + : { width: size ?? 300, minWidth: 220, 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 && ( +
+ {coordFormatSelector} +
+ setAddForm((f) => ({ ...f, name: e.target.value }))} placeholder="Name" style={{ ...inputStyle, flex: 1 }} /> + setAddForm((f) => ({ ...f, lat: e.target.value }))} placeholder={`Lat (${fmt.placeholderLat})`} style={{ ...inputStyle, flex: 1 }} /> + setAddForm((f) => ({ ...f, lon: e.target.value }))} placeholder={`Lon (${fmt.placeholderLon})`} style={{ ...inputStyle, flex: 1 }} /> + +
+ {addError &&
{addError}
} +
+ )} + + {/* Tile cache form (collapsible) */} + {showTileCache && ( +
+
+ setCenterForm((f) => ({ ...f, lat: e.target.value }))} placeholder={`Lat (${fmt.placeholderLat})`} style={{ ...inputStyle, flex: 1 }} /> + setCenterForm((f) => ({ ...f, lon: e.target.value }))} placeholder={`Lon (${fmt.placeholderLon})`} style={{ ...inputStyle, flex: 1 }} /> + setCenterForm((f) => ({ ...f, radiusKm: e.target.value }))} placeholder="km" style={{ ...inputStyle, width: 48 }} /> + +
+ {dlStatus && } +
+ )} + + {/* 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} + coordFormat={coordFormat} + /> + ))} + {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} + coordFormat={coordFormat} + /> + ))} +
+ + {/* Add waypoint */} +
+ + {showAddForm && ( + <> + {coordFormatSelector} + 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={`Lat (${fmt.placeholderLat})`} style={{ ...inputStyle, flex: 1 }} /> + setAddForm((f) => ({ ...f, lon: e.target.value }))} placeholder={`Lon (${fmt.placeholderLon})`} style={{ ...inputStyle, flex: 1 }} /> +
+ {addError &&
{addError}
} + +
or click anywhere on the map
+ + )} +
+ + {/* Clear all */} + {waypoints.length > 0 && ( + + )} + + {/* Tile cache */} +
+ + {showTileCache && ( + <> +
+ setCenterForm((f) => ({ ...f, lat: e.target.value }))} placeholder={`Lat (${fmt.placeholderLat})`} style={{ ...inputStyle, flex: 1 }} /> + setCenterForm((f) => ({ ...f, lon: e.target.value }))} placeholder={`Lon (${fmt.placeholderLon})`} style={{ ...inputStyle, flex: 1 }} /> + setCenterForm((f) => ({ ...f, radiusKm: e.target.value }))} placeholder="km" style={{ ...inputStyle, width: 48 }} /> +
+ + {dlStatus && } + + )} +
+ +
+ + )} +
+ ); +} + +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/components/layout/MapDeliveryView.js b/umdloop_gui_web/app/components/layout/MapDeliveryView.js new file mode 100644 index 00000000..afc4e488 --- /dev/null +++ b/umdloop_gui_web/app/components/layout/MapDeliveryView.js @@ -0,0 +1,261 @@ +"use client"; + +import React, { useState, useEffect, useRef, useCallback } from "react"; +import ROSLIB from "roslib"; +import MapView from "../map/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() { + // Always start at `false` (matches server render). Switch to the actual + // orientation after mount so the hydration tree matches the SSR HTML. + const [portrait, setPortrait] = useState(false); + useEffect(() => { + const update = () => setPortrait(window.innerHeight > window.innerWidth); + update(); + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + }, []); + return portrait; +} + +export default function MapDeliveryView({ selectedSubsystem }) { + // Gate all client-only state (localStorage, window size) behind `mounted` + // so the first render matches the server-rendered HTML exactly. + const [mounted, setMounted] = useState(false); + useEffect(() => { setMounted(true); }, []); + + const [waypoints, setWaypoints] = useState([]); + 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 containerRef = useRef(null); + const rosHeadingRef = useRef(false); + const portrait = usePortrait(); + + // Hydrate waypoints from localStorage once mounted. + useEffect(() => { + setWaypoints(loadWaypoints()); + }, []); + + const PANEL_SIZE_KEY = portrait ? "delivery-panel-h" : "delivery-panel-w"; + const DEFAULT_SIZE = portrait ? 340 : 300; + const [panelSize, setPanelSize] = useState(DEFAULT_SIZE); + + useEffect(() => { + const saved = typeof window !== "undefined" ? localStorage.getItem(PANEL_SIZE_KEY) : null; + const n = saved ? parseInt(saved, 10) : NaN; + setPanelSize(Number.isFinite(n) && n > 0 ? n : DEFAULT_SIZE); + }, [PANEL_SIZE_KEY, DEFAULT_SIZE]); + + const startResize = useCallback((e) => { + e.preventDefault(); + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + const onMove = (ev) => { + const total = portrait ? rect.height : rect.width; + const pos = portrait ? ev.clientY - rect.top : ev.clientX - rect.left; + const next = Math.max(220, Math.min(total - 200, total - pos)); + setPanelSize(next); + }; + const onUp = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + setPanelSize((s) => { + if (typeof window !== "undefined") localStorage.setItem(PANEL_SIZE_KEY, String(s)); + return s; + }); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }, [portrait, PANEL_SIZE_KEY]); + + useEffect(() => { + if (!mounted) return; // skip until after localStorage hydration, so we don't overwrite saved waypoints with [] + localStorage.setItem(STORAGE_KEY, JSON.stringify(waypoints)); + }, [waypoints, mounted]); + + // 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 + +
+ )} + + {/* Resize handle between map and panel */} + {panelOpen && ( +
+ )} + + {/* Mission panel */} + {panelOpen && ( + setPanelOpen(false)} + portrait={portrait} + size={panelSize} + /> + )} +
+ ); +} diff --git a/umdloop_gui_web/app/components/layout/MiniMapHUD.js b/umdloop_gui_web/app/components/layout/MiniMapHUD.js new file mode 100644 index 00000000..ce7af55a --- /dev/null +++ b/umdloop_gui_web/app/components/layout/MiniMapHUD.js @@ -0,0 +1,138 @@ +"use client"; + +import React, { useRef, useEffect } from "react"; + +// Euclidean distance in meters (flat-earth, good enough for <10 km) +export function euclideanMeters(pos1, pos2) { + const R = 6371000; + const dLat = (pos2.latitude - pos1.latitude) * (Math.PI / 180) * R; + const dLon = (pos2.longitude - pos1.longitude) * (Math.PI / 180) * R * Math.cos(pos1.latitude * (Math.PI / 180)); + return Math.sqrt(dLat * dLat + dLon * dLon); +} + +function draw(canvas, roverHeading, roverPosition, nextWaypoint) { + const ctx = canvas.getContext("2d"); + const W = canvas.width; + const H = canvas.height; + const cx = W / 2; + const cy = H / 2; + const R = Math.min(W, H) * 0.44; + + ctx.clearRect(0, 0, W, H); + + // Background circle + ctx.beginPath(); + ctx.arc(cx, cy, R, 0, Math.PI * 2); + ctx.fillStyle = "#111827"; + ctx.fill(); + ctx.strokeStyle = "#374151"; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Inner range ring + ctx.beginPath(); + ctx.arc(cx, cy, R * 0.55, 0, Math.PI * 2); + ctx.strokeStyle = "#1f2937"; + ctx.lineWidth = 1; + ctx.stroke(); + + // North indicator — rotates based on rover heading to show where North is relative to rover forward + if (roverHeading !== null && roverHeading !== undefined) { + // bearing to North (π/2 in ROS East-CCW) relative to rover forward + const northRelative = Math.PI / 2 - roverHeading; + // canvas: 0=right CW, up=-π/2; robot faces up → waypoint at canvasAngle = -π/2 - relativeAngle + const northCanvasAngle = -Math.PI / 2 - northRelative; + const nr = R * 0.82; + const nx = cx + Math.cos(northCanvasAngle) * nr; + const ny = cy + Math.sin(northCanvasAngle) * nr; + ctx.font = "bold 9px sans-serif"; + ctx.fillStyle = "#6b7280"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("N", nx, ny); + } + + // Dotted line + arrowhead to next waypoint + if (nextWaypoint && roverPosition && roverHeading !== null && roverHeading !== undefined) { + // bearing in ROS convention (0=East, CCW, radians) + const bearing = Math.atan2( + nextWaypoint.latitude - roverPosition.latitude, + nextWaypoint.longitude - roverPosition.longitude + ); + const relativeAngle = bearing - roverHeading; + const canvasAngle = -Math.PI / 2 - relativeAngle; + const lineLen = R * 0.68; + + const endX = cx + Math.cos(canvasAngle) * lineLen; + const endY = cy + Math.sin(canvasAngle) * lineLen; + + ctx.save(); + ctx.setLineDash([5, 5]); + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(endX, endY); + ctx.strokeStyle = "#22c55e"; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.restore(); + + // Arrowhead + const arrowLen = 9; + const spread = Math.PI / 6; + ctx.beginPath(); + ctx.moveTo(endX, endY); + ctx.lineTo(endX - arrowLen * Math.cos(canvasAngle - spread), endY - arrowLen * Math.sin(canvasAngle - spread)); + ctx.lineTo(endX - arrowLen * Math.cos(canvasAngle + spread), endY - arrowLen * Math.sin(canvasAngle + spread)); + ctx.closePath(); + ctx.fillStyle = "#22c55e"; + ctx.fill(); + } + + // Robot triangle at center, always pointing up + const triH = 13; + const triW = 9; + ctx.beginPath(); + ctx.moveTo(cx, cy - triH); + ctx.lineTo(cx - triW, cy + triH * 0.5); + ctx.lineTo(cx, cy + triH * 0.2); + ctx.lineTo(cx + triW, cy + triH * 0.5); + ctx.closePath(); + ctx.fillStyle = "#22c55e"; + ctx.fill(); + ctx.strokeStyle = "white"; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // "FWD" label above robot + ctx.font = "7px sans-serif"; + ctx.fillStyle = "#4b5563"; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText("FWD", cx, cy - triH - 2); +} + +export default function MiniMapHUD({ roverHeading, roverPosition, nextWaypoint, size = 180 }) { + const canvasRef = useRef(); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + draw(canvas, roverHeading, roverPosition, nextWaypoint); + }, [roverHeading, roverPosition, nextWaypoint]); + + const noData = roverHeading === null || roverHeading === undefined; + + return ( +
+ + {noData && ( +
+ No heading +
+ )} +
+ ); +} diff --git a/umdloop_gui_web/app/components/layout/PageContent.jsx b/umdloop_gui_web/app/components/layout/PageContent.jsx index ae7236c5..5694734c 100644 --- a/umdloop_gui_web/app/components/layout/PageContent.jsx +++ b/umdloop_gui_web/app/components/layout/PageContent.jsx @@ -2,6 +2,7 @@ import React from "react"; import MapView from "../map/MapView"; +import MapDeliveryView from "./MapDeliveryView"; import OperationsWall from "../../features/operations-wall/OperationsWall"; import OperatorTab from "../../features/operator/OperatorTab"; import Navigation from "../../features/navigation/Navigation"; @@ -71,7 +72,7 @@ export default function PageContent({ return (
- +
); diff --git a/umdloop_gui_web/app/components/layout/RoleScreen.jsx b/umdloop_gui_web/app/components/layout/RoleScreen.jsx index 1bfaa974..c5bf5a95 100644 --- a/umdloop_gui_web/app/components/layout/RoleScreen.jsx +++ b/umdloop_gui_web/app/components/layout/RoleScreen.jsx @@ -9,6 +9,7 @@ import EquipmentOperatorView from "../../features/science/EquipmentOperatorView" import SpectrometerScientistView from "../../features/science/SpectrometerScientistView"; import OperationsWall from "../../features/operations-wall/OperationsWall"; import MapView from "../map/MapView"; +import MapDeliveryView from "./MapDeliveryView"; import SubsystemBar from "./SubsystemBar"; import ConnectionStatusBanner from "./ConnectionStatusBanner"; import { SUBSYSTEMS, NAVIGATION_BUTTONS } from "../../config"; @@ -107,8 +108,18 @@ function getRoleContent({ mission, role, selectedSubsystem, setSelectedSubsystem ); } - // Navigator maps to the existing navigation feature + // Navigator: delivery mission gets the waypoint map; autonomous nav keeps + // the Object Detection / Control Panel view. if (role.id === "navigator") { + if (mission.id === "delivery") { + return ( +
+
+ +
+
+ ); + } return (
diff --git a/umdloop_gui_web/app/components/map/MapView.jsx b/umdloop_gui_web/app/components/map/MapView.jsx index 9539e582..61bb8486 100644 --- a/umdloop_gui_web/app/components/map/MapView.jsx +++ b/umdloop_gui_web/app/components/map/MapView.jsx @@ -1,248 +1,308 @@ "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 maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; -import { useLocalTiles } from "../../config"; +import { Protocol } from "pmtiles"; +import { REGIONS, getActiveRegionKey, setActiveRegionKey } from "../../config"; import { getRoverPosition } from "../../lib/api"; -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"); +// Register the pmtiles:// protocol with MapLibre once per page load. +let pmtilesProtocolRegistered = false; +function ensurePmtilesProtocol() { + if (pmtilesProtocolRegistered) return; + const protocol = new Protocol(); + maplibregl.addProtocol("pmtiles", protocol.tile); + pmtilesProtocolRegistered = true; +} + +// 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, + onTileMissing, +}) { + const isControlled = externalWaypoints !== undefined; + + // Initial state must match SSR: pick the first registered region, then + // sync to the user's saved choice (localStorage) after mount. + const [regionKey, setRegionKey] = useState(() => Object.keys(REGIONS)[0]); + const region = REGIONS[regionKey] || REGIONS[Object.keys(REGIONS)[0]]; + + const [viewState, setViewState] = useState(() => ({ + longitude: region.center[0], + latitude: region.center[1], + zoom: region.zoom ?? 13, + })); + + useEffect(() => { + const stored = getActiveRegionKey(); + if (stored && stored !== regionKey && REGIONS[stored]) { + setRegionKey(stored); + const r = REGIONS[stored]; + setViewState((vs) => ({ ...vs, longitude: r.center[0], latitude: r.center[1], zoom: r.zoom ?? vs.zoom })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleRegionChange = useCallback((e) => { + const next = e.target.value; + setRegionKey(next); + setActiveRegionKey(next); + const r = REGIONS[next]; + if (r) { + setViewState((vs) => ({ ...vs, longitude: r.center[0], latitude: r.center[1], zoom: r.zoom ?? vs.zoom })); + } + }, []); + + // 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 data = await getRoverPosition(); 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 deleteAllWaypoints = () => setWaypoints([]); + const deleteWaypoint = (id) => setInternalWaypoints((prev) => prev.filter((wp) => wp.id !== id)); + const deleteAllWaypoints = () => setInternalWaypoints([]); - 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}`; + ensurePmtilesProtocol(); const mapStyle = { version: 8, sources: { satellite: { type: "raster", - tiles: [tileUrl], + url: `pmtiles://${region.pmtiles}`, 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 */} -
+
setViewState(evt.viewState)} onDragStart={handleDragStart} onClick={handleMapClick} + onLoad={handleMapLoad} style={{ width: "100%", height: "100%" }} mapStyle={mapStyle} attributionControl={true} > - {roverPosition && ( - -
1 && ( + + + + )} + + {/* Rover marker */} + {displayedRoverPos && ( + + {roverHeading !== null && roverHeading !== undefined ? ( +
+ + + +
+ ) : ( +
+ )} )} - {waypoints.map((waypoint, idx) => ( - + {/* Waypoint markers */} + {displayedWaypoints.map((wp, idx) => ( +
{idx + 1}
diff --git a/umdloop_gui_web/app/config/environment.js b/umdloop_gui_web/app/config/environment.js index 0b728a34..367109da 100644 --- a/umdloop_gui_web/app/config/environment.js +++ b/umdloop_gui_web/app/config/environment.js @@ -11,10 +11,13 @@ */ export function getRosbridgeUrl() { + const envUrl = process.env.NEXT_PUBLIC_ROSBRIDGE_WS_URL; if (typeof window !== "undefined") { - return window.__ROSBRIDGE_WS_URL__ ?? process.env.NEXT_PUBLIC_ROSBRIDGE_WS_URL ?? "ws://192.168.88.90:9090"; + if (window.__ROSBRIDGE_WS_URL__) return window.__ROSBRIDGE_WS_URL__; + if (envUrl) return envUrl; + return `ws://${window.location.hostname}:9090`; } - return process.env.NEXT_PUBLIC_ROSBRIDGE_WS_URL || "ws://192.168.88.90:9090"; + return envUrl || "ws://127.0.0.1:9090"; } export function useLocalTiles() { @@ -22,10 +25,14 @@ export function useLocalTiles() { } export function getApiBaseUrl() { + const envUrl = process.env.NEXT_PUBLIC_GUI_API_URL; if (typeof window !== "undefined") { - return window.__GUI_API_URL__ ?? process.env.NEXT_PUBLIC_GUI_API_URL ?? "http://192.168.88.90:5000"; + if (window.__GUI_API_URL__) return window.__GUI_API_URL__; + if (envUrl) return envUrl; + const protocol = window.location.protocol === "https:" ? "https:" : "http:"; + return `${protocol}//${window.location.hostname}:5000`; } - return process.env.NEXT_PUBLIC_GUI_API_URL || "http://192.168.88.90:5000"; + return envUrl || "http://127.0.0.1:5000"; } // Control-channel WebSocket (port 8081) for camera enable/disable/state and diff --git a/umdloop_gui_web/app/config/index.js b/umdloop_gui_web/app/config/index.js index 30fa57e4..a08a5ff5 100644 --- a/umdloop_gui_web/app/config/index.js +++ b/umdloop_gui_web/app/config/index.js @@ -1,4 +1,5 @@ export { getRosbridgeUrl, getApiBaseUrl, getWebRTCUrl, useLocalTiles } from "./environment"; +export { REGIONS, getActiveRegion, getActiveRegionKey, setActiveRegionKey } from "./regions"; export { GUI_REQUIRED_TOPICS, TECHNICIAN_TOPICS, TECHNICIAN_COMMAND_TOPICS } from "./ros-topics"; export { MODES, diff --git a/umdloop_gui_web/app/config/regions.js b/umdloop_gui_web/app/config/regions.js new file mode 100644 index 00000000..ed236bce --- /dev/null +++ b/umdloop_gui_web/app/config/regions.js @@ -0,0 +1,42 @@ +/** + * Mission-region tile bundles. Each region maps to a single .pmtiles file + * served from /regions/.pmtiles. + * + * Add or update a region: + * scripts/add_region.py --name --lat --lon --radius-km + * + * The block between REGIONS-START and REGIONS-END is auto-generated by + * add_region.py — manual edits inside it will be lost on the next run. + */ +// REGIONS-START +export const REGIONS = { + umd: { + label: "UMD / College Park", + pmtiles: "/regions/umd.pmtiles", + center: [-76.9378, 38.9897], + zoom: 13, + }, +}; +// REGIONS-END + +const STORAGE_KEY = "active-region"; + +export function getActiveRegionKey() { + if (typeof window !== "undefined") { + const stored = window.localStorage?.getItem(STORAGE_KEY); + if (stored && REGIONS[stored]) return stored; + if (window.__ACTIVE_REGION__) return window.__ACTIVE_REGION__; + } + return process.env.NEXT_PUBLIC_ACTIVE_REGION || "umd"; +} + +export function setActiveRegionKey(key) { + if (typeof window === "undefined") return; + if (!REGIONS[key]) return; + window.localStorage?.setItem(STORAGE_KEY, key); +} + +export function getActiveRegion() { + const key = getActiveRegionKey(); + return REGIONS[key] || REGIONS.umd; +} diff --git a/umdloop_gui_web/app/config/ros-topics.js b/umdloop_gui_web/app/config/ros-topics.js index ba42a76a..e333943c 100644 --- a/umdloop_gui_web/app/config/ros-topics.js +++ b/umdloop_gui_web/app/config/ros-topics.js @@ -17,7 +17,7 @@ export const GUI_REQUIRED_TOPICS = { }, heading: { name: process.env.NEXT_PUBLIC_GUI_HEADING_TOPIC || "/heading", - messageType: process.env.NEXT_PUBLIC_GUI_HEADING_TYPE || "std_msgs/msg/Float64", + messageType: process.env.NEXT_PUBLIC_GUI_HEADING_TYPE || "msgs/msg/Heading", }, diagnostics: { name: process.env.NEXT_PUBLIC_GUI_DIAGNOSTICS_TOPIC || "/diagnostics", diff --git a/umdloop_gui_web/app/features/technician/TechnicianDashboard.jsx b/umdloop_gui_web/app/features/technician/TechnicianDashboard.jsx index c6363aed..a5f5f914 100644 --- a/umdloop_gui_web/app/features/technician/TechnicianDashboard.jsx +++ b/umdloop_gui_web/app/features/technician/TechnicianDashboard.jsx @@ -272,7 +272,7 @@ export default function TechnicianDashboard({ missionId }) { headingTopic.subscribe((msg) => { countBytes(msg); markHeartbeat("heading"); - const heading = parseMetric(msg?.data ?? msg?.heading_deg ?? msg?.heading); + const heading = parseMetric(msg?.compass_bearing ?? msg?.data); if (heading != null) { setHeadingDeg(heading); markTopicAvailable("heading"); diff --git a/umdloop_gui_web/app/lib/coords.js b/umdloop_gui_web/app/lib/coords.js new file mode 100644 index 00000000..6f35613c --- /dev/null +++ b/umdloop_gui_web/app/lib/coords.js @@ -0,0 +1,95 @@ +// GNSS coordinate parsing for the delivery mission. +// Rule 2.b.v: coordinates may be provided in DD, DDM, or DMS. + +export const COORD_FORMATS = { + DD: { + label: "DD", + name: "Decimal Degrees", + hint: "single signed number", + examplesLat: ["38.9897", "-41.844044"], + examplesLon: ["-76.9378", "146.574150"], + placeholderLat: "38.9897", + placeholderLon: "-76.9378", + }, + DDM: { + label: "DDM", + name: "Degrees Decimal Minutes", + hint: "deg min, space-separated; optional N/S/E/W", + examplesLat: ["38 59.382 N", "-41 50.643"], + examplesLon: ["76 56.268 W", "146 34.449 E"], + placeholderLat: "38 59.382 N", + placeholderLon: "76 56.268 W", + }, + DMS: { + label: "DMS", + name: "Degrees Minutes Seconds", + hint: "deg min sec, space-separated; optional N/S/E/W", + examplesLat: ["38 59 22.92 N", "-41 50 38.6"], + examplesLon: ["76 56 16.08 W", "146 34 26.9 E"], + placeholderLat: "38 59 22.92 N", + placeholderLon: "76 56 16.08 W", + }, +}; + +// Strip degree / minute / second / cardinal markers; return tokens + hemisphere sign. +function tokenize(input) { + const raw = String(input).trim(); + if (!raw) return null; + + // Detect hemisphere from a leading or trailing N/S/E/W (case-insensitive). + let sign = 1; + let body = raw; + const hemi = body.match(/^\s*([NSEW])\b|\b([NSEW])\s*$/i); + if (hemi) { + const letter = (hemi[1] || hemi[2]).toUpperCase(); + if (letter === "S" || letter === "W") sign = -1; + body = body.replace(hemi[0], " ").trim(); + } + + // Replace common symbols with spaces so split handles them uniformly. + body = body.replace(/[°ºd]/gi, " ").replace(/[′'`]/g, " ").replace(/[″"]/g, " ").replace(/,/g, " "); + const parts = body.split(/\s+/).filter(Boolean); + if (parts.length === 0) return null; + + const nums = parts.map(Number); + if (nums.some((n) => Number.isNaN(n))) return null; + + // If the only number is signed, fold that sign in. + if (nums.length >= 1 && nums[0] < 0) { + sign *= -1; + nums[0] = -nums[0]; + } + return { sign, nums }; +} + +export function parseCoord(input, { axis = "lat", format = "DD" } = {}) { + const t = tokenize(input); + if (!t) return { ok: false, error: "Empty or invalid coordinate" }; + + const { sign, nums } = t; + let value; + if (format === "DD") { + if (nums.length !== 1) return { ok: false, error: "DD expects a single number" }; + value = nums[0]; + } else if (format === "DDM") { + if (nums.length < 2) return { ok: false, error: "DDM expects degrees and minutes" }; + const [deg, min] = nums; + if (min < 0 || min >= 60) return { ok: false, error: "Minutes must be in [0, 60)" }; + value = deg + min / 60; + } else if (format === "DMS") { + if (nums.length < 3) return { ok: false, error: "DMS expects degrees, minutes, and seconds" }; + const [deg, min, sec] = nums; + if (min < 0 || min >= 60) return { ok: false, error: "Minutes must be in [0, 60)" }; + if (sec < 0 || sec >= 60) return { ok: false, error: "Seconds must be in [0, 60)" }; + value = deg + min / 60 + sec / 3600; + } else { + return { ok: false, error: `Unknown format ${format}` }; + } + + value *= sign; + const limit = axis === "lat" ? 90 : 180; + if (value < -limit || value > limit) { + return { ok: false, error: `${axis === "lat" ? "Latitude" : "Longitude"} out of range` }; + } + return { ok: true, value }; +} diff --git a/umdloop_gui_web/dev_server.py b/umdloop_gui_web/dev_server.py new file mode 100644 index 00000000..9ab4df92 --- /dev/null +++ b/umdloop_gui_web/dev_server.py @@ -0,0 +1,207 @@ +""" +Lightweight stub server for local GUI development — no ROS, no YOLO, no Jetson needed. + +Provides: + GET /navigation/rover-position — slowly-drifting fake GPS position + GET /navigation/rover-heading — continuously-rotating fake heading (radians, ROS 0=East CCW) + GET /tiles///.jpg — local cache first, falls back to MapTiler CDN + POST /tiles/download — real tile download (identical to production) + GET /tiles/download/status — download progress + +Run alongside the Next.js dev server: + Terminal 1: cd umdloop_gui_web && uv run python dev_server.py + Terminal 2: cd umdloop_gui_web && npm run dev + +Then open http://localhost:3000 +""" + +import math +import os +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from urllib import request as urllib_request + +from flask import Flask, Response, jsonify, request, send_file +from flask_cors import CORS + +app = Flask(__name__) +CORS(app) + +# ── Tile config (same as production) ────────────────────────────────────────── + +MAPTILER_KEY = os.getenv("MAPTILER_KEY", "DDQqKsPBfdOZOVxgcoy5") +_TILES_DIR = os.path.join(os.path.dirname(__file__), "public", "tiles") + +# ── Fake rover state ─────────────────────────────────────────────────────────── +# Start at MDRS site; you can edit these to match wherever you want to test. + +_BASE_LAT = 38.4065 +_BASE_LON = -110.7917 +_START_TIME = time.time() + + +def _fake_position(): + """Slowly circle around the base point so the rover marker visibly moves.""" + t = time.time() - _START_TIME + radius_deg = 0.0002 # ~20 m radius + lat = _BASE_LAT + radius_deg * math.sin(t * 0.15) + lon = _BASE_LON + radius_deg * math.cos(t * 0.15) + return lat, lon + + +def _fake_heading(): + """Rotate 0→2π over 60 s so you can see the arrow and minimap line spin.""" + t = time.time() - _START_TIME + return (t * (2 * math.pi / 60)) % (2 * math.pi) # radians, 0=East CCW + + +# ── Navigation stubs ────────────────────────────────────────────────────────── + +@app.get("/navigation/rover-position") +def rover_position(): + lat, lon = _fake_position() + return jsonify({"ok": True, "fix": True, "latitude": lat, "longitude": lon}) + + +@app.get("/navigation/rover-heading") +def rover_heading(): + """REST heading fallback used by MapDeliveryView when ROSLIB is unavailable.""" + return jsonify({"ok": True, "heading": _fake_heading()}) + + +# ── Tile proxy (identical to production server.py) ──────────────────────────── + +def _lat_lon_to_tile(lat, lon, zoom): + n = 2 ** zoom + x = int((lon + 180.0) / 360.0 * n) + lat_rad = math.radians(lat) + y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) + return x, y + + +def _tile_range(min_lat, min_lon, max_lat, max_lon, zoom): + x_min, y_max = _lat_lon_to_tile(min_lat, min_lon, zoom) + x_max, y_min = _lat_lon_to_tile(max_lat, max_lon, zoom) + return x_min, x_max, y_min, y_max + + +def _bbox_from_center_radius(lat, lon, radius_km): + delta_lat = radius_km / 111.32 + delta_lon = radius_km / (111.32 * math.cos(math.radians(lat))) + return lat - delta_lat, lat + delta_lat, lon - delta_lon, lon + delta_lon + + +_download_lock = threading.Lock() +_download_state = {"running": False, "downloaded": 0, "skipped": 0, "total": 0, "errors": 0, "message": ""} + + +def _download_worker(min_lat, max_lat, min_lon, max_lon, min_zoom, max_zoom): + tasks = [] + for z in range(min_zoom, max_zoom + 1): + x_min, x_max, y_min, y_max = _tile_range(min_lat, min_lon, max_lat, max_lon, z) + for x in range(x_min, x_max + 1): + for y in range(y_min, y_max + 1): + out_path = os.path.join(_TILES_DIR, str(z), str(x), f"{y}.jpg") + url = f"https://api.maptiler.com/tiles/satellite/{z}/{x}/{y}.jpg?key={MAPTILER_KEY}" + tasks.append((url, out_path)) + + with _download_lock: + _download_state.update({"running": True, "downloaded": 0, "skipped": 0, "total": len(tasks), "errors": 0, "message": "Downloading…"}) + + downloaded = skipped = errors = 0 + + def fetch(args): + url, out_path = args + if os.path.exists(out_path) and os.path.getsize(out_path) > 0: + return "skip" + os.makedirs(os.path.dirname(out_path), exist_ok=True) + tmp = out_path + ".tmp" + try: + with urllib_request.urlopen(url, timeout=8) as resp: + data = resp.read() + with open(tmp, "wb") as f: + f.write(data) + os.replace(tmp, out_path) + return "ok" + except Exception: + if os.path.exists(tmp): + os.remove(tmp) + return "error" + + with ThreadPoolExecutor(max_workers=12) as pool: + for result in pool.map(fetch, tasks): + if result == "skip": + skipped += 1 + elif result == "ok": + downloaded += 1 + else: + errors += 1 + with _download_lock: + _download_state.update({"downloaded": downloaded, "skipped": skipped, "errors": errors}) + + with _download_lock: + _download_state.update({"running": False, "message": f"Done — {downloaded} new, {skipped} cached, {errors} errors"}) + + +@app.get("/tiles///.jpg") +def serve_tile(z, x, y): + local_path = os.path.join(_TILES_DIR, str(z), str(x), f"{y}.jpg") + if os.path.exists(local_path) and os.path.getsize(local_path) > 0: + return send_file(local_path, mimetype="image/jpeg") + url = f"https://api.maptiler.com/tiles/satellite/{z}/{x}/{y}.jpg?key={MAPTILER_KEY}" + try: + with urllib_request.urlopen(url, timeout=5) as resp: + data = resp.read() + os.makedirs(os.path.dirname(local_path), exist_ok=True) + tmp = local_path + ".tmp" + with open(tmp, "wb") as f: + f.write(data) + os.replace(tmp, local_path) + return Response(data, mimetype="image/jpeg") + except Exception: + return "", 404 + + +@app.post("/tiles/download") +def start_tile_download(): + if _download_state["running"]: + return jsonify({"ok": False, "error": "Download already in progress"}), 409 + body = request.get_json(silent=True) or {} + try: + if "center" in body: + c = body["center"] + radius_km = float(body.get("radius_km", 2.0)) + min_lat, max_lat, min_lon, max_lon = _bbox_from_center_radius( + float(c["lat"]), float(c["lon"]), radius_km + ) + else: + bbox = body["bbox"] + min_lat, max_lat = float(bbox["min_lat"]), float(bbox["max_lat"]) + min_lon, max_lon = float(bbox["min_lon"]), float(bbox["max_lon"]) + min_zoom = int(body.get("min_zoom", 12)) + max_zoom = int(body.get("max_zoom", 18)) + except (KeyError, TypeError, ValueError) as e: + return jsonify({"ok": False, "error": f"Invalid params: {e}"}), 400 + + t = threading.Thread( + target=_download_worker, + args=(min_lat, max_lat, min_lon, max_lon, min_zoom, max_zoom), + daemon=True, + ) + t.start() + return jsonify({"ok": True, "started": True}) + + +@app.get("/tiles/download/status") +def tile_download_status(): + with _download_lock: + return jsonify({"ok": True, **_download_state}) + + +if __name__ == "__main__": + print("Dev stub server running on http://localhost:5000") + print(f" Fake rover at ({_BASE_LAT}, {_BASE_LON}), slowly circling") + print(" Heading rotates 0→360° every 60s") + print(" Tiles: local cache + MapTiler CDN fallback") + app.run(host="0.0.0.0", port=5000, debug=False) diff --git a/umdloop_gui_web/package-lock.json b/umdloop_gui_web/package-lock.json index ef98ea19..00700ccd 100644 --- a/umdloop_gui_web/package-lock.json +++ b/umdloop_gui_web/package-lock.json @@ -11,6 +11,7 @@ "mapbox-gl": "^3.16.0", "maplibre-gl": "^5.13.0", "next": "15.5.2", + "pmtiles": "^4.4.1", "react": "19.1.0", "react-dom": "19.1.0", "react-map-gl": "^8.1.0", @@ -3592,6 +3593,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5555,6 +5562,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pmtiles": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.4.1.tgz", + "integrity": "sha512-5oTeQc/yX/ft1evbpIlnoCZugQuug/iYIAj/ZTqIqzdGek4uZEho99En890EE6NOSI3JTI3IG8R7r8+SltphxA==", + "license": "BSD-3-Clause", + "dependencies": { + "fflate": "^0.8.2" + } + }, "node_modules/pngparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pngparse/-/pngparse-2.0.1.tgz", diff --git a/umdloop_gui_web/package.json b/umdloop_gui_web/package.json index adf29ce5..b0be7447 100644 --- a/umdloop_gui_web/package.json +++ b/umdloop_gui_web/package.json @@ -12,6 +12,7 @@ "mapbox-gl": "^3.16.0", "maplibre-gl": "^5.13.0", "next": "15.5.2", + "pmtiles": "^4.4.1", "react": "19.1.0", "react-dom": "19.1.0", "react-map-gl": "^8.1.0", diff --git a/umdloop_gui_web/public/regions/umd.pmtiles b/umdloop_gui_web/public/regions/umd.pmtiles new file mode 100644 index 00000000..572ace4f --- /dev/null +++ b/umdloop_gui_web/public/regions/umd.pmtiles @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5cd15b23ffa251e4199dd638b01d22943ae50a14c245f01c6a809baacb51482 +size 31411044 diff --git a/umdloop_gui_web/scripts/add_region.py b/umdloop_gui_web/scripts/add_region.py new file mode 100755 index 00000000..eeb538b1 --- /dev/null +++ b/umdloop_gui_web/scripts/add_region.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +""" +Add or update a tile region around a GNSS point. + +Downloads MapTiler satellite tiles for a circle of given radius into a +per-region MBTiles cache, converts to public/regions/.pmtiles, and +edits app/config/regions.js to register (or update) the region entry. + +Re-running with the same --name updates that region in place: + - the MBTiles cache is reused (only missing tiles are fetched) + - the .pmtiles file is rebuilt + - the regions.js entry is overwritten with new lat/lon/zoom/label + +Usage: + scripts/add_region.py --name mdrs --lat 38.425 --lon -110.785 --radius-km 3 + scripts/add_region.py --name umd --lat 38.9897 --lon -76.9378 --radius-km 1.5 \\ + --min-zoom 12 --max-zoom 18 --label "UMD / College Park" + +Requires the `pmtiles` CLI on PATH (brew install pmtiles). +""" +import argparse +import math +import re +import sqlite3 +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +MAPTILER_KEY = "DDQqKsPBfdOZOVxgcoy5" +DEFAULT_MIN_ZOOM = 12 +DEFAULT_MAX_ZOOM = 18 + +SCRIPT_DIR = Path(__file__).resolve().parent +WEB_ROOT = SCRIPT_DIR.parent +REGIONS_DIR = WEB_ROOT / "public" / "regions" +CACHE_DIR = REGIONS_DIR / ".cache" +REGIONS_JS = WEB_ROOT / "app" / "config" / "regions.js" +MARKER_START = "// REGIONS-START" +MARKER_END = "// REGIONS-END" + + +def lat_lon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]: + n = 2 ** zoom + x = int((lon + 180.0) / 360.0 * n) + lat_rad = math.radians(lat) + y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) + return x, y + + +def bbox_from_center_radius(lat: float, lon: float, radius_km: float) -> tuple[float, float, float, float]: + dlat = radius_km / 111.32 + dlon = radius_km / (111.32 * math.cos(math.radians(lat))) + return lat - dlat, lat + dlat, lon - dlon, lon + dlon + + +def open_mbtiles(path: Path, name: str, min_z: int, max_z: int) -> sqlite3.Connection: + """Open (or create) an MBTiles SQLite file with the standard schema.""" + fresh = not path.exists() + conn = sqlite3.connect(path) + if fresh: + conn.executescript( + """ + CREATE TABLE metadata (name TEXT, value TEXT); + CREATE TABLE tiles ( + zoom_level INTEGER, + tile_column INTEGER, + tile_row INTEGER, + tile_data BLOB, + PRIMARY KEY (zoom_level, tile_column, tile_row) + ); + """ + ) + # Refresh metadata each run. + conn.execute("DELETE FROM metadata") + conn.executemany( + "INSERT INTO metadata VALUES (?, ?)", + [ + ("name", name), + ("format", "jpg"), + ("minzoom", str(min_z)), + ("maxzoom", str(max_z)), + ("type", "baselayer"), + ], + ) + conn.commit() + return conn + + +def download_into_mbtiles( + conn: sqlite3.Connection, + lat: float, lon: float, radius_km: float, + min_z: int, max_z: int, +) -> tuple[int, int, int]: + mn_lat, mx_lat, mn_lon, mx_lon = bbox_from_center_radius(lat, lon, radius_km) + downloaded = skipped = errors = 0 + + for z in range(min_z, max_z + 1): + x_min, y_max = lat_lon_to_tile(mn_lat, mn_lon, z) + x_max, y_min = lat_lon_to_tile(mx_lat, mx_lon, z) + flip = (1 << z) - 1 + count = (x_max - x_min + 1) * (y_max - y_min + 1) + print(f" z{z}: {count} tiles (x {x_min}-{x_max}, y {y_min}-{y_max})") + + for x in range(x_min, x_max + 1): + for y in range(y_min, y_max + 1): + tms_y = flip - y + cur = conn.execute( + "SELECT 1 FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?", + (z, x, tms_y), + ) + if cur.fetchone() is not None: + skipped += 1 + continue + + url = f"https://api.maptiler.com/tiles/satellite/{z}/{x}/{y}.jpg?key={MAPTILER_KEY}" + try: + with urllib.request.urlopen(url, timeout=20) as resp: + blob = resp.read() + conn.execute( + "INSERT OR REPLACE INTO tiles VALUES (?, ?, ?, ?)", + (z, x, tms_y, blob), + ) + downloaded += 1 + if downloaded % 100 == 0: + conn.commit() + print(f" {downloaded} downloaded, {skipped} cached") + time.sleep(0.05) + except Exception as e: + errors += 1 + print(f" ERROR {z}/{x}/{y}: {e}") + + conn.commit() + return downloaded, skipped, errors + + +def convert_to_pmtiles(mbtiles: Path, pmtiles: Path) -> None: + if pmtiles.exists(): + pmtiles.unlink() + subprocess.run(["pmtiles", "convert", str(mbtiles), str(pmtiles)], check=True) + + +def update_regions_js(name: str, label: str, lat: float, lon: float, default_zoom: int) -> None: + if not REGIONS_JS.exists(): + raise SystemExit(f"{REGIONS_JS} not found") + + src = REGIONS_JS.read_text() + if MARKER_START not in src or MARKER_END not in src: + raise SystemExit(f"Markers {MARKER_START!r}/{MARKER_END!r} missing from {REGIONS_JS}") + + pre, rest = src.split(MARKER_START, 1) + _, post = rest.split(MARKER_END, 1) + + # Parse existing entries (best-effort: each top-level "key: { ... }," block). + entries = parse_region_entries(rest.split(MARKER_END, 1)[0]) + entries[name] = { + "label": label, + "pmtiles": f"/regions/{name}.pmtiles", + "center": [lon, lat], + "zoom": default_zoom, + } + + rendered = render_regions_block(entries) + REGIONS_JS.write_text(f"{pre}{MARKER_START}\n{rendered}\n{MARKER_END}{post}") + + +def parse_region_entries(block: str) -> dict[str, dict]: + """Parse the body of the REGIONS object into {key: {label, pmtiles, center, zoom}}.""" + entries: dict[str, dict] = {} + # Each entry: : { label: "...", pmtiles: "...", center: [lon, lat], zoom: N }, + pattern = re.compile( + r'(?P[A-Za-z_][\w-]*)\s*:\s*\{\s*' + r'label:\s*"(?P