Skip to content

Commit f0f1ace

Browse files
refactor: new UI and framework for controller, better UX/UI and easier future features implementation
1 parent e8ced3b commit f0f1ace

28 files changed

+1551
-0
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Tank Operations Frontend
2+
3+
This Vite + React application provides the web experience for the Tank Operations stack.
4+
5+
## Getting Started
6+
7+
`
8+
cd visual_controller/frontend
9+
npm install
10+
npm run dev
11+
`
12+
13+
Set the following environment variables when running locally or deploying:
14+
15+
- VITE_API_BASE_URL – e.g. http://api.nene.02labs.me
16+
- VITE_WS_BASE_URL – e.g. wss://ws.nene.02labs.me
17+
- VITE_DEFAULT_TANK_ID – optional default tank identifier (defaults to ank_001).
18+
19+
Deploy the built site to
20+
ene.02labs.me, keeping the FastAPI backend reachable at pi.nene.02labs.me and the websocket service at ws.nene.02labs.me.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Tank Control Console</title>
8+
</head>
9+
<body>
10+
<div id="root"></div>
11+
<script type="module" src="/src/main.tsx"></script>
12+
</body>
13+
</html>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "tank-frontend",
3+
"version": "0.1.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "vite build",
9+
"preview": "vite preview"
10+
},
11+
"dependencies": {
12+
"axios": "^1.6.8",
13+
"clsx": "^2.1.1",
14+
"leaflet": "^1.9.4",
15+
"react": "^18.3.1",
16+
"react-dom": "^18.3.1",
17+
"react-leaflet": "^4.3.3",
18+
"react-router-dom": "^6.23.1",
19+
"reconnecting-websocket": "^4.4.0",
20+
"zustand": "^4.5.2"
21+
},
22+
"devDependencies": {
23+
"@types/leaflet": "^1.9.9",
24+
"@types/node": "^20.12.7",
25+
"@types/react": "^18.3.1",
26+
"@types/react-dom": "^18.3.0",
27+
"@vitejs/plugin-react": "^4.2.1",
28+
"typescript": "^5.4.5",
29+
"vite": "^5.2.11"
30+
}
31+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { createContext, useContext, useMemo, useState } from "react";
2+
import { Navigate, Route, Routes } from "react-router-dom";
3+
import { TopNav } from "./layout/TopNav";
4+
import { LegacyControllerPage } from "./pages/LegacyController";
5+
import { NtControllerPage } from "./pages/NtController";
6+
import { StatusPage } from "./pages/Status";
7+
import { defaultTankId } from "./utils/constants";
8+
9+
interface TankContextValue {
10+
tankId: string;
11+
setTankId: (value: string) => void;
12+
}
13+
14+
const TankContext = createContext<TankContextValue | undefined>(undefined);
15+
16+
export function useTankContext() {
17+
const value = useContext(TankContext);
18+
if (!value) {
19+
throw new Error("useTankContext must be used within a TankProvider");
20+
}
21+
return value;
22+
}
23+
24+
export default function App() {
25+
const [tankId, setTankId] = useState(defaultTankId);
26+
27+
const value = useMemo<TankContextValue>(() => ({ tankId, setTankId }), [tankId]);
28+
29+
return (
30+
<TankContext.Provider value={value}>
31+
<div className="app-shell">
32+
<TopNav />
33+
<main className="app-main">
34+
<Routes>
35+
<Route path="/" element={<Navigate to="/legacy" replace />} />
36+
<Route path="/legacy" element={<LegacyControllerPage />} />
37+
<Route path="/nt" element={<NtControllerPage />} />
38+
<Route path="/status" element={<StatusPage />} />
39+
</Routes>
40+
</main>
41+
</div>
42+
</TankContext.Provider>
43+
);
44+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
.manual-card {
2+
display: flex;
3+
flex-direction: column;
4+
gap: 1rem;
5+
}
6+
7+
.manual-grid {
8+
display: grid;
9+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
10+
gap: 0.75rem;
11+
}
12+
13+
.manual-speed {
14+
display: flex;
15+
flex-direction: column;
16+
gap: 0.45rem;
17+
font-size: 0.9rem;
18+
color: rgba(226, 232, 240, 0.85);
19+
}
20+
21+
.manual-speed input[type="range"] {
22+
width: 100%;
23+
}
24+
25+
.manual-footer {
26+
display: flex;
27+
justify-content: space-between;
28+
font-size: 0.85rem;
29+
}
30+
31+
.manual-footer .error {
32+
color: #f87171;
33+
}
34+
35+
.btn.danger {
36+
background: linear-gradient(135deg, #f87171, #ef4444);
37+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useState } from "react";
2+
import { sendCommand } from "../../utils/api";
3+
import "./ManualButtons.css";
4+
5+
interface ManualButtonsProps {
6+
tankId: string;
7+
}
8+
9+
const COMMANDS = [
10+
{ label: "Forward", command: "forward" },
11+
{ label: "Stop", command: "stop", intent: "danger" },
12+
{ label: "Backward", command: "backward" },
13+
{ label: "Left", command: "left" },
14+
{ label: "Right", command: "right" }
15+
];
16+
17+
export function ManualButtons({ tankId }: ManualButtonsProps) {
18+
const [speed, setSpeed] = useState<number>(180);
19+
const [busy, setBusy] = useState(false);
20+
const [error, setError] = useState<string | null>(null);
21+
const [lastCommand, setLastCommand] = useState<string | null>(null);
22+
23+
async function handleCommand(command: string) {
24+
setBusy(true);
25+
setError(null);
26+
try {
27+
await sendCommand(tankId, {
28+
command,
29+
leftSpeed: speed,
30+
rightSpeed: speed
31+
});
32+
setLastCommand(command);
33+
} catch (err) {
34+
setError(err instanceof Error ? err.message : "Failed to send command");
35+
} finally {
36+
setBusy(false);
37+
}
38+
}
39+
40+
return (
41+
<div className="card manual-card">
42+
<div className="section-title">Manual Control</div>
43+
<div className="manual-grid">
44+
{COMMANDS.map(({ label, command, intent }) => (
45+
<button
46+
key={command}
47+
className={`btn ${intent === "danger" ? "secondary danger" : ""}`}
48+
disabled={busy}
49+
onClick={() => handleCommand(command)}
50+
>
51+
{label}
52+
</button>
53+
))}
54+
</div>
55+
<label className="manual-speed">
56+
<span>Speed ({speed})</span>
57+
<input
58+
type="range"
59+
min={0}
60+
max={255}
61+
value={speed}
62+
onChange={(event) => setSpeed(Number(event.target.value))}
63+
/>
64+
</label>
65+
<div className="manual-footer">
66+
<span className="muted">Last command: {lastCommand ?? "—"}</span>
67+
{error && <span className="error">{error}</span>}
68+
</div>
69+
</div>
70+
);
71+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
.tank-map {
2+
width: 100%;
3+
height: 320px;
4+
border-radius: 12px;
5+
overflow: hidden;
6+
box-shadow: inset 0 0 24px rgba(15, 23, 42, 0.6);
7+
margin-bottom: 1rem;
8+
}
9+
10+
.map-card {
11+
display: flex;
12+
flex-direction: column;
13+
gap: 0.75rem;
14+
}
15+
16+
.map-meta {
17+
display: flex;
18+
flex-wrap: wrap;
19+
gap: 0.75rem;
20+
font-size: 0.85rem;
21+
color: rgba(226, 232, 240, 0.75);
22+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useEffect, useMemo, useState } from "react";
2+
import { MapContainer, Marker, TileLayer, useMap } from "react-leaflet";
3+
import L from "leaflet";
4+
import { GpsSnapshot } from "../../state/useTankStore";
5+
import "./MapView.css";
6+
7+
const markerIcon = new L.Icon({
8+
iconUrl:
9+
"https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-lightblue.png",
10+
iconSize: [25, 41],
11+
iconAnchor: [12, 41],
12+
popupAnchor: [1, -34],
13+
shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png",
14+
shadowSize: [41, 41]
15+
});
16+
17+
interface MapViewProps {
18+
gps?: GpsSnapshot | null;
19+
}
20+
21+
function MapPositionUpdater({ gps }: { gps?: GpsSnapshot | null }) {
22+
const map = useMap();
23+
24+
useEffect(() => {
25+
if (!gps) return;
26+
const center: L.LatLngExpression = [gps.lat, gps.lon];
27+
map.setView(center, map.getZoom() < 15 ? 15 : map.getZoom(), { animate: true });
28+
}, [gps, map]);
29+
30+
return null;
31+
}
32+
33+
export function MapView({ gps }: MapViewProps) {
34+
const [tileError, setTileError] = useState(false);
35+
36+
const position = useMemo<L.LatLngExpression>(() => {
37+
if (gps && Number.isFinite(gps.lat) && Number.isFinite(gps.lon)) {
38+
return [gps.lat, gps.lon] as L.LatLngExpression;
39+
}
40+
return [0, 0];
41+
}, [gps]);
42+
43+
const hasFix = Boolean(gps && Number.isFinite(gps.lat) && Number.isFinite(gps.lon));
44+
45+
return (
46+
<div className="map-card card">
47+
<div className="section-title">Position</div>
48+
<MapContainer center={position} zoom={hasFix ? 16 : 2} zoomControl={false} className="tank-map">
49+
<TileLayer
50+
attribution='&copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors'
51+
url={
52+
tileError
53+
? "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
54+
: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
55+
}
56+
eventHandlers={{
57+
tileerror: () => setTileError(true)
58+
}}
59+
/>
60+
{hasFix && (
61+
<Marker position={position} icon={markerIcon}>
62+
<span>Tank</span>
63+
</Marker>
64+
)}
65+
<MapPositionUpdater gps={gps ?? undefined} />
66+
</MapContainer>
67+
<div className="map-meta">
68+
{hasFix ? (
69+
<>
70+
<span>
71+
Lat: {gps?.lat.toFixed(6)} Lon: {gps?.lon.toFixed(6)}
72+
</span>
73+
<span>Alt: {gps?.alt_m?.toFixed(1) ?? "—"} m</span>
74+
<span>Speed: {gps?.speed_mps?.toFixed(2) ?? "—"} m/s</span>
75+
<span>HDOP: {gps?.hdop?.toFixed(1) ?? "—"}</span>
76+
<span>Sat: {gps?.satellites ?? "—"}</span>
77+
</>
78+
) : (
79+
<span>No GPS fix received yet.</span>
80+
)}
81+
</div>
82+
</div>
83+
);
84+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
canvas { width: 100%; max-width: 100%; height: auto; border-radius: 50%; background: #031024; display: block; margin: 0 auto 1rem; box-shadow: inset 0 0 28px rgba(15, 23, 42, 0.8); }
2+
3+
.radar-card {
4+
display: flex;
5+
flex-direction: column;
6+
gap: 0.75rem;
7+
}
8+
9+
.radar-meta {
10+
display: flex;
11+
justify-content: space-between;
12+
font-size: 0.85rem;
13+
color: rgba(203, 213, 225, 0.7);
14+
}

0 commit comments

Comments
 (0)