From eba955bb6008ffe0557553ec4e7eb876f4a5e2be Mon Sep 17 00:00:00 2001 From: Mohammad Durrani Date: Sat, 9 May 2026 00:53:26 -0400 Subject: [PATCH 1/5] Add delivery mission panel, offline tile caching, and heading-aware rover marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MapDeliveryView: shared state wrapper with ROSLIB heading subscription, GPS polling, localStorage waypoint persistence, orientation detection - DeliveryMissionPanel: collapsible side panel (portrait/landscape layouts) with minimap HUD, named waypoints, distance/ETA, tile download UI - MiniMapHUD: canvas HUD — robot fixed pointing up, dotted line to next waypoint rotates around it, rotating N indicator - MapView: controlled-mode props (waypoints, roverHeading, roverPosition), heading arrow on rover marker, polyline between waypoints, Flask tile proxy - server.py / dev_server.py: tile proxy endpoint with atomic writes, parallel download worker (12 threads, no sleep), /tiles/download + status - download_tiles.py: --lat/--lon/--radius-km CLI args for center+radius mode - config.js: heading messageType updated to msgs/msg/Heading - TechnicianDashboard: heading subscriber reads msg.compass_bearing --- .../components/layout/DeliveryMissionPanel.js | 562 ++++++++++++++++++ .../app/components/layout/MapDeliveryView.js | 200 +++++++ .../app/components/layout/MiniMapHUD.js | 138 +++++ .../app/components/layout/PageContent.jsx | 3 +- .../app/components/map/MapView.jsx | 310 +++++----- umdloop_gui_web/app/config/ros-topics.js | 2 +- .../technician/TechnicianDashboard.jsx | 2 +- umdloop_gui_web/dev_server.py | 207 +++++++ umdloop_gui_web/scripts/download_tiles.py | 115 ++-- umdloop_gui_web/server.py | 139 ++++- 10 files changed, 1480 insertions(+), 198 deletions(-) create mode 100644 umdloop_gui_web/app/components/layout/DeliveryMissionPanel.js create mode 100644 umdloop_gui_web/app/components/layout/MapDeliveryView.js create mode 100644 umdloop_gui_web/app/components/layout/MiniMapHUD.js create mode 100644 umdloop_gui_web/dev_server.py 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..8898c52a --- /dev/null +++ b/umdloop_gui_web/app/components/layout/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 ? ( +
+ setDraft((d) => ({ ...d, name: e.target.value }))} + placeholder="Name" + autoFocus + style={inputStyle} + /> +
+ setDraft((d) => ({ ...d, lat: e.target.value }))} placeholder="Latitude" style={{ ...inputStyle, flex: 1 }} /> + setDraft((d) => ({ ...d, lon: e.target.value }))} placeholder="Longitude" style={{ ...inputStyle, flex: 1 }} /> +
+
+ + +
+
+ ) : ( +
+ {/* 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 && ( +
+
+ setAddForm((f) => ({ ...f, name: e.target.value }))} placeholder="Name" style={{ ...inputStyle, flex: 1 }} /> + setAddForm((f) => ({ ...f, lat: e.target.value }))} placeholder="Lat" style={{ ...inputStyle, flex: 1 }} /> + setAddForm((f) => ({ ...f, lon: e.target.value }))} placeholder="Lon" style={{ ...inputStyle, flex: 1 }} /> + +
+ {addError &&
{addError}
} +
+ )} + + {/* Tile cache form (collapsible) */} + {showTileCache && ( +
+
+ setCenterForm((f) => ({ ...f, lat: e.target.value }))} placeholder="Lat" style={{ ...inputStyle, flex: 1 }} /> + setCenterForm((f) => ({ ...f, lon: e.target.value }))} placeholder="Lon" 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} + /> + ))} + {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 */} +
+ + {showTileCache && ( + <> +
+ setCenterForm((f) => ({ ...f, lat: e.target.value }))} placeholder="Lat" style={{ ...inputStyle, flex: 1 }} /> + setCenterForm((f) => ({ ...f, lon: e.target.value }))} placeholder="Lon" 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..93567f7b --- /dev/null +++ b/umdloop_gui_web/app/components/layout/MapDeliveryView.js @@ -0,0 +1,200 @@ +"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() { + 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/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/map/MapView.jsx b/umdloop_gui_web/app/components/map/MapView.jsx index 9539e582..0dbee9e2 100644 --- a/umdloop_gui_web/app/components/map/MapView.jsx +++ b/umdloop_gui_web/app/components/map/MapView.jsx @@ -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"; import { getRoverPosition } from "../../lib/api"; -export default function MapView({ selectedSubsystem, titleOverride }) { +// 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; + 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 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 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 */} -
+
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/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/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/scripts/download_tiles.py b/umdloop_gui_web/scripts/download_tiles.py index d8f957e1..3c66be0f 100644 --- a/umdloop_gui_web/scripts/download_tiles.py +++ b/umdloop_gui_web/scripts/download_tiles.py @@ -2,19 +2,23 @@ """ Download satellite map tiles for offline use at competition. -Grabs MapTiler satellite tiles for a bounding box around the MDRS site -and saves them to public/tiles/{z}/{x}/{y}.jpg so Next.js can serve them -as static assets. +Grabs MapTiler satellite tiles for a bounding box and saves them to +public/tiles/{z}/{x}/{y}.jpg so Next.js / Flask can serve them as static assets. -Usage: +Usage (bounding box, original default): python3 scripts/download_tiles.py -Customize the bounding box, zoom range, or API key below as needed. +Usage (center + radius): + python3 scripts/download_tiles.py --lat 38.425 --lon -110.785 --radius-km 3 + +Usage (custom bbox + zoom): + python3 scripts/download_tiles.py --min-lat 38.40 --max-lat 38.45 \ + --min-lon -110.81 --max-lon -110.76 --min-zoom 12 --max-zoom 18 """ +import argparse import math import os -import sys import time import urllib.request @@ -22,24 +26,20 @@ MAPTILER_KEY = "DDQqKsPBfdOZOVxgcoy5" -# MDRS site bounding box (~5 km around the origin) -MIN_LAT = 38.400 -MAX_LAT = 38.450 -MIN_LON = -110.810 -MAX_LON = -110.760 +# Default bounding box (MDRS site, ~5 km) +DEFAULT_MIN_LAT = 38.400 +DEFAULT_MAX_LAT = 38.450 +DEFAULT_MIN_LON = -110.810 +DEFAULT_MAX_LON = -110.760 -# Zoom levels to download. 12-13 give context, 14-18 give detail for driving. -# Zoom 19 quadruples the tile count vs 18 — only enable if you need sub-meter -# detail and have time/disk for it. -MIN_ZOOM = 12 -MAX_ZOOM = 18 +DEFAULT_MIN_ZOOM = 12 +DEFAULT_MAX_ZOOM = 18 OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "..", "public", "tiles") -# ── Tile math ────────────────────────────────────────────────────────────────── +# ── Geometry helpers ─────────────────────────────────────────────────────────── def lat_lon_to_tile(lat, lon, zoom): - """Convert lat/lon to slippy-map tile x, y at a given zoom level.""" n = 2 ** zoom x = int((lon + 180.0) / 360.0 * n) lat_rad = math.radians(lat) @@ -48,43 +48,44 @@ def lat_lon_to_tile(lat, lon, zoom): def tile_range(min_lat, min_lon, max_lat, max_lon, zoom): - """Return (x_min, x_max, y_min, y_max) tile indices for a bounding box.""" - x_min, y_max = lat_lon_to_tile(min_lat, min_lon, zoom) # SW corner - x_max, y_min = lat_lon_to_tile(max_lat, max_lon, zoom) # NE corner + 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): + """Return (min_lat, max_lat, min_lon, max_lon) covering a circle of 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 ─────────────────────────────────────────────────────────────────── -def download_tiles(): - os.makedirs(OUTPUT_DIR, exist_ok=True) +def download_tiles(min_lat, max_lat, min_lon, max_lon, min_zoom, max_zoom, output_dir=None): + out = output_dir or OUTPUT_DIR + os.makedirs(out, exist_ok=True) - # Count total tiles first for progress display total = 0 - 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 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) total += (x_max - x_min + 1) * (y_max - y_min + 1) - print(f"Downloading {total} tiles (zoom {MIN_ZOOM}-{MAX_ZOOM}) to {os.path.abspath(OUTPUT_DIR)}") - print(f"Bounding box: ({MIN_LAT}, {MIN_LON}) to ({MAX_LAT}, {MAX_LON})\n") + print(f"Downloading {total} tiles (zoom {min_zoom}-{max_zoom}) → {os.path.abspath(out)}") + print(f"Bounding box: ({min_lat:.4f}, {min_lon:.4f}) to ({max_lat:.4f}, {max_lon:.4f})\n") - downloaded = 0 - skipped = 0 - errors = 0 + downloaded = skipped = errors = 0 - 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) - z_dir = os.path.join(OUTPUT_DIR, str(z)) - count_at_zoom = (x_max - x_min + 1) * (y_max - y_min + 1) - print(f"Zoom {z}: {count_at_zoom} tiles (x {x_min}-{x_max}, y {y_min}-{y_max})") + 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) + count = (x_max - x_min + 1) * (y_max - y_min + 1) + print(f"Zoom {z}: {count} tiles (x {x_min}-{x_max}, y {y_min}-{y_max})") for x in range(x_min, x_max + 1): - x_dir = os.path.join(z_dir, str(x)) + x_dir = os.path.join(out, str(z), str(x)) os.makedirs(x_dir, exist_ok=True) for y in range(y_min, y_max + 1): out_path = os.path.join(x_dir, f"{y}.jpg") - - # Skip tiles we already have if os.path.exists(out_path) and os.path.getsize(out_path) > 0: skipped += 1 continue @@ -100,22 +101,48 @@ def download_tiles(): print(f" ERROR {z}/{x}/{y}: {e}") errors += 1 - # Progress done = downloaded + skipped + errors if done % 50 == 0 or done == total: print(f" Progress: {done}/{total} ({downloaded} new, {skipped} cached, {errors} errors)") - # Be polite to the API — slight delay time.sleep(0.05) print(f"\nDone. {downloaded} downloaded, {skipped} already cached, {errors} errors.") size_mb = sum( os.path.getsize(os.path.join(dp, f)) - for dp, _, fns in os.walk(OUTPUT_DIR) + for dp, _, fns in os.walk(out) for f in fns ) / (1024 * 1024) - print(f"Total size: {size_mb:.1f} MB") + print(f"Total tile cache size: {size_mb:.1f} MB") if __name__ == "__main__": - download_tiles() + parser = argparse.ArgumentParser(description="Download MapTiler satellite tiles for offline use") + + # Center + radius mode + parser.add_argument("--lat", type=float, help="Center latitude (use with --lon and --radius-km)") + parser.add_argument("--lon", type=float, help="Center longitude (use with --lat and --radius-km)") + parser.add_argument("--radius-km", type=float, dest="radius_km", help="Download radius in km") + + # Explicit bbox mode + parser.add_argument("--min-lat", type=float, dest="min_lat", default=None) + parser.add_argument("--max-lat", type=float, dest="max_lat", default=None) + parser.add_argument("--min-lon", type=float, dest="min_lon", default=None) + parser.add_argument("--max-lon", type=float, dest="max_lon", default=None) + + # Zoom range + parser.add_argument("--min-zoom", type=int, dest="min_zoom", default=DEFAULT_MIN_ZOOM) + parser.add_argument("--max-zoom", type=int, dest="max_zoom", default=DEFAULT_MAX_ZOOM) + + args = parser.parse_args() + + if args.lat is not None and args.lon is not None and args.radius_km is not None: + print(f"Center+radius mode: ({args.lat}, {args.lon}), radius={args.radius_km} km") + mn_lat, mx_lat, mn_lon, mx_lon = bbox_from_center_radius(args.lat, args.lon, args.radius_km) + elif args.min_lat is not None: + mn_lat, mx_lat, mn_lon, mx_lon = args.min_lat, args.max_lat, args.min_lon, args.max_lon + else: + print("No args provided — using default MDRS bounding box.") + mn_lat, mx_lat, mn_lon, mx_lon = DEFAULT_MIN_LAT, DEFAULT_MAX_LAT, DEFAULT_MIN_LON, DEFAULT_MAX_LON + + download_tiles(mn_lat, mx_lat, mn_lon, mx_lon, args.min_zoom, args.max_zoom) diff --git a/umdloop_gui_web/server.py b/umdloop_gui_web/server.py index 46430eb8..818734dc 100644 --- a/umdloop_gui_web/server.py +++ b/umdloop_gui_web/server.py @@ -1,17 +1,20 @@ import base64 import json +import math import os import re import signal import ssl import subprocess import sys +import threading import time +from concurrent.futures import ThreadPoolExecutor from urllib import error as urllib_error from urllib import request as urllib_request import cv2 -from flask import Flask, Response, jsonify, request +from flask import Flask, Response, jsonify, request, send_file from flask_cors import CORS from ultralytics import YOLO from ros_bridge import ros_context @@ -21,6 +24,85 @@ CORS(app) process = None +# ── Tile proxy / offline cache ───────────────────────────────────────────────── + +MAPTILER_KEY = os.getenv("MAPTILER_KEY", "DDQqKsPBfdOZOVxgcoy5") +_TILES_DIR = os.path.join(os.path.dirname(__file__), "public", "tiles") + +_download_lock = threading.Lock() +_download_state = {"running": False, "downloaded": 0, "skipped": 0, "total": 0, "errors": 0, "message": ""} + + +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 + + +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"}) + + +# ── MikroTik ─────────────────────────────────────────────────────────────────── + MIKROTIK_HOST = os.getenv("MIKROTIK_HOST", "").strip() MIKROTIK_USER = os.getenv("MIKROTIK_USER", "").strip() MIKROTIK_PASS = os.getenv("MIKROTIK_PASS", "") @@ -382,6 +464,61 @@ def navigation_path_plan(): }) +@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) # atomic on POSIX; avoids serving partial writes + 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__": # exposes to all network interfaces, run app in debug mode on port 5000 app.run(host='0.0.0.0', debug=True, use_reloader=False) From c1b7228b64010b5ae36ee90dc4a1bab5c8b75b50 Mon Sep 17 00:00:00 2001 From: Mohammad Durrani Date: Tue, 26 May 2026 15:10:01 -0400 Subject: [PATCH 2/5] Switch offline tiles from per-tile JPEGs to PMTiles region bundles - Bundle per-mission tile sets into single .pmtiles files under public/regions/, tracked via git-lfs. UMD area (3412 tiles, z10-z18) is 30 MB as one file vs ~70 MB as 3412 inodes. - MapView reads tiles via maplibre's pmtiles:// protocol; no Flask tile-server route is needed at runtime. - Add scripts/build_pmtiles.py to convert a Z/X/Y dir into a region bundle (requires the `pmtiles` CLI from protomaps/go-pmtiles). - Add a regions registry (config/regions.js); active region is selectable via NEXT_PUBLIC_ACTIVE_REGION. - Treat umdloop_gui_web/public/tiles/ as a build-staging area and gitignore it; the committed artifact is the .pmtiles file. --- .gitattributes | 1 + .gitignore | 4 + .../app/components/map/MapView.jsx | 18 ++- umdloop_gui_web/app/config/index.js | 1 + umdloop_gui_web/app/config/regions.js | 26 +++++ umdloop_gui_web/package-lock.json | 16 +++ umdloop_gui_web/package.json | 1 + umdloop_gui_web/public/regions/umd.pmtiles | 3 + umdloop_gui_web/scripts/build_pmtiles.py | 109 ++++++++++++++++++ 9 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 .gitattributes create mode 100644 umdloop_gui_web/app/config/regions.js create mode 100644 umdloop_gui_web/public/regions/umd.pmtiles create mode 100755 umdloop_gui_web/scripts/build_pmtiles.py 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..9d22b27c 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,7 @@ 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/ diff --git a/umdloop_gui_web/app/components/map/MapView.jsx b/umdloop_gui_web/app/components/map/MapView.jsx index 0dbee9e2..9e2ae57d 100644 --- a/umdloop_gui_web/app/components/map/MapView.jsx +++ b/umdloop_gui_web/app/components/map/MapView.jsx @@ -2,10 +2,21 @@ import React, { useState, useRef, useCallback, useEffect } from "react"; import { Map, Marker, Source, Layer } from "react-map-gl/maplibre"; +import maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; -import { getApiBaseUrl } from "../../config"; +import { Protocol } from "pmtiles"; +import { getActiveRegion } from "../../config"; import { getRoverPosition } from "../../lib/api"; +// 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; @@ -105,14 +116,15 @@ export default function MapView({ const deleteWaypoint = (id) => setInternalWaypoints((prev) => prev.filter((wp) => wp.id !== id)); const deleteAllWaypoints = () => setInternalWaypoints([]); - const tileUrl = `${getApiBaseUrl()}/tiles/{z}/{x}/{y}.jpg`; + const region = getActiveRegion(); + ensurePmtilesProtocol(); const mapStyle = { version: 8, sources: { satellite: { type: "raster", - tiles: [tileUrl], + url: `pmtiles://${region.pmtiles}`, tileSize: 256, attribution: "© MapTiler © OpenStreetMap contributors", }, diff --git a/umdloop_gui_web/app/config/index.js b/umdloop_gui_web/app/config/index.js index 30fa57e4..37f40ec7 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 } 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..394b1efc --- /dev/null +++ b/umdloop_gui_web/app/config/regions.js @@ -0,0 +1,26 @@ +/** + * Mission-region tile bundles. Each region maps to a single .pmtiles file + * served from /regions/.pmtiles. To add a region: drop the .pmtiles + * file in public/regions/ and add an entry here. + * + * Build a region bundle from a Z/X/Y tile dir: + * scripts/build_pmtiles.py public/tiles public/regions/.pmtiles --name + */ +export const REGIONS = { + umd: { + label: "UMD / College Park", + pmtiles: "/regions/umd.pmtiles", + center: [-76.9378, 38.9897], + zoom: 13, + }, +}; + +export function getActiveRegionKey() { + if (typeof window !== "undefined" && window.__ACTIVE_REGION__) return window.__ACTIVE_REGION__; + return process.env.NEXT_PUBLIC_ACTIVE_REGION || "umd"; +} + +export function getActiveRegion() { + const key = getActiveRegionKey(); + return REGIONS[key] || REGIONS.umd; +} 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/build_pmtiles.py b/umdloop_gui_web/scripts/build_pmtiles.py new file mode 100755 index 00000000..82b143ca --- /dev/null +++ b/umdloop_gui_web/scripts/build_pmtiles.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +Convert a Z/X/Y JPEG tile directory into a single .pmtiles archive. + +Usage: + scripts/build_pmtiles.py [--name NAME] + +Requires the `pmtiles` CLI (https://github.com/protomaps/go-pmtiles) on PATH: + brew install pmtiles + +Pipeline: tile_dir -> temporary .mbtiles (SQLite) -> pmtiles convert. +MBTiles stores rows in TMS order (y inverted); XYZ dirs are flipped on import. +""" +import argparse +import os +import sqlite3 +import subprocess +import sys +import tempfile +from pathlib import Path + + +def build_mbtiles(tile_dir: Path, mbtiles_path: Path, name: str) -> tuple[int, int, int]: + conn = sqlite3.connect(mbtiles_path) + cur = conn.cursor() + cur.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) + ); + """ + ) + + count = 0 + min_z, max_z = None, None + for z_dir in sorted(tile_dir.iterdir()): + if not z_dir.is_dir() or not z_dir.name.isdigit(): + continue + z = int(z_dir.name) + min_z = z if min_z is None else min(min_z, z) + max_z = z if max_z is None else max(max_z, z) + flip = (1 << z) - 1 + for x_dir in z_dir.iterdir(): + if not x_dir.is_dir() or not x_dir.name.isdigit(): + continue + x = int(x_dir.name) + for tile_path in x_dir.glob("*.jpg"): + stem = tile_path.stem + if not stem.isdigit(): + continue + y = int(stem) + tms_y = flip - y + with open(tile_path, "rb") as fh: + cur.execute( + "INSERT OR REPLACE INTO tiles VALUES (?, ?, ?, ?)", + (z, x, tms_y, fh.read()), + ) + count += 1 + + if count == 0: + raise SystemExit(f"No .jpg tiles found under {tile_dir}") + + metadata = { + "name": name, + "format": "jpg", + "minzoom": str(min_z), + "maxzoom": str(max_z), + "type": "baselayer", + } + cur.executemany("INSERT INTO metadata VALUES (?, ?)", metadata.items()) + conn.commit() + conn.close() + return count, min_z, max_z + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("tile_dir", type=Path) + p.add_argument("out", type=Path) + p.add_argument("--name", default=None, help="Archive display name (default: out filename stem)") + args = p.parse_args() + + if not args.tile_dir.is_dir(): + raise SystemExit(f"{args.tile_dir} is not a directory") + + name = args.name or args.out.stem + args.out.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as td: + mb = Path(td) / "tiles.mbtiles" + print(f"Indexing {args.tile_dir} -> {mb}") + count, min_z, max_z = build_mbtiles(args.tile_dir, mb, name) + print(f" {count} tiles, z{min_z}-z{max_z}") + + print(f"Converting -> {args.out}") + subprocess.run(["pmtiles", "convert", str(mb), str(args.out)], check=True) + + size_mb = args.out.stat().st_size / (1024 * 1024) + print(f"Done: {args.out} ({size_mb:.1f} MB)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From ecc76a2555d7189ad395b59d4bd863e2def50ab3 Mon Sep 17 00:00:00 2001 From: Mohammad Durrani Date: Tue, 26 May 2026 17:01:43 -0400 Subject: [PATCH 3/5] Add region picker UI and add_region CLI - MapView header now shows a Region + {Object.entries(REGIONS).map(([key, r]) => ( + + ))} + + +
GPS: {displayedRosStatus} {displayedRoverPos && ( @@ -212,6 +237,7 @@ export default function MapView({ {/* Map */}
setViewState(evt.viewState)} diff --git a/umdloop_gui_web/app/config/index.js b/umdloop_gui_web/app/config/index.js index 37f40ec7..a08a5ff5 100644 --- a/umdloop_gui_web/app/config/index.js +++ b/umdloop_gui_web/app/config/index.js @@ -1,5 +1,5 @@ export { getRosbridgeUrl, getApiBaseUrl, getWebRTCUrl, useLocalTiles } from "./environment"; -export { REGIONS, getActiveRegion, getActiveRegionKey } from "./regions"; +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 index 394b1efc..f008f891 100644 --- a/umdloop_gui_web/app/config/regions.js +++ b/umdloop_gui_web/app/config/regions.js @@ -15,11 +15,23 @@ export const REGIONS = { }, }; +const STORAGE_KEY = "active-region"; + export function getActiveRegionKey() { - if (typeof window !== "undefined" && window.__ACTIVE_REGION__) return window.__ACTIVE_REGION__; + 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/scripts/add_region.py b/umdloop_gui_web/scripts/add_region.py new file mode 100755 index 00000000..307f78ff --- /dev/null +++ b/umdloop_gui_web/scripts/add_region.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +Build a region tile bundle (.pmtiles) from a GNSS point + radius. + +Downloads satellite tiles around the point (using download_tiles.py), +bundles them into public/regions/.pmtiles, and prints the JS +snippet to paste into app/config/regions.js. + +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 + +Requires the `pmtiles` CLI on PATH (brew install pmtiles). +""" +import argparse +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +# Reuse the existing downloader (same dir). +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import download_tiles # noqa: E402 + +SCRIPT_DIR = Path(__file__).resolve().parent +WEB_ROOT = SCRIPT_DIR.parent +REGIONS_DIR = WEB_ROOT / "public" / "regions" +BUILD_PMTILES = SCRIPT_DIR / "build_pmtiles.py" + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--name", required=True, help="Region key (e.g. mdrs, umd) — used as filename and config key") + p.add_argument("--lat", type=float, required=True, help="Center latitude") + p.add_argument("--lon", type=float, required=True, help="Center longitude") + p.add_argument("--radius-km", type=float, required=True, dest="radius_km", help="Coverage radius in km") + p.add_argument("--min-zoom", type=int, default=download_tiles.DEFAULT_MIN_ZOOM, dest="min_zoom") + p.add_argument("--max-zoom", type=int, default=download_tiles.DEFAULT_MAX_ZOOM, dest="max_zoom") + p.add_argument("--label", default=None, help="Display label (default: name)") + p.add_argument("--keep-tiles", action="store_true", help="Don't delete the staging tile dir after bundling") + args = p.parse_args() + + if not args.name.replace("-", "").replace("_", "").isalnum(): + raise SystemExit(f"Invalid region name: {args.name!r} (use alphanumeric / -_ only)") + + REGIONS_DIR.mkdir(parents=True, exist_ok=True) + out_pmtiles = REGIONS_DIR / f"{args.name}.pmtiles" + + with tempfile.TemporaryDirectory(prefix=f"region-{args.name}-", dir=WEB_ROOT / "public") as staging: + staging_path = Path(staging) + print(f"[1/2] Downloading tiles around ({args.lat}, {args.lon}) r={args.radius_km}km") + mn_lat, mx_lat, mn_lon, mx_lon = download_tiles.bbox_from_center_radius( + args.lat, args.lon, args.radius_km + ) + download_tiles.download_tiles( + mn_lat, mx_lat, mn_lon, mx_lon, + args.min_zoom, args.max_zoom, + output_dir=str(staging_path), + ) + + print(f"\n[2/2] Building {out_pmtiles}") + subprocess.run( + [sys.executable, str(BUILD_PMTILES), str(staging_path), str(out_pmtiles), + "--name", args.name], + check=True, + ) + + if args.keep_tiles: + keep_dir = WEB_ROOT / "public" / "tiles" + keep_dir.mkdir(parents=True, exist_ok=True) + for child in staging_path.iterdir(): + dest = keep_dir / child.name + if dest.exists(): + shutil.rmtree(dest) if dest.is_dir() else dest.unlink() + shutil.move(str(child), str(dest)) + print(f"Staging tiles preserved at {keep_dir}") + + label = args.label or args.name + print("\n" + "=" * 60) + print(f"Add this entry to umdloop_gui_web/app/config/regions.js:") + print("=" * 60) + print(f""" {args.name}: {{ + label: "{label}", + pmtiles: "/regions/{args.name}.pmtiles", + center: [{args.lon}, {args.lat}], + zoom: {min(args.max_zoom, 14)}, + }},""") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 254a1eb02b685c6f5d8b27a472c8c336bacfb315 Mon Sep 17 00:00:00 2001 From: Mohammad Durrani Date: Tue, 26 May 2026 17:06:02 -0400 Subject: [PATCH 4/5] Make add_region.py standalone and auto-edit regions.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the download_tiles.py / build_pmtiles.py dependencies; download satellite tiles directly into a per-region MBTiles cache and shell out to `pmtiles convert` once at the end. No Z/X/Y staging dir. - Re-running with the same --name updates the region in place: the MBTiles cache is reused (only missing tiles fetched), the .pmtiles file is rebuilt, and the regions.js entry is overwritten. - Auto-edit app/config/regions.js between // REGIONS-START and // REGIONS-END markers — no manual paste step. - Cache lives at public/regions/.cache/.mbtiles (gitignored); the committed artifact remains the .pmtiles file. --- .gitignore | 3 + umdloop_gui_web/app/config/regions.js | 12 +- umdloop_gui_web/scripts/add_region.py | 270 ++++++++++++++++++++------ 3 files changed, 224 insertions(+), 61 deletions(-) diff --git a/.gitignore b/.gitignore index 9d22b27c..026c709e 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ 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/config/regions.js b/umdloop_gui_web/app/config/regions.js index f008f891..ed236bce 100644 --- a/umdloop_gui_web/app/config/regions.js +++ b/umdloop_gui_web/app/config/regions.js @@ -1,11 +1,14 @@ /** * Mission-region tile bundles. Each region maps to a single .pmtiles file - * served from /regions/.pmtiles. To add a region: drop the .pmtiles - * file in public/regions/ and add an entry here. + * served from /regions/.pmtiles. * - * Build a region bundle from a Z/X/Y tile dir: - * scripts/build_pmtiles.py public/tiles public/regions/.pmtiles --name + * 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", @@ -14,6 +17,7 @@ export const REGIONS = { zoom: 13, }, }; +// REGIONS-END const STORAGE_KEY = "active-region"; diff --git a/umdloop_gui_web/scripts/add_region.py b/umdloop_gui_web/scripts/add_region.py index 307f78ff..eeb538b1 100755 --- a/umdloop_gui_web/scripts/add_region.py +++ b/umdloop_gui_web/scripts/add_region.py @@ -1,92 +1,248 @@ #!/usr/bin/env python3 """ -Build a region tile bundle (.pmtiles) from a GNSS point + radius. +Add or update a tile region around a GNSS point. -Downloads satellite tiles around the point (using download_tiles.py), -bundles them into public/regions/.pmtiles, and prints the JS -snippet to paste into app/config/regions.js. +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 + 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 shutil +import math +import re +import sqlite3 import subprocess import sys -import tempfile +import time +import urllib.request from pathlib import Path -# Reuse the existing downloader (same dir). -sys.path.insert(0, str(Path(__file__).resolve().parent)) -import download_tiles # noqa: E402 +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" -BUILD_PMTILES = SCRIPT_DIR / "build_pmtiles.py" +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