diff --git a/.gitignore b/.gitignore index 461172f..5ef6a52 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,7 @@ yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) -.env -.env.local +.env* # vercel .vercel diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..102c3d0 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,147 @@ +# TypeScript & Tailwind v4 Migration Log + +This document records the migration of the flight-dashboard project from plain JavaScript (Next.js, Tailwind v3) to TypeScript with Tailwind v4. It covers major changes, bugs found and fixed along the way, and loose ground rules for extending the codebase going forward. + +--- + +## 1. Scope of the migration + +### Files converted + +| Original | New | Type | +|---|---|---| +| `BoardStatusWidget.js` | `BoardStatusWidget.tsx` | Component | +| `SensorReadingWidget.js` | `SensorReadingWidget.tsx` | Component | +| `Dashboard.js` | `Dashboard.tsx` | Component | +| `Three.js` | `Three.tsx` | Component | +| `layout.js` | `layout.tsx` | Next.js root layout | +| `page.js` | `page.tsx` | Next.js page | +| `useBackendConnection.js` | `useBackendConnection.ts` | Hook | +| `useBoardConnection.js` | `useBoardConnection.ts` | Hook | +| `useMockData.js` | `useMockData.ts` | Hook | +| `useSensorData.js` | `useSensorData.ts` | Hook | +| `api.js` | `api.ts` | Util | +| `mock.js` | `mock.ts` | Util | +| `globals.css` | `globals.css` | Styles, upgraded to Tailwind v4 syntax | + + +## 2. Type architecture: where types live + +To avoid duplicated/divergent interfaces across files, types follow an "owned by the producer" rule: + +- **`BoardInfo`, `BoardSummary`, `WirelessBoardInfo`** are defined and exported from `BoardStatusWidget.tsx` (the consumer that defines the contract), and imported everywhere else that needs them (`Dashboard.tsx`, `useBoardConnection.ts`, `useMockData.ts`). +- **`SensorData`** is defined and exported from `SensorReadingWidget.tsx`, imported by `Dashboard.tsx` and `useSensorData.ts`. +- **`RawSensorPacket`, `ConnectBoardPacket`, `WirelessInfoResponse`, `ComPortsMap`** are defined and exported from `api.ts`, since these represent the *wire format* coming from the backend, as distinct from the UI-shaped types above. +- **Hook return types** (`UseBackendConnectionResult`, `UseBoardConnectionResult`, `UseMockDataResult`) are defined directly above each hook and exported, then reused in `Dashboard.tsx` as the cast target. + +### Outstanding cleanup (not yet applied) + +**Rule going forward:** never define a duplicate interface for a shape that already has a canonical definition elsewhere. If a hook needs the shape `api.ts` already describes, import it. + +--- + +## 3. Bug fixes + +These are functional changes, not just type annotations. Each one represents behavior that was actually different before and after. + +### Mock CSV refetched on every poll tick +`mock.js`'s `fetchCSV()` re-fetched and re-parsed the entire CSV file from the network on every call — and `useSensorData` polls every 40ms, meaning ~25 fetches per second of a file that never changes. + +**Fix:** `mock.ts` caches the parsed text in a module-level variable after the first fetch. + +### Mock CSV reader only captured the first stream chunk +The original used `response.body.getReader()` and a single `reader.read()` call, which only grabs the first chunk of a streamed response. + +**Fix:** Replaced with `response.text()`, which correctly drains the full body. + +### Broken null/array swap in sensor data parsing +The original had: `result.length == undefined ? oldResult = result : result = oldResult`, intended to discard malformed array-shaped mock rows. The assignment directions were backwards, so `oldResult` (always `null`) routinely got passed into `parseSensorData`, meaning most ticks silently no-opped instead of updating state. + +**Fix:** Replaced with an explicit guard — if the incoming packet looks malformed (has a `.length` property), skip the update and keep previous state. + +### Stale closure in backend status polling +`useBackendConnection.js`'s `checkBoardStatus` called `setReset(!reset)`, capturing `reset` from the enclosing render rather than using a functional update. This mostly worked by accident because the polling interval restarts whenever `connected` changes, but it's fragile. + +**Fix:** Changed to `setReset(prev => !prev)`, consistent with the pattern already used elsewhere in the same file. + +### Missing animation-frame cleanup in `Three.tsx` +The original render loop (`requestAnimationFrame` recursive call) had no corresponding `cancelAnimationFrame` in the effect's cleanup function, so unmounting the component would leave the loop running against a detached canvas. + +**Fix:** Track the frame ID and cancel it on cleanup. + +### Dark mode never actually applied +`globals.css` defined all theme colors inside `@media (prefers-color-scheme: dark)`, but `Dashboard.tsx` toggles dark mode by adding/removing a `.dark` class on `` — these are two unconnected systems. The in-app toggle button changed which Tailwind `dark:` utility classes were active, but the underlying CSS variables (`--base-700`, `--highlight`, etc.) never changed, because they were gated on OS preference, not the class. + +**Fix:** Color variables are now defined in `:root` (light mode) and `.dark` (dark mode), and Tailwind v4's dark variant is rebound to the class selector (see Section 4). + +### Light-mode colors were undefined +Before the fix above, light mode had **no variable definitions at all** — they only existed inside the dark media query. Any session where the OS wasn't in dark mode (or before the bug above is even considered) was rendering with undefined custom properties. + +**Fix:** Full light-mode palette added to `:root`. + +--- + +## 4. Tailwind v4 migration specifics + +This project has no `tailwind.config.ts`. Tailwind v4 moves theme configuration into CSS itself. + +### What changed in `globals.css` + +```css +@import "tailwindcss"; /* was: @tailwind base/components/utilities; */ + +@custom-variant dark (&:where(.dark, .dark *)); /* replaces darkMode: 'class' config option */ + +@theme inline { + --color-base: var(--base); + --color-base-700: var(--base-700); + /* ...etc for every custom color token */ +} +``` + +- **`@theme inline`** is what makes utility classes like `bg-base-700`, `text-highlight`, `bg-accent-yellow` exist at all. If a color is used as a Tailwind class anywhere in the app but isn't listed here, the class silently does nothing — no error, no warning, just unstyled output. +- **`@custom-variant dark`** replaces the old `darkMode: 'class'` JS config option. If `dark:` utilities ever stop responding to the toggle after a future Tailwind upgrade, this line is the first thing to check. +- The actual *values* of each color (light vs dark) still live in plain `:root` / `.dark` CSS variable blocks, exactly as before — `@theme` just points the Tailwind-generated utilities at those variables rather than redefining the values itself. This split matters: `@theme` values are effectively static at the "what utility classes exist" level, while the `:root`/`.dark` variables are what actually switches at runtime. + +### `base-content` is not a real design token + +`text-base-content` is used in `Dashboard.tsx` (the dark-mode toggle button), but no `--base-content` variable was ever defined anywhere in the original CSS. It's currently aliased to `--highlight` in `@theme inline` as the closest sensible equivalent. **Decide if this should be its own distinct color** — if icon-on-base-background contrast ever looks wrong, this alias is why. + +### Adding a new color token (for future development) + +1. Add the raw value to `:root` (light mode value). +2. Add the same variable name to `.dark` (dark mode value). +3. Add a line to `@theme inline`: `--color-my-token: var(--my-token);` +4. Use it as `bg-my-token`, `text-my-token`, etc. + + +## 5. Ground rules for future development + +### TypeScript + +- **No implicit `any`.** Every prop, hook return, event handler, and async function should have an explicit type. If `tsconfig.json` doesn't already have `"strict": true`, turn it on — these files were written as if it were on. +- **Don't duplicate interfaces.** If a type already exists for a data shape (UI-facing or wire-format), import it. See Section 2's "owned by the producer" rule: UI-shaped types live with the component that defines the contract; wire-format types live in `api.ts`. +- **Type hook return values as exported interfaces** named `UseResult`, declared directly above the hook. This keeps the calling component's destructuring/casting consistent and gives you one place to update if a hook's return shape changes. + +### React / Next.js + +- **Keys must be stable identifiers, not array indices**, whenever list order can change (e.g. `boards.map(...)` keys off `port`, not `i`). +- **Every interactive ` - - - - - {/* Right Side - Data Panels */} - -
-
- - -
- - {/* GPS Coordinate */} -
-

