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 */}
-
+