Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
umdloop_gui_web/public/regions/*.pmtiles filter=lfs diff=lfs merge=lfs -text
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
562 changes: 562 additions & 0 deletions umdloop_gui_web/app/components/layout/DeliveryMissionPanel.js

Large diffs are not rendered by default.

200 changes: 200 additions & 0 deletions umdloop_gui_web/app/components/layout/MapDeliveryView.js
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: portrait ? "column" : "row",
position: "relative",
overflow: "hidden",
}}>
{/* Map — takes remaining space */}
<div style={{
flex: 1,
minHeight: 0,
minWidth: 0,
}}>
<MapView
titleOverride="Map"
selectedSubsystem={selectedSubsystem}
waypoints={waypoints}
onAddWaypoint={handleAddWaypoint}
roverPosition={roverPosition}
roverStatus={roverStatus}
roverHeading={roverHeading}
onTileMissing={handleTileMissing}
/>
</div>

{/* Collapsed toggle button */}
{!panelOpen && (
<button
onClick={() => setPanelOpen(true)}
style={{
position: "absolute",
...(portrait
? { bottom: 0, left: "50%", transform: "translateX(-50%)", borderRadius: "8px 8px 0 0", padding: "8px 24px", borderBottom: "none" }
: { right: 0, top: "50%", transform: "translateY(-50%)", borderRadius: "8px 0 0 8px", padding: "14px 8px", borderRight: "none" }
),
background: "#1f2937",
border: "1px solid #374151",
color: "white",
cursor: "pointer",
fontSize: 18,
zIndex: 10,
lineHeight: 1,
}}
title="Open mission panel"
>
{portrait ? "▲ Mission" : "◁"}
</button>
)}

{/* Tile missing toast (when panel is closed) */}
{tileMissing && !panelOpen && (
<div style={{
position: "absolute", right: 12, top: 54, zIndex: 20,
background: "#7c2d12", color: "#fca5a5", padding: "8px 14px",
borderRadius: 8, fontSize: "13px", fontWeight: 700,
boxShadow: "0 2px 8px rgba(0,0,0,0.6)",
display: "flex", alignItems: "center", gap: 8,
}}>
⚠ Tiles missing
<button
onClick={() => setPanelOpen(true)}
style={{ background: "#991b1b", border: "none", color: "white", borderRadius: 4, padding: "3px 8px", cursor: "pointer", fontWeight: 700, fontSize: 12 }}
>
Download →
</button>
</div>
)}

{/* Mission panel */}
{panelOpen && (
<DeliveryMissionPanel
waypoints={waypoints}
setWaypoints={setWaypoints}
roverPosition={roverPosition}
roverHeading={roverHeading}
onClose={() => setPanelOpen(false)}
portrait={portrait}
/>
)}
</div>
);
}
138 changes: 138 additions & 0 deletions umdloop_gui_web/app/components/layout/MiniMapHUD.js
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ position: "relative", width: size, height: size }}>
<canvas ref={canvasRef} width={size} height={size} style={{ borderRadius: "50%", display: "block" }} />
{noData && (
<div style={{
position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
borderRadius: "50%", background: "rgba(17,24,39,0.75)", color: "#6b7280", fontSize: "11px", textAlign: "center",
}}>
No heading
</div>
)}
</div>
);
}
3 changes: 2 additions & 1 deletion umdloop_gui_web/app/components/layout/PageContent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -71,7 +72,7 @@ export default function PageContent({
return (
<div style={{ height: "100%", minHeight: 0, padding: "10px" }}>
<div style={{ border: "1px solid #333", borderRadius: "10px", overflow: "hidden", height: "100%" }}>
<MapView selectedSubsystem={selectedSubsystem} titleOverride="Map" />
<MapDeliveryView selectedSubsystem={selectedSubsystem} />
</div>
</div>
);
Expand Down
Loading