GPS Coordinate

- -
-
- - - ); - - -} diff --git a/src/components/layout/Dashboard.tsx b/src/components/layout/Dashboard.tsx new file mode 100644 index 0000000..451207b --- /dev/null +++ b/src/components/layout/Dashboard.tsx @@ -0,0 +1,150 @@ +"use client"; + +import React, { useState, useEffect, type FC } from "react"; + +import MyThree from "@/utils/Three"; + +import { SensorReadingWidget, type SensorData } from "@/components/widgets/SensorReadingWidget"; +import { + BoardStatusWidget, + type BoardInfo, + type BoardSummary, + type WirelessBoardInfo, +} from "@/components/widgets/BoardStatusWidget"; + +import { useBackendConnection } from "@/hooks/useBackendConnection"; +import { useBoardConnection } from "@/hooks/useBoardConnection"; +import { useSensorData } from "@/hooks/useSensorData"; +import { useMockData } from "@/hooks/useMockData"; + +interface UseBackendConnectionResult { + connected: boolean; + setConnected: (connected: boolean) => void; + reset: boolean; + setReset: React.Dispatch>; + checkStatusPing: () => void; +} + +interface UseBoardConnectionResult { + boards: BoardSummary[]; + activeComPort: string | null; + boardInfo: BoardInfo; + wirelessBoardInfo: WirelessBoardInfo | null; + setBoardInfo: React.Dispatch>; + connectToBoard: (boardName: string, callback: (success: boolean) => void) => void; + disconnectBoard: (setConnected: (connected: boolean) => void) => void; +} + +interface UseMockDataResult { + mockConnected: boolean; + onMockConnected: () => void; + onMockDisconnected: () => void; +} + +const BrightnessIcon: FC<{ className?: string }> = ({ className }) => ( + + + +); + +export const Dashboard: FC = () => { + const { connected, setConnected, reset, setReset, checkStatusPing } = + useBackendConnection() as UseBackendConnectionResult; + + const { + boards, + activeComPort, + boardInfo, + wirelessBoardInfo, + setBoardInfo, + connectToBoard, + disconnectBoard, + } = useBoardConnection(reset) as UseBoardConnectionResult; + + const { mockConnected, onMockConnected, onMockDisconnected } = useMockData( + setBoardInfo, + ) as UseMockDataResult; + + const sensorData = useSensorData(connected, mockConnected, checkStatusPing) as SensorData; + + const [darkMode, setDarkMode] = useState(true); + + useEffect(() => { + document.documentElement.classList.toggle("dark", darkMode); + }, [darkMode]); + + const handleConnect = (boardName: string): void => { + if (mockConnected) return; + connectToBoard(boardName, (success: boolean) => { + setConnected(success); + if (!success && !mockConnected) setReset((prev) => !prev); + }); + }; + + const handleDisconnect = (): void => { + onMockDisconnected(); + if (connected) { + disconnectBoard(setConnected); + } + }; + + return ( +
+ {/* Left Side - 3D Model */} +
+
+
+ +
+ +
+ + {/* Right Side - Data Panels */} +
+
+ + +
+ + {/* GPS Coordinate */} +
+

GPS Coordinate

+
+
+
+ ); +}; diff --git a/src/components/widgets/BoardStatusWidget.js b/src/components/widgets/BoardStatusWidget.js deleted file mode 100644 index 5ba4c69..0000000 --- a/src/components/widgets/BoardStatusWidget.js +++ /dev/null @@ -1,162 +0,0 @@ -const COMBoard = ({ name, description, isConnected, onConnect, index }) => ( -
- -
-); - -const MockBoard = ({ onMockConnected }) => ( -
- -
-); - -const BoardInformation = ({ boardInfo, wirelessBoardInfo, mockConnected, onDisconnect }) => ( -
- -
-); - -const WirelessBoardInformation = ({ wirelessBoardInfo }) => { - if (!wirelessBoardInfo) return ( - null - ); - - return ( -
-
-
-

- Wireless Connection: -

-

- {wirelessBoardInfo.target.indexOf("(") === -1 - ? wirelessBoardInfo.target - : wirelessBoardInfo.target.slice(0, wirelessBoardInfo.target.indexOf("("))} -

-

{wirelessBoardInfo.firmware}

-
- - - - -
-
- ); -}; - -// --- Parent component --- -export const BoardStatusWidget = ({ - boards, - activeComPort, - boardInfo, - wirelessBoardInfo, - connected, - onConnect, - mockConnected, - onMockConnected, - onDisconnect, -}) => { - return ( -
-

Boards

-
- { - !(connected || mockConnected) ? - ( - boards.length === 0 ? - ( - - ) - : - ( -
- - {boards.map(({ port, device_description }, i) => ( - - ))} -
- ) - ) - : - ( - - ) - } -
-
- ); -}; diff --git a/src/components/widgets/BoardStatusWidget.tsx b/src/components/widgets/BoardStatusWidget.tsx new file mode 100644 index 0000000..89dca43 --- /dev/null +++ b/src/components/widgets/BoardStatusWidget.tsx @@ -0,0 +1,218 @@ +import type { FC } from "react"; + +export interface BoardInfo { + name: string; + firmware: string; +} + +export interface WirelessBoardInfo { + target: string; + firmware: string; +} + +export interface BoardSummary { + port: string; + device_description?: string; +} + +interface COMBoardProps { + name: string; + description?: string; + isConnected: boolean; + onConnect: (name: string) => void; +} + +interface MockBoardProps { + onMockConnected: () => void; +} + +interface WirelessBoardInformationProps { + wirelessBoardInfo: WirelessBoardInfo | null | undefined; +} + +interface BoardInformationProps { + boardInfo: BoardInfo; + wirelessBoardInfo: WirelessBoardInfo | null | undefined; + mockConnected: boolean; + onDisconnect: () => void; +} + +export interface BoardStatusWidgetProps { + boards: BoardSummary[]; + activeComPort: string | null; + boardInfo: BoardInfo; + wirelessBoardInfo: WirelessBoardInfo | null | undefined; + connected: boolean; + onConnect: (name: string) => void; + mockConnected: boolean; + onMockConnected: () => void; + onDisconnect: () => void; + /** Seconds elapsed in the current mock telemetry run (0-100 expected for the progress bar). */ + mockElapsedSeconds?: number; +} + +function stripParenSuffix(value: string): string { + const parenIndex = value.indexOf("("); + return parenIndex === -1 ? value : value.slice(0, parenIndex); +} + +const ConnectionIndicator: FC<{ pulse?: boolean; className: string }> = ({ + pulse = false, + className, +}) => ( + + {pulse && ( + + )} + + +); + +const COMBoard: FC = ({ name, description, isConnected, onConnect }) => ( +
+ +
+); + +const MockBoard: FC = ({ onMockConnected }) => ( +
+ +
+); + +const WirelessBoardInformation: FC = ({ wirelessBoardInfo }) => { + if (!wirelessBoardInfo) return null; + + return ( +
+
+
+

Wireless Connection:

+

+ {stripParenSuffix(wirelessBoardInfo.target)} +

+

{wirelessBoardInfo.firmware}

+
+ +
+
+ ); +}; + +const BoardInformation: FC = ({ + boardInfo, + wirelessBoardInfo, + mockConnected, + onDisconnect, + mockElapsedSeconds, +}) => ( +
+ +
+); + +export const BoardStatusWidget: FC = ({ + boards, + activeComPort, + boardInfo, + wirelessBoardInfo, + connected, + onConnect, + mockConnected, + onMockConnected, + onDisconnect, + mockElapsedSeconds = 0, +}) => { + const isAnyConnected = connected || mockConnected; + + return ( +
+

Boards

+
+ {isAnyConnected ? ( + + ) : boards.length === 0 ? ( + + ) : ( +
+ + {boards.map(({ port, device_description }) => ( + + ))} +
+ )} +
+
+ ); +}; diff --git a/src/components/widgets/SensorReadingWidget.js b/src/components/widgets/SensorReadingWidget.js deleted file mode 100644 index b8adc65..0000000 --- a/src/components/widgets/SensorReadingWidget.js +++ /dev/null @@ -1,100 +0,0 @@ -export const SensorReadingWidget = ({ sensorData }) => { - const { - accelerationX, - accelerationY, - accelerationZ, - gyroscopeX, - gyroscopeY, - gyroscopeZ, - pitch, - pitchRate, - roll, - rollRate, - yaw, - yawRate, - pressure, - velocity, - altitude, - chipTemperature, - latitude, - longitude, - time - } = sensorData; - - - return ( -
-

Sensor Readings

-
- - - - - - -
-

Temperature:

-

{chipTemperature}

- -
- - - - - - -
-
- ); -}; -function padNumber(value, length = 5) { - const str = String(value); - if (str.startsWith('-')) { - // Remove minus, pad the rest, add minus back - return '-' + str.slice(1).padStart(length, '0'); - } else { - return str.padStart(length, '0'); - } -} - -const DataGroup = ({ title, data }) => ( -
-

{title}

-
- {data.map(({ label, value }) => ( -
- {label} - - {value == 0 ? 0 : padNumber(value, 6)} - -
- ))} -
-
- -); diff --git a/src/components/widgets/SensorReadingWidget.tsx b/src/components/widgets/SensorReadingWidget.tsx new file mode 100644 index 0000000..646ec5c --- /dev/null +++ b/src/components/widgets/SensorReadingWidget.tsx @@ -0,0 +1,91 @@ +import type { FC } from "react"; + +export interface SensorData { + quat_w: number; + quat_x: number; + quat_y: number; + quat_z: number; + alt: number; + long: number; + lat: number; + acc_z: number; + roll_rate: number; +} + +export interface SensorReadingWidgetProps { + sensorData: SensorData; +} + +interface DataItem { + label: string; + value: number; +} + +interface DataGroupProps { + title: string; + data: DataItem[]; +} + +function padNumber(value: number, length = 6): string { + const str = String(value); + if (str.startsWith("-")) { + return `-${str.slice(1).padStart(length, "0")}`; + } + return str.padStart(length, "0"); +} + +function formatValue(value: number): string { + return value === 0 ? "0" : padNumber(value); +} + +const DataGroup: FC = ({ title, data }) => ( +
+

{title}

+
+ {data.map(({ label, value }) => ( +
+ {label} + {formatValue(value)} +
+ ))} +
+
+); + +export const SensorReadingWidget: FC = ({ sensorData }) => { + const { + quat_w, + quat_x, + quat_y, + quat_z, + alt, + long, + lat, + acc_z, + roll_rate, + } = sensorData; + + return ( +
+

Sensor Readings

+
+ + +
+
+ ); +}; diff --git a/src/hooks/useBackendConnection.js b/src/hooks/useBackendConnection.js deleted file mode 100644 index efddfd9..0000000 --- a/src/hooks/useBackendConnection.js +++ /dev/null @@ -1,50 +0,0 @@ -import { api } from "@/utils/api"; -import { useState, useEffect } from "react"; - -export function useBackendConnection() { - const [connected, setConnected] = useState(false); - const [reset, setReset] = useState(false); - - const checkBoardStatus = () => { - api.checkBackend() - .then((response) => { - setReset(!reset); - }) - .catch(error => { - console.log("Backend not reachable ", error); - }) - } - - const checkStatusPing = () => { - api.ping() - .then((response) => { - console.log("ping status ", response); - setConnected(true) - }) - .catch((error) => { - console.log("ping error ", error); - setConnected(false); - setReset(prev => !prev); - }); - } - - - useEffect(() => { - if (!connected) { - const interval = setInterval(() => { - checkBoardStatus(); - }, 2000); - - return () => clearInterval(interval); - } - }, [connected]); - - - return { - connected, - setConnected, - reset, - setReset, - checkStatusPing, - }; -} \ No newline at end of file diff --git a/src/hooks/useBackendConnection.ts b/src/hooks/useBackendConnection.ts new file mode 100644 index 0000000..a5aae33 --- /dev/null +++ b/src/hooks/useBackendConnection.ts @@ -0,0 +1,57 @@ +import { useState, useEffect, useCallback } from "react"; +import { api } from "@/utils/api"; + +export interface UseBackendConnectionResult { + connected: boolean; + setConnected: (connected: boolean) => void; + reset: boolean; + setReset: React.Dispatch>; + checkStatusPing: () => void; +} + +const BOARD_STATUS_POLL_MS = 2000; + +export function useBackendConnection(): UseBackendConnectionResult { + const [connected, setConnected] = useState(false); + const [reset, setReset] = useState(false); + + const checkBoardStatus = useCallback(() => { + api + .checkBackend() + .then(() => { + setReset((prev) => !prev); + }) + .catch((error: unknown) => { + console.error("Backend not reachable", error); + }); + }, []); + + const checkStatusPing = useCallback(() => { + api + .ping() + .then((response: unknown) => { + console.log("ping status", response); + setConnected(true); + }) + .catch((error: unknown) => { + console.error("ping error", error); + setConnected(false); + setReset((prev) => !prev); + }); + }, []); + + useEffect(() => { + if (connected) return; + + const interval = setInterval(checkBoardStatus, BOARD_STATUS_POLL_MS); + return () => clearInterval(interval); + }, [connected, checkBoardStatus]); + + return { + connected, + setConnected, + reset, + setReset, + checkStatusPing, + }; +} diff --git a/src/hooks/useBoardConnection.js b/src/hooks/useBoardConnection.js deleted file mode 100644 index e38e400..0000000 --- a/src/hooks/useBoardConnection.js +++ /dev/null @@ -1,129 +0,0 @@ -import { useState, useEffect, useRef } from 'react'; -import { api } from '@/utils/api'; - -export const useBoardConnection = (reset) => { - const [boards, setBoards] = useState([]); - const [activeComPort, setActiveComPort] = useState(null); - const [boardInfo, setBoardInfo] = useState({ - firmware: null, - name: null, - status: null, - }); - const [wirelessBoardInfo, setWirelessBoardInfo] = useState(null); - const wirelessIntervalRef = useRef(null); - - // Fetch COM ports on reset - useEffect(() => { - Promise.all([api.getComPorts(), api.getActiveComPort()]) - .then(([portsResponse, activeResponse]) => { - setBoards( - Object.entries(portsResponse.data).map(([port, device_description]) => ({ - port, - device_description, - })) - ); - - if (activeResponse.status === 204 || !activeResponse.data) { - setActiveComPort(null); - } else { - setActiveComPort(activeResponse.data); - } - }) - .catch(error => { - console.error('Error fetching board data:', error); - }); - }, [reset]); - - // Function to start polling wireless info - const startWirelessPolling = () => { - if (wirelessIntervalRef.current) return; // already polling - - const fetchWirelessInfo = async () => { - try { - const response = await api.getWirelessInfo(); - - // If 204 or empty response, set null - if (response.status === 204 || !response.data) { - setWirelessBoardInfo(null); - return; - } - - // The API returns the object directly - setWirelessBoardInfo(response.data); - - } catch (error) { - console.error('Error fetching wireless info:', error); - setWirelessBoardInfo(null); - } - }; - - // Initial fetch - fetchWirelessInfo(); - - // Poll every 1.5 seconds - wirelessIntervalRef.current = setInterval(fetchWirelessInfo, 1500); - }; - - const stopWirelessPolling = () => { - if (wirelessIntervalRef.current) { - api.stopDashboardDump(); - clearInterval(wirelessIntervalRef.current); - wirelessIntervalRef.current = null; - } - }; - - const connectToBoard = (name, onConnect) => { - api.connectBoard(name) - .then(response => { - const packet = response.data; - console.log("Connected to PCB:", packet); - setBoardInfo({ - firmware: packet.controller.firmware, - name: packet.controller.name, - status: packet.status - }); - - // Start polling wireless info - startWirelessPolling(); - - // Start dashboard dump poll - api.startDashboardDump(); - - onConnect(true); - }) - .catch(error => { - console.error('Error connecting to PCB:', error); - onConnect(false); - }); - }; - - const disconnectBoard = (onDisconnect) => { - api.disconnectBoard() - .then(() => { - // Stop polling when disconnected - stopWirelessPolling(); - api.stopDashboardDump(); - onDisconnect(false); - }) - .catch(() => onDisconnect(true)); - }; - - // Cleanup polling on unmount - useEffect(() => { - return () => { - stopWirelessPolling(); - }; - }, []); - - console.log(wirelessBoardInfo) - - return { - boards, - activeComPort, - boardInfo, - wirelessBoardInfo, - setBoardInfo, - connectToBoard, - disconnectBoard - }; -}; \ No newline at end of file diff --git a/src/hooks/useBoardConnection.ts b/src/hooks/useBoardConnection.ts new file mode 100644 index 0000000..e25dd1d --- /dev/null +++ b/src/hooks/useBoardConnection.ts @@ -0,0 +1,147 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { api } from "@/utils/api"; +import type { BoardInfo, BoardSummary, WirelessBoardInfo } from "@/components/widgets/BoardStatusWidget"; + +export interface UseBoardConnectionResult { + boards: BoardSummary[]; + activeComPort: string | null; + boardInfo: BoardInfo; + wirelessBoardInfo: WirelessBoardInfo | null; + setBoardInfo: React.Dispatch>; + connectToBoard: (name: string, onConnect: (success: boolean) => void) => void; + disconnectBoard: (onDisconnect: (connected: boolean) => void) => void; +} + +interface ControllerPacket { + controller: { + firmware: string; + name: string; + }; + status: string; +} + +const WIRELESS_POLL_MS = 1500; + +const EMPTY_BOARD_INFO: BoardInfo = { + firmware: "", + name: "", +}; + +export const useBoardConnection = (reset: boolean): UseBoardConnectionResult => { + const [boards, setBoards] = useState([]); + const [activeComPort, setActiveComPort] = useState(null); + const [boardInfo, setBoardInfo] = useState(EMPTY_BOARD_INFO); + const [wirelessBoardInfo, setWirelessBoardInfo] = useState(null); + const wirelessIntervalRef = useRef | null>(null); + + // Fetch COM ports on reset + useEffect(() => { + Promise.all([api.getComPorts(), api.getActiveComPort()]) + .then(([portsResponse, activeResponse]) => { + setBoards( + Object.entries(portsResponse.data as Record).map( + ([port, device_description]) => ({ + port, + device_description, + }), + ), + ); + + if (activeResponse.status === 204 || !activeResponse.data) { + setActiveComPort(null); + } else { + setActiveComPort(activeResponse.data as string); + } + }) + .catch((error: unknown) => { + console.error("Error fetching board data:", error); + }); + }, [reset]); + + const fetchWirelessInfo = useCallback(async () => { + try { + const response = await api.getWirelessInfo(); + + if (response.status === 204 || !response.data) { + setWirelessBoardInfo(null); + return; + } + + setWirelessBoardInfo(response.data as WirelessBoardInfo); + } catch (error) { + console.error("Error fetching wireless info:", error); + setWirelessBoardInfo(null); + } + }, []); + + const startWirelessPolling = useCallback(() => { + if (wirelessIntervalRef.current) return; // already polling + + fetchWirelessInfo(); + wirelessIntervalRef.current = setInterval(fetchWirelessInfo, WIRELESS_POLL_MS); + }, [fetchWirelessInfo]); + + const stopWirelessPolling = useCallback(() => { + if (wirelessIntervalRef.current) { + api.stopDashboardDump(); + clearInterval(wirelessIntervalRef.current); + wirelessIntervalRef.current = null; + } + }, []); + + const connectToBoard = useCallback( + (name: string, onConnect: (success: boolean) => void) => { + api + .connectBoard(name) + .then((response) => { + const packet = response.data as ControllerPacket; + console.log("Connected to PCB:", packet); + setBoardInfo({ + firmware: packet.controller.firmware, + name: packet.controller.name, + }); + + startWirelessPolling(); + api.startDashboardDump(); + + onConnect(true); + }) + .catch((error: unknown) => { + console.error("Error connecting to PCB:", error); + onConnect(false); + }); + }, + [startWirelessPolling], + ); + + const disconnectBoard = useCallback( + (onDisconnect: (connected: boolean) => void) => { + api + .disconnectBoard() + .then(() => { + stopWirelessPolling(); + api.stopDashboardDump(); + onDisconnect(false); + }) + .catch(() => onDisconnect(true)); + }, + [stopWirelessPolling], + ); + + // Cleanup polling on unmount + useEffect(() => { + return () => { + stopWirelessPolling(); + }; + }, [stopWirelessPolling]); + + return { + boards, + activeComPort, + boardInfo, + wirelessBoardInfo, + setBoardInfo, + connectToBoard, + disconnectBoard, + }; +}; diff --git a/src/hooks/useMockData.js b/src/hooks/useMockData.js deleted file mode 100644 index 3b68f7c..0000000 --- a/src/hooks/useMockData.js +++ /dev/null @@ -1,28 +0,0 @@ -import { useState } from 'react'; - -export const useMockData = (setBoardInfo) => { - const [mockConnected, setMockConnected] = useState(false) - - const onMockConnected = () => { - setMockConnected(mockConnected => !mockConnected) - setBoardInfo( - { - firmware: "1.0.1", - name: "MOCK FLIGHT", - status: "RUNNING" - } - ); - } - const onMockDisconnected = () => { - setMockConnected(false); - console.log("DISCONNECTING MOCK FLIGHT") - //setBoardInfo(null); - - } - - return { - mockConnected, - onMockConnected, - onMockDisconnected, - }; -}; \ No newline at end of file diff --git a/src/hooks/useMockData.ts b/src/hooks/useMockData.ts new file mode 100644 index 0000000..d6471c0 --- /dev/null +++ b/src/hooks/useMockData.ts @@ -0,0 +1,34 @@ +import { useState, useCallback } from "react"; +import type { BoardInfo } from "@/components/widgets/BoardStatusWidget"; + +export interface UseMockDataResult { + mockConnected: boolean; + onMockConnected: () => void; + onMockDisconnected: () => void; +} + +const MOCK_BOARD_INFO: BoardInfo = { + firmware: "1.0.1", + name: "MOCK FLIGHT", +}; + +export const useMockData = ( + setBoardInfo: React.Dispatch>, +): UseMockDataResult => { + const [mockConnected, setMockConnected] = useState(false); + + const onMockConnected = useCallback(() => { + setMockConnected((prev) => !prev); + setBoardInfo(MOCK_BOARD_INFO); + }, [setBoardInfo]); + + const onMockDisconnected = useCallback(() => { + setMockConnected(false); + }, []); + + return { + mockConnected, + onMockConnected, + onMockDisconnected, + }; +}; diff --git a/src/hooks/useSensorData.js b/src/hooks/useSensorData.js deleted file mode 100644 index 3fb1e55..0000000 --- a/src/hooks/useSensorData.js +++ /dev/null @@ -1,110 +0,0 @@ -import { useState, useEffect } from 'react'; -import { api } from '@/utils/api'; -import { MockFlight } from '@/utils/mock'; - -// --- Utility: Formats numbers safely --- -const toFixed = (value, prevVal, digits = 2) => - { - if (value === null || value === undefined || isNaN(value)) { - //console.log("Invalid value encountered in toFixed:", value, "\nSetting to Previous Value: ",prevVal); - return prevVal; - } - return Number.parseFloat(value).toFixed(digits); - - - } - -// --- Utility: Converts raw sensor data to consistent state shape --- -const parseSensorData = (data, prevState) => { - if (!data) { - return prevState; - } - //console.log("PARSING FETCHED DATA") - return { - accelerationX: toFixed(data.accXconv, prevState.accXconv), - accelerationY: toFixed(data.accYconv, prevState.accYconv), - accelerationZ: toFixed(data.accZconv, prevState.accZconv), - - gyroscopeX: toFixed(data.gyroXconv, prevState.gyroXconv), - gyroscopeY: toFixed(data.gyroYconv, prevState.gyroYconv), - gyroscopeZ: toFixed(data.gyroZconv, prevState.gyroZconv), - - pitch: toFixed(data.pitchDeg, prevState.pitchDeg), - pitchRate: toFixed(data.pitchRate, prevState.pitchRate), - roll: toFixed(data.rollDeg, prevState.rollDeg), - rollRate: toFixed(data.rollRate, prevState.rollRate), - yaw: toFixed(data.yawDeg, prevState.yawDeg), - yawRate: toFixed(data.yawRate, prevState.yawRate), - - pressure: toFixed(data.pres, prevState.pres), - velocity: toFixed(data.bvelo, prevState.bvelo), - altitude: toFixed(data.alt, prevState.alt), - - time: toFixed(data.time, prevState.time, 2), - - longitude: data.lat !== 0 || data.long !== 0 ? data.long : prevState.longitude, - latitude: data.lat !== 0 || data.long !== 0 ? data.lat : prevState.latitude, - chipTemperature: toFixed(data.temp ?? prevState.chipTemperature), - }; -}; - -export const useSensorData = (connected, mock, onConnectionLost) => { - const [sensorData, setSensorData] = useState(() => ({ - accelerationX: 0, - accelerationY: 0, - accelerationZ: 0, - gyroscopeX: 0, - gyroscopeY: 0, - gyroscopeZ: 0, - pitch: 0, - pitchRate: 0, - roll: 0, - rollRate: 0, - yaw: 0, - yawRate: 0, - pressure: 0, - velocity: 0, - altitude: 0, - chipTemperature: 0, - longitude: 0, - latitude: 0, - time: 0, - })); - - let [rowCount, setRowCount] = useState(0); - const pollingInterval = 40; - - useEffect(() => { - if (!connected && !mock) return; - - const fetchData = async () => { - try - { - let oldResult = null; - let result = mock - ? await MockFlight.getSensorData(rowCount) - : (await api.getSensorData()).data; - - //console.log("Fetched Sensor Data:", result); - - //Removes random flickering NaN values from mock data by reusing last valid data - result.length == undefined ? oldResult = result : result = oldResult - - setSensorData(parseSensorData(result, sensorData)); - if (mock) { - window.timeGOBAL = toFixed(sensorData.time * 5,0,2); // Horrible Horrible Practice - setRowCount(count => count + 1); - } - } catch (err) - { - console.error('Connection error:', err); - if (!mock) onConnectionLost?.(); - } - }; - - const interval = setInterval(fetchData, pollingInterval); - return () => clearInterval(interval); - }, [connected, mock, rowCount, onConnectionLost]); - - return sensorData; -}; diff --git a/src/hooks/useSensorData.ts b/src/hooks/useSensorData.ts new file mode 100644 index 0000000..26080e0 --- /dev/null +++ b/src/hooks/useSensorData.ts @@ -0,0 +1,106 @@ +import { useState, useEffect, useCallback } from "react"; +import { api } from "@/utils/api"; +import { MockFlight } from "@/utils/mock"; +import type { SensorData } from "@/components/widgets/SensorReadingWidget"; + +/** + * Raw sensor payload shape coming from the backend / mock flight source. + * Field names mirror the device's wire format before conversion to the + */ +interface RawSensorPacket { + quat_w?: number; + quat_x?: number; + quat_y?: number; + quat_z?: number; + alt?: number; + long?: number; + lat?: number; + acc_z?: number; + roll_rate?: number; + /** Present when the mock source returns an array instead of a single packet. */ + length?: number; +} + +const POLLING_INTERVAL_MS = 40; + +const INITIAL_SENSOR_DATA: SensorData = { + quatW: 1, + quatX: 0, + quatY: 0, + quatZ: 0, + altitude: 0, + longitude: 0, + latitude: 0, + accelerationZ: 0, + rollRate: 0, +}; + +/** Formats a raw numeric value, falling back to the previous value when invalid. */ +function toFixedOrPrevious(value: number | undefined, previous: number, digits = 2): number { + if (value === null || value === undefined || Number.isNaN(value)) { + return previous; + } + return Number(Number(value).toFixed(digits)); +} + +function parseSensorData(data: RawSensorPacket | null, prevState: SensorData): SensorData { + if (!data) { + return prevState; + } + + return { + quatW: toFixedOrPrevious(data.quat_w, prevState.quatW, 6), + quatX: toFixedOrPrevious(data.quat_x, prevState.quatX, 6), + quatY: toFixedOrPrevious(data.quat_y, prevState.quatY, 6), + quatZ: toFixedOrPrevious(data.quat_z, prevState.quatZ, 6), + + altitude: toFixedOrPrevious(data.alt, prevState.altitude), + + longitude: data.lat !== 0 || data.long !== 0 ? (data.long ?? prevState.longitude) : prevState.longitude, + latitude: data.lat !== 0 || data.long !== 0 ? (data.lat ?? prevState.latitude) : prevState.latitude, + + accelerationZ: toFixedOrPrevious(data.acc_z, prevState.accelerationZ), + rollRate: toFixedOrPrevious(data.roll_rate, prevState.rollRate), + }; +} + +export const useSensorData = ( + connected: boolean, + mock: boolean, + onConnectionLost?: () => void, +): SensorData => { + const [sensorData, setSensorData] = useState(INITIAL_SENSOR_DATA); + const [rowCount, setRowCount] = useState(0); + + const fetchData = useCallback(async () => { + try { + const result: RawSensorPacket = mock + ? await MockFlight.getSensorData(rowCount) + : (await api.getSensorData()).data; + + // Mock data occasionally flickers in a malformed (array-shaped) packet; + // skip the update rather than corrupting state with it. + if (result && typeof result.length === "number") { + return; + } + + setSensorData((prev) => parseSensorData(result, prev)); + + if (mock) { + setRowCount((count) => count + 1); + } + } catch (err) { + console.error("Connection error:", err); + if (!mock) onConnectionLost?.(); + } + }, [mock, rowCount, onConnectionLost]); + + useEffect(() => { + if (!connected && !mock) return; + + const interval = setInterval(fetchData, POLLING_INTERVAL_MS); + return () => clearInterval(interval); + }, [connected, mock, fetchData]); + + return sensorData; +}; \ No newline at end of file diff --git a/src/utils/GoogleMaps.js b/src/utils/GoogleMaps.js deleted file mode 100644 index 7b50a91..0000000 --- a/src/utils/GoogleMaps.js +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -const GoogleMap = ({ latitude, longitude }) => { - const [map, setMap] = useState(null); - const [marker, setMarker] = useState(null); - - useEffect(() => { - if (!latitude || !longitude) return; - - function initMap() { - const location = { lat: latitude, lng: longitude }; - - const mapInstance = new google.maps.Map(document.getElementById("map"), { - zoom: 10, - center: location, - }); - - const markerInstance = new google.maps.Marker({ - position: location, - map: mapInstance, - title: "Flight Location", - }); - - setMap(mapInstance); - setMarker(markerInstance); - } - - if (window.google) { - initMap(); - } else { - const script = document.createElement("script"); - script.src = `https://maps.googleapis.com/maps/api/js?key=${process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY}&callback=console.debug&libraries=maps,marker&v=beta` - script.async = true; - script.defer = true; - document.body.appendChild(script); - script.onload = initMap; - } - }, []); - - useEffect(() => { - if (map && marker) { - const newPosition = { lat: latitude, lng: longitude }; - marker.setPosition(newPosition); - map.setCenter(newPosition); // Optional: Center the map on the new position - } - }, [latitude, longitude]); - - return
; -}; - -export default GoogleMap; diff --git a/src/utils/Three.js b/src/utils/Three.js deleted file mode 100644 index d528730..0000000 --- a/src/utils/Three.js +++ /dev/null @@ -1,233 +0,0 @@ -import * as THREE from 'three'; -import { useEffect, useRef } from "react"; -import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'; -import { OutlineEffect } from 'three/addons/effects/OutlineEffect.js'; - -export function MyThree({ roll, pitch, yaw, lightMode }) { - const refContainer = useRef(null); - const rendererRef = useRef(null); - const sceneRef = useRef(null); - const rocketRef = useRef(null); - const AmbientLight = useRef(null); - - let bgColor = lightMode ? 0x272727 : 0xCACACA; - - //darkmode : lightmode - let rocketOutlineColor = lightMode ? new THREE.Color().setRGB(20,20,20) : new THREE.Color().setRGB(0,0,0); - let rocketOutlineThickness = lightMode ? 0.005 : 0.0075; - let rocketOutlineAlpha = lightMode ? 0.4 : 0.8; - - let ambientLightIntensity = lightMode ? 0.4 : 0.85; - - useEffect(() => { - const scene = sceneRef.current; - const rocket = rocketRef.current; - if (!scene || !rocket) return; - // Update background color - const startColor = scene.background.clone(); - const endColor = new THREE.Color(bgColor); - - const duration = 300; // transition time for scene background color - const startTime = performance.now(); - - function animate() { - const now = performance.now(); - const elapsed = now - startTime; - const t = Math.min(elapsed / duration, 1); // Clamp from 0 to 1 - - scene.background.copy(startColor).lerp(endColor, t); - - if (t < 1) { - requestAnimationFrame(animate); - } else { - scene.background.copy(endColor); // Final color - } - } - - animate(); - - AmbientLight.current.intensity = ambientLightIntensity; - - const outline = rocket.material.userData.outlineParameters; - outline.color[0] = rocketOutlineColor.r; - outline.color[1] = rocketOutlineColor.g; - outline.color[2] = rocketOutlineColor.b; - - outline.thickness = rocketOutlineThickness; - outline.alpha = rocketOutlineAlpha; - }, [lightMode]); - - useEffect(() => { - if (!refContainer.current) return; - - const width = refContainer.current.clientWidth; - const height = refContainer.current.clientHeight; - - // Scene setup - const scene = new THREE.Scene(); - scene.background = new THREE.Color(bgColor); - const camera = new THREE.PerspectiveCamera(80, width / height, 0.1, 1000); - const renderer = new THREE.WebGLRenderer({ antialias: true }); - renderer.setPixelRatio(window.devicePixelRatio); - renderer.setSize(width, height); - - rendererRef.current = renderer; - sceneRef.current = scene; - - refContainer.current.appendChild(renderer.domElement); - - const loader = new STLLoader(); - let effect = new OutlineEffect(renderer); - - loader.load('/NautilusModel.stl', function (geometry) { - const posAttr = geometry.attributes.position; - const vertexCount = posAttr.count; - - geometry.computeBoundingBox(); - const bbox = geometry.boundingBox; - const minZ = bbox.min.z; - const maxZ = bbox.max.z; - const height = maxZ - minZ; - const thresholdZ = minZ + Math.floor(0.855 * height); - - const finsMask = new Float32Array(vertexCount); - for (let i = 0; i < vertexCount; i++) { - const x = posAttr.getX(i); - const y = posAttr.getY(i); - const z = posAttr.getZ(i); - if (z > -40 && z < 170) { - if (Math.abs(x) > 28 || Math.abs(y) > 28) finsMask[i] = 1.0; - } else finsMask[i] = 0.0; - } - - const colors = new Float32Array(vertexCount * 3); - for (let i = 0; i < vertexCount; i++) { - const z = posAttr.getZ(i); - if (z >= thresholdZ) { - colors[i * 3 + 0] = 1.0; - colors[i * 3 + 1] = 0.051; - colors[i * 3 + 2] = 0.051; - } else if (z < -940) { - colors[i * 3 + 0] = 0.01; - colors[i * 3 + 1] = 0.01; - colors[i * 3 + 2] = 0.01; - } else { - colors[i * 3 + 0] = 0.9; - colors[i * 3 + 1] = 0.9; - colors[i * 3 + 2] = 0.9; - } - - if (finsMask[i] > 0.9) { - colors[i * 3 + 0] = 1.0; - colors[i * 3 + 1] = 0.051; - colors[i * 3 + 2] = 0.051; - } - } - - geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); - const materialTEMP = new THREE.MeshStandardMaterial({ - color: 0xFAFAFA, - side: THREE.DoubleSide, - flatShading: true, - vertexColors: true, - }); - - materialTEMP.userData.outlineParameters = { - thickness: rocketOutlineThickness, - color: rocketOutlineColor.toArray(), - alpha: rocketOutlineAlpha, - visible: true - }; - - const realRocket = new THREE.Mesh(geometry, materialTEMP); - realRocket.scale.set(0.01, 0.01, 0.005); - realRocket.position.set(0, 0, 0); - realRocket.rotation.set(-Math.PI / 2, 0, 0); - rocketRef.current = realRocket; - - scene.add(realRocket); - }); - - // Grid setup - const gridSize = 20; - const gridDivisions = 20; - const gridOffset = 6; - - - const gridXZ = new THREE.GridHelper(gridSize, gridDivisions, 0x000000, 0x000000); - gridXZ.position.y = -gridOffset; - scene.add(gridXZ); - - const gridYZ = new THREE.GridHelper(gridSize, gridDivisions, 0x000000, 0x000000); - gridYZ.rotation.z = Math.PI / 2; - gridYZ.position.x = -gridSize / 2; - gridYZ.position.y = -gridOffset + gridSize / 2; - scene.add(gridYZ); - - const gridXY = new THREE.GridHelper(gridSize, gridDivisions, 0x000000, 0x000000); - gridXY.rotation.x = Math.PI / 2; - gridXY.position.z = -gridSize / 2; - gridXY.position.y = -gridOffset + gridSize / 2; - scene.add(gridXY); - - // Lighting & camera - camera.position.set(10, 7, 10); - camera.lookAt(0, 0, 0); - const ambient = new THREE.AmbientLight(0xffffff, ambientLightIntensity); - AmbientLight.current = ambient; - scene.add(ambient); - - const directional = new THREE.DirectionalLight(0xffffee, 1); - directional.position.set(-20, 10, 100); - scene.add(directional); - - const axesOffset = 0.1; - const axes = new THREE.AxesHelper(gridSize); - axes.position.set(-(gridSize / 2) + axesOffset, axesOffset - gridOffset, -(gridSize / 2) + axesOffset); - scene.add(axes); - - // Animation loop - const animate = () => { - requestAnimationFrame(animate); - effect.render(scene, camera); - }; - animate(); - - const handleResize = () => { - const newWidth = refContainer.current.clientWidth; - const newHeight = refContainer.current.clientHeight; - renderer.setSize(newWidth, newHeight); - camera.aspect = newWidth / newHeight; - camera.updateProjectionMatrix(); - }; - window.addEventListener("resize", handleResize); - - return () => { - window.removeEventListener("resize", handleResize); - refContainer.current?.removeChild(renderer.domElement); - if (rocketRef.current) scene.remove(rocketRef.current); - renderer.dispose(); - }; - }, []); - - useEffect(() => { - if (!rocketRef.current) return; - - const rollRad = THREE.MathUtils.degToRad(isFinite(roll) ? roll : 0); - const pitchRad = THREE.MathUtils.degToRad(isFinite(pitch) ? pitch : 0); - const yawRad = THREE.MathUtils.degToRad(isFinite(yaw) ? yaw : 0); - - rocketRef.current.rotation.order = 'ZYX'; - - // Keep your original logic, but fully in radians - if (Math.abs(roll) < 6 && Math.abs(pitch) < 6 && Math.abs(yaw) < 6) { - rocketRef.current.rotation.set(pitchRad - Math.PI / 2, yawRad, 0); - } else { - rocketRef.current.rotation.set(pitchRad - Math.PI / 2, yawRad, 0); - } - }, [roll, pitch, yaw]); - - return
; -} - -export default MyThree; \ No newline at end of file diff --git a/src/utils/Three.tsx b/src/utils/Three.tsx new file mode 100644 index 0000000..25dd6f8 --- /dev/null +++ b/src/utils/Three.tsx @@ -0,0 +1,310 @@ +declare module "three"; +declare module "three/examples/jsm/loaders/STLLoader"; +declare module "three/examples/jsm/effects/OutlineEffect.js"; + +import * as THREE from "three"; +import { useEffect, useRef, type FC } from "react"; +import { STLLoader } from "three/examples/jsm/loaders/STLLoader"; +import { OutlineEffect } from "three/examples/jsm/effects/OutlineEffect.js"; + +export interface MyThreeProps { + w: number; + x: number; + y: number; + z: number; + lightMode: boolean; +} + +interface OutlineParameters { + thickness: number; + color: number[]; + alpha: number; + visible: boolean; +} + +interface OutlinedMesh extends THREE.Mesh { + material: THREE.MeshStandardMaterial & { + userData: { + outlineParameters: OutlineParameters; + }; + }; +} + +const GRID_SIZE = 20; +const GRID_DIVISIONS = 20; +const GRID_OFFSET = 6; +const BG_TRANSITION_DURATION_MS = 300; + +/** + * Converts a quaternion (w, x, y, z) to a THREE.Quaternion, normalising it + * first so an unnormalised or zero quaternion never breaks the renderer. + */ +function toThreeQuat(w: number, x: number, y: number, z: number): THREE.Quaternion { + const q = new THREE.Quaternion(x, y, z, w); // THREE: (x, y, z, w) + const len = q.length(); + // Fall back to identity if the quaternion is degenerate + if (len < 1e-6) return new THREE.Quaternion(0, 0, 0, 1); + return q.normalize(); +} + +export const MyThree: FC = ({ w, x, y, z, lightMode }) => { + const refContainer = useRef(null); + const rendererRef = useRef(null); + const sceneRef = useRef(null); + const rocketRef = useRef(null); + const ambientLightRef = useRef(null); + + const bgColor = lightMode ? 0x272727 : 0xcacaca; + + // darkmode : lightmode + const rocketOutlineColor = lightMode + ? new THREE.Color(20, 20, 20) + : new THREE.Color(0, 0, 0); + const rocketOutlineThickness = lightMode ? 0.005 : 0.0075; + const rocketOutlineAlpha = lightMode ? 0.4 : 0.8; + + const ambientLightIntensity = lightMode ? 0.4 : 0.85; + + // Animate scene background + outline/lighting when lightMode changes + useEffect(() => { + const scene = sceneRef.current; + const rocket = rocketRef.current; + const ambient = ambientLightRef.current; + if (!scene || !rocket || !ambient || !scene.background) return; + + const startColor = (scene.background as THREE.Color).clone(); + const endColor = new THREE.Color(bgColor); + const startTime = performance.now(); + + function animateBackground() { + const elapsed = performance.now() - startTime; + const t = Math.min(elapsed / BG_TRANSITION_DURATION_MS, 1); + + (scene.background as THREE.Color).copy(startColor).lerp(endColor, t); + + if (t < 1) { + requestAnimationFrame(animateBackground); + } else { + (scene.background as THREE.Color).copy(endColor); + } + } + + animateBackground(); + + ambient.intensity = ambientLightIntensity; + + const outline = rocket.material.userData.outlineParameters; + outline.color[0] = rocketOutlineColor.r; + outline.color[1] = rocketOutlineColor.g; + outline.color[2] = rocketOutlineColor.b; + outline.thickness = rocketOutlineThickness; + outline.alpha = rocketOutlineAlpha; + }, [ + lightMode, + bgColor, + ambientLightIntensity, + rocketOutlineColor, + rocketOutlineThickness, + rocketOutlineAlpha, + ]); + + // One-time scene setup + useEffect(() => { + const container = refContainer.current; + if (!container) return; + + const width = container.clientWidth; + const height = container.clientHeight; + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(bgColor); + const camera = new THREE.PerspectiveCamera(80, width / height, 0.1, 1000); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setSize(width, height); + + rendererRef.current = renderer; + sceneRef.current = scene; + + container.appendChild(renderer.domElement); + + const loader = new STLLoader(); + const effect = new OutlineEffect(renderer); + + loader.load("/NautilusModel.stl", (geometry) => { + const posAttr = geometry.attributes.position; + const vertexCount = posAttr.count; + + geometry.computeBoundingBox(); + const bbox = geometry.boundingBox; + if (!bbox) return; + + const minZ = bbox.min.z; + const maxZ = bbox.max.z; + const height3d = maxZ - minZ; + const thresholdZ = minZ + Math.floor(0.855 * height3d); + + const finsMask = new Float32Array(vertexCount); + for (let i = 0; i < vertexCount; i++) { + const x = posAttr.getX(i); + const y = posAttr.getY(i); + const z = posAttr.getZ(i); + if (z > -40 && z < 170) { + if (Math.abs(x) > 28 || Math.abs(y) > 28) finsMask[i] = 1.0; + } else { + finsMask[i] = 0.0; + } + } + + const colors = new Float32Array(vertexCount * 3); + for (let i = 0; i < vertexCount; i++) { + const z = posAttr.getZ(i); + if (z >= thresholdZ) { + colors[i * 3 + 0] = 1.0; + colors[i * 3 + 1] = 0.051; + colors[i * 3 + 2] = 0.051; + } else if (z < -940) { + colors[i * 3 + 0] = 0.01; + colors[i * 3 + 1] = 0.01; + colors[i * 3 + 2] = 0.01; + } else { + colors[i * 3 + 0] = 0.9; + colors[i * 3 + 1] = 0.9; + colors[i * 3 + 2] = 0.9; + } + + if (finsMask[i] > 0.9) { + colors[i * 3 + 0] = 1.0; + colors[i * 3 + 1] = 0.051; + colors[i * 3 + 2] = 0.051; + } + } + + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + const material = new THREE.MeshStandardMaterial({ + color: 0xfafafa, + side: THREE.DoubleSide, + flatShading: true, + vertexColors: true, + }); + + material.userData.outlineParameters = { + thickness: rocketOutlineThickness, + color: rocketOutlineColor.toArray(), + alpha: rocketOutlineAlpha, + visible: true, + } satisfies OutlineParameters; + + const realRocket = new THREE.Mesh(geometry, material) as unknown as OutlinedMesh; + realRocket.scale.set(0.01, 0.01, 0.005); + realRocket.position.set(0, 0, 0); + + // Apply the model-space base rotation as a quaternion. + // The STL's "up" axis is +Z; we rotate it to Three.js's +Y up-axis + // (-90° around X) so that a unit quaternion (w=1, x=y=z=0) shows the + // rocket pointing straight up — identical to the original behaviour. + const baseQuat = new THREE.Quaternion().setFromAxisAngle( + new THREE.Vector3(1, 0, 0), + -Math.PI / 2, + ); + + // Compose with the incoming orientation quaternion. + const orientQuat = toThreeQuat(w, x, y, z); + realRocket.quaternion.copy(orientQuat.clone().multiply(baseQuat)); + + rocketRef.current = realRocket; + + scene.add(realRocket); + }); + + // Grid setup + const gridXZ = new THREE.GridHelper(GRID_SIZE, GRID_DIVISIONS, 0x000000, 0x000000); + gridXZ.position.y = -GRID_OFFSET; + scene.add(gridXZ); + + const gridYZ = new THREE.GridHelper(GRID_SIZE, GRID_DIVISIONS, 0x000000, 0x000000); + gridYZ.rotation.z = Math.PI / 2; + gridYZ.position.x = -GRID_SIZE / 2; + gridYZ.position.y = -GRID_OFFSET + GRID_SIZE / 2; + scene.add(gridYZ); + + const gridXY = new THREE.GridHelper(GRID_SIZE, GRID_DIVISIONS, 0x000000, 0x000000); + gridXY.rotation.x = Math.PI / 2; + gridXY.position.z = -GRID_SIZE / 2; + gridXY.position.y = -GRID_OFFSET + GRID_SIZE / 2; + scene.add(gridXY); + + // Lighting & camera + camera.position.set(10, 7, 10); + camera.lookAt(0, 0, 0); + + const ambient = new THREE.AmbientLight(0xffffff, ambientLightIntensity); + ambientLightRef.current = ambient; + scene.add(ambient); + + const directional = new THREE.DirectionalLight(0xffffee, 1); + directional.position.set(-20, 10, 100); + scene.add(directional); + + const axesOffset = 0.1; + const axes = new THREE.AxesHelper(GRID_SIZE); + axes.position.set( + -(GRID_SIZE / 2) + axesOffset, + axesOffset - GRID_OFFSET, + -(GRID_SIZE / 2) + axesOffset, + ); + scene.add(axes); + + // Animation loop + let animationFrameId: number; + const animate = () => { + animationFrameId = requestAnimationFrame(animate); + effect.render(scene, camera); + }; + animate(); + + const handleResize = () => { + if (!refContainer.current) return; + const newWidth = refContainer.current.clientWidth; + const newHeight = refContainer.current.clientHeight; + renderer.setSize(newWidth, newHeight); + camera.aspect = newWidth / newHeight; + camera.updateProjectionMatrix(); + }; + window.addEventListener("resize", handleResize); + + return () => { + cancelAnimationFrame(animationFrameId); + window.removeEventListener("resize", handleResize); + if (renderer.domElement.parentNode === container) { + container.removeChild(renderer.domElement); + } + if (rocketRef.current) scene.remove(rocketRef.current); + renderer.dispose(); + }; + // Intentionally run once on mount; bgColor/outline values are re-applied + // reactively in the lightMode effect above rather than recreating the scene. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const rocket = rocketRef.current; + if (!rocket) return; + + // Base rotation: -90° around X so the STL's +Z nose maps to Three.js +Y + const baseQuat = new THREE.Quaternion().setFromAxisAngle( + new THREE.Vector3(1, 0, 0), + -Math.PI / 2, + ); + + const orientQuat = toThreeQuat(w, x, y, z); + + // Apply: first orient in world space, then apply base model correction + rocket.quaternion.copy(orientQuat.clone().multiply(baseQuat)); + }, [w, x, y, z]); + + + return
; +}; + +export default MyThree; diff --git a/src/utils/api.js b/src/utils/api.js deleted file mode 100644 index e93592c..0000000 --- a/src/utils/api.js +++ /dev/null @@ -1,25 +0,0 @@ -import axios from 'axios'; - -const BASE_URL = 'http://127.0.0.1:5000'; -// const BASE_URL = 'http://localhost:5000'; - -export const api = { - // Backend status - checkBackend: () => axios.get(`${BASE_URL}/`), - ping: () => axios.get(`${BASE_URL}/ping`), - - // Board management - connectBoard: (comport) => { - console.log("Connecting to board with comport:", comport); - return axios.post(`${BASE_URL}/connect`, { comport:comport.toString() }) - }, - getComPorts: () => axios.get(`${BASE_URL}/comports`), - getActiveComPort: () => axios.get(`${BASE_URL}/comports/active`), - disconnectBoard: () => axios.get(`${BASE_URL}/disconnect`), - getWirelessInfo: () => axios.get(`${BASE_URL}/wireless-stats`), - - // Sensor data - startDashboardDump: () => axios.post(`${BASE_URL}/dashboard-dump`, { start:true }), - stopDashboardDump: () => axios.post(`${BASE_URL}/dashboard-dump`, { stop:true }), - getSensorData: () => axios.get(`${BASE_URL}/dashboard-dump`), -}; \ No newline at end of file diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 0000000..54e9536 --- /dev/null +++ b/src/utils/api.ts @@ -0,0 +1,69 @@ +import axios, { type AxiosResponse } from "axios"; + +const BASE_URL = "http://127.0.0.1:5000"; +// const BASE_URL = 'http://localhost:5000'; + +export interface ConnectBoardPacket { + controller: { + firmware: string; + name: string; + }; + status: string; +} + +export interface WirelessInfoResponse { + target: string; + firmware: string; +} + +/** Raw per-port description map: { "COM3": "USB Serial Device", ... } */ +export type ComPortsMap = Record; + +/** Raw sensor payload shape coming from the backend dashboard-dump endpoint. */ +export interface RawSensorPacket { + accXconv?: number; + accYconv?: number; + accZconv?: number; + gyroXconv?: number; + gyroYconv?: number; + gyroZconv?: number; + pitchDeg?: number; + pitchRate?: number; + rollDeg?: number; + rollRate?: number; + yawDeg?: number; + yawRate?: number; + pres?: number; + bvelo?: number; + alt?: number; + time?: number; + lat?: number; + long?: number; + temp?: number; +} + +export const api = { + // Backend status + checkBackend: (): Promise> => axios.get(`${BASE_URL}/`), + ping: (): Promise> => axios.get(`${BASE_URL}/ping`), + + // Board management + connectBoard: (comport: string): Promise> => { + console.log("Connecting to board with comport:", comport); + return axios.post(`${BASE_URL}/connect`, { comport: comport.toString() }); + }, + getComPorts: (): Promise> => axios.get(`${BASE_URL}/comports`), + getActiveComPort: (): Promise> => + axios.get(`${BASE_URL}/comports/active`), + disconnectBoard: (): Promise> => axios.get(`${BASE_URL}/disconnect`), + getWirelessInfo: (): Promise> => + axios.get(`${BASE_URL}/wireless-stats`), + + // Sensor data + startDashboardDump: (): Promise> => + axios.post(`${BASE_URL}/dashboard-dump`, { start: true }), + stopDashboardDump: (): Promise> => + axios.post(`${BASE_URL}/dashboard-dump`, { stop: true }), + getSensorData: (): Promise> => + axios.get(`${BASE_URL}/dashboard-dump`), +}; diff --git a/src/utils/mock.js b/src/utils/mock.js deleted file mode 100644 index 53517c8..0000000 --- a/src/utils/mock.js +++ /dev/null @@ -1,23 +0,0 @@ -import Papa from 'papaparse'; - -async function fetchCSV() { - const response = await fetch('/appa_sensor_data.csv'); - const reader = response.body.getReader(); - const result = await reader.read(); - const decoder = new TextDecoder('utf-8'); - const csv = await decoder.decode(result.value); - - - return csv; -} - -export const MockFlight = -{ - - getSensorData: async(row) => { - const data = Papa.parse(await fetchCSV(), { header: true }); - return data.data[row] - - } - -} \ No newline at end of file diff --git a/src/utils/mock.ts b/src/utils/mock.ts new file mode 100644 index 0000000..e55d68c --- /dev/null +++ b/src/utils/mock.ts @@ -0,0 +1,23 @@ +declare module "papaparse"; + +import Papa from "papaparse"; +import type { RawSensorPacket } from "@/utils/api"; + +let cachedCsvText: string | null = null; + +async function fetchCSV(): Promise { + if (cachedCsvText !== null) return cachedCsvText; + + const response = await fetch("/appa_sensor_data.csv"); + const text = await response.text(); + cachedCsvText = text; + return text; +} + +export const MockFlight = { + getSensorData: async (row: number): Promise => { + const csv = await fetchCSV(); + const parsed = Papa.parse(csv, { header: true, dynamicTyping: true }); + return parsed.data[row]; + }, +}; diff --git a/tailwind.config.mjs b/tailwind.config.mjs deleted file mode 100644 index b382ffd..0000000 --- a/tailwind.config.mjs +++ /dev/null @@ -1,29 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - darkMode: 'class', - content: [ - "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", - "./src/components/**/*.{js,ts,jsx,tsx,mdx}", - "./src/app/**/*.{js,ts,jsx,tsx,mdx}", - ], - theme: { - extend: { - colors: { - "base": "var(--base)", - "base-100": "var(--base-100)", - "base-200": "var(--base-200)", - "base-300": "var(--base-300)", - "base-400": "var(--base-400)", - "base-500": "var(--base-500)", - "base-600": "var(--base-600)", - "base-700": "var(--base-700)", - "highlight": "var(--highlight)", - "accent-red": "var(--accent-red)", - "accent-green": "var(--accent-green)", - "accent-yellow": "var(--accent-yellow)", - - }, - }, - }, - plugins: [], -}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cf9c65d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +}