diff --git a/.gitattributes b/.gitattributes
index 42a998b1f..59525ef62 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1 @@
-sitl_setup/run.sh eol=lf
\ No newline at end of file
+sitl_setup/run.sh eol=lf
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7a9069000..d93b942a8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,6 @@
# Contributing
-This document outlines the full process for creating, working on, and merging tickets within the FGCS repository.
+This document outlines the full process for creating, working on, and merging tickets within the FGCS repository.
All steps can be completed directly through GitHub and VS Code.
---
@@ -35,7 +35,7 @@ You can either:
2. Drag your ticket into the **“In Progress”** column on the board.
-3. Open the ticket again, locate the **Development** section on the right-hand side, and click **“Create a new branch.”**
+3. Open the ticket again, locate the **Development** section on the right-hand side, and click **“Create a new branch.”**
Then click **“Create Branch.”**
@@ -58,10 +58,10 @@ You can commit changes using **VS Code (recommended)** or **Bash**.
### Method 1: VS Code (Recommended)
-1. Open the **Source Control** tab on the left-hand side.
-2. Right-click the files you want to commit and select **“Stage Changes.”**
+1. Open the **Source Control** tab on the left-hand side.
+2. Right-click the files you want to commit and select **“Stage Changes.”**
Confirm the correct files appear under the *Staged Changes* section.
-3. Add a clear commit message describing your changes.
+3. Add a clear commit message describing your changes.
4. Press **Commit**, then click the **arrows in the bottom-left corner** to *Synchronize Changes* (push your commit).
@@ -92,8 +92,8 @@ git push origin
---
### Merging
-Navigate to the ticket again and open a pull request. Wait until the automatic tests are ran and fix any changes suggested by those.
+Navigate to the ticket again and open a pull request. Wait until the automatic tests are ran and fix any changes suggested by those.
-Then, in the top right click reviewers then Copilot (GitHub Pro required). Copilot will then offer a code review, it is strongly suggested you make these changes.
+Then, in the top right click reviewers then Copilot (GitHub Pro required). Copilot will then offer a code review, it is strongly suggested you make these changes.
Finally, request the "Avis Code team" as a reviewer, and one of the official code reviewers will look over your work. If they deem it suitable to merge, you will be cleared to merge; alternatively, changes will be suggested which you will make and then request another review.
diff --git a/gcs/.env_sample b/gcs/.env_sample
deleted file mode 100644
index e23e49d29..000000000
--- a/gcs/.env_sample
+++ /dev/null
@@ -1,2 +0,0 @@
-VITE_MAPTILER_API_KEY=
-VITE_BACKEND_URL=http://127.0.0.1:4237
\ No newline at end of file
diff --git a/gcs/electron/main.ts b/gcs/electron/main.ts
index 4f7f33e89..5049f94ae 100644
--- a/gcs/electron/main.ts
+++ b/gcs/electron/main.ts
@@ -40,6 +40,9 @@ import registerVibeStatusIPC, {
} from "./modules/vibeStatusWindow"
import registerVideoIPC, { destroyVideoWindow } from "./modules/videoWindow"
import { readParamsFile } from "./utils/paramsFile"
+import registerGraphWindowIPC, {
+ destroyAllGraphWindows,
+} from "./modules/graphWindow"
// Check if required data files exist
function checkRequiredDataFiles(): {
@@ -277,6 +280,7 @@ function createWindow() {
registerFFmpegBinaryIPC()
registerRTSPStreamIPC(win)
registerFlaParamsIPC()
+ registerGraphWindowIPC(win)
// Open links in browser, not within the electron window.
// Note, links must have target="_blank"
@@ -465,6 +469,7 @@ function closeWindows() {
destroyVibeStatusWindow()
cleanupAllRTSPStreams()
destroyFlaParamsWindow()
+ destroyAllGraphWindows()
}
// Quit when all windows are closed, except on macOS. There, it's common
diff --git a/gcs/electron/modules/graphWindow.ts b/gcs/electron/modules/graphWindow.ts
new file mode 100644
index 000000000..3593b9430
--- /dev/null
+++ b/gcs/electron/modules/graphWindow.ts
@@ -0,0 +1,212 @@
+import { BrowserWindow, ipcMain } from "electron"
+import path from "path"
+
+const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]
+
+type GraphKey = "graph_a" | "graph_b" | "graph_c" | "graph_d"
+
+type GraphWindowMeta = {
+ id: string
+ msg: string
+ field: string
+ title: string
+ description?: string
+ label?: string
+}
+
+type OpenArgs = {
+ graphKey: GraphKey
+ meta: GraphWindowMeta
+}
+
+type CloseArgs = {
+ graphKey: GraphKey
+}
+
+type GraphPoint = {
+ graphKey: GraphKey
+ data: { x: number; y: number }
+}
+
+const graphWins: Partial> = {}
+const lastMeta: Partial> = {}
+
+// Reference to the main FGCS window (the one with Redux + toolbar)
+let mainWin: BrowserWindow | null = null
+
+/** Get a window if it's still usable; auto-clean if destroyed. */
+function getGraphWin(graphKey: GraphKey): BrowserWindow | null {
+ const win = graphWins[graphKey]
+ if (!win) return null
+
+ if (win.isDestroyed()) {
+ graphWins[graphKey] = undefined
+ return null
+ }
+
+ if (win.webContents.isDestroyed()) {
+ graphWins[graphKey] = undefined
+ return null
+ }
+
+ return win
+}
+
+function notifyMainGraphClosed(graphKey: GraphKey) {
+ try {
+ if (!mainWin) return
+ if (mainWin.isDestroyed()) return
+ if (mainWin.webContents.isDestroyed()) return
+ mainWin.webContents.send("app:graph-window:closed", { graphKey })
+ } catch {
+ // ignore during shutdown / race conditions
+ }
+}
+
+function sendInit(graphKey: GraphKey) {
+ const win = getGraphWin(graphKey)
+ const meta = lastMeta[graphKey]
+ if (!win || !meta) return
+ try {
+ win.webContents.send("app:graph-window:init", { graphKey, meta })
+ } catch {
+ // ignore (window may be closing)
+ }
+}
+
+export function openGraphWindow({ graphKey, meta }: OpenArgs) {
+ // Always cache latest meta for this slot (used by ready-handshake)
+ lastMeta[graphKey] = meta
+
+ // Reuse existing window if it's alive
+ let win = getGraphWin(graphKey)
+
+ if (!win) {
+ win = new BrowserWindow({
+ width: 700,
+ height: 350,
+ frame: true,
+ icon: path.join(process.env.VITE_PUBLIC!, "app_icon.ico"),
+ show: false,
+ title: meta?.title ?? "Graph",
+ webPreferences: {
+ preload: path.join(__dirname, "preload.js"),
+ contextIsolation: true,
+ },
+ fullscreen: false,
+ fullscreenable: false,
+ alwaysOnTop: true,
+ })
+
+ graphWins[graphKey] = win
+ win.setMenuBarVisibility(false)
+
+ // IMPORTANT: clean up reference when user closes window
+ win.on("closed", () => {
+ graphWins[graphKey] = undefined
+ delete lastMeta[graphKey]
+ // Tell the main window so Redux can untick the checkbox
+ notifyMainGraphClosed(graphKey)
+ })
+
+ // Load content only on first creation
+ if (VITE_DEV_SERVER_URL) {
+ win.loadURL(VITE_DEV_SERVER_URL + "graphWindow.html")
+ } else {
+ win.loadFile(path.join(process.env.DIST!, "graphWindow.html"))
+ }
+
+ // Best-effort init on load (ready-handshake below is the real guarantee)
+ win.webContents.once("did-finish-load", () => {
+ const alive = getGraphWin(graphKey)
+ if (!alive) return
+ sendInit(graphKey)
+ })
+ } else {
+ // Window already exists: just update title + init payload
+ try {
+ win.setTitle(meta?.title ?? "Graph")
+ sendInit(graphKey)
+ } catch (e) {
+ // If it threw, treat it as dead and try again once
+ graphWins[graphKey] = undefined
+ return openGraphWindow({ graphKey, meta })
+ }
+ }
+
+ // Show/focus safely
+ if (!win.isDestroyed()) {
+ win.show()
+ win.focus()
+ }
+}
+
+export function closeGraphWindow({ graphKey }: CloseArgs) {
+ const win = getGraphWin(graphKey)
+ if (!win) return
+ // Do NOT set undefined here — let the 'closed' event clean up.
+ win.close()
+}
+
+export function destroyAllGraphWindows() {
+ ;(Object.keys(graphWins) as GraphKey[]).forEach((k) => {
+ const win = getGraphWin(k)
+ win?.close()
+ })
+}
+
+// Accept the main window so we can send close events back to Redux UI
+export default function registerGraphWindowIPC(appWin?: BrowserWindow) {
+ if (appWin) mainWin = appWin
+
+ ipcMain.removeHandler("app:open-graph-window")
+ ipcMain.removeHandler("app:close-graph-window")
+ ipcMain.removeHandler("app:update-graph-windows")
+
+ // READY HANDSHAKE:
+ // popout renderer sends "app:graph-window:ready" after it registers listeners.
+ // When we receive it, we send init using cached meta so init is never missed.
+ ipcMain.removeAllListeners("app:graph-window:ready")
+ ipcMain.on("app:graph-window:ready", (event) => {
+ const senderId = event.sender.id
+
+ const graphKey = (Object.keys(graphWins) as GraphKey[]).find((k) => {
+ const w = getGraphWin(k)
+ return w?.webContents?.id === senderId
+ })
+
+ if (!graphKey) return
+
+ sendInit(graphKey)
+ })
+
+ ipcMain.handle("app:open-graph-window", (_event, args: OpenArgs) => {
+ openGraphWindow(args)
+ })
+
+ ipcMain.handle("app:close-graph-window", (_event, args: CloseArgs) => {
+ closeGraphWindow(args)
+ })
+
+ ipcMain.handle(
+ "app:update-graph-windows",
+ (_event, graphResults: GraphPoint[] | false) => {
+ if (!graphResults) return
+
+ for (const result of graphResults) {
+ const win = getGraphWin(result.graphKey)
+ if (!win) continue
+
+ try {
+ win.webContents.send("app:send-graph-point", result)
+ } catch {
+ // If it errors during close, drop it
+ graphWins[result.graphKey] = undefined
+ delete lastMeta[result.graphKey]
+ // Also tell the main window, in case the close event races
+ notifyMainGraphClosed(result.graphKey)
+ }
+ }
+ },
+ )
+}
diff --git a/gcs/electron/preload.js b/gcs/electron/preload.js
index bd2f1bc42..76430db2c 100644
--- a/gcs/electron/preload.js
+++ b/gcs/electron/preload.js
@@ -16,6 +16,9 @@ const ALLOWED_INVOKE_CHANNELS = [
"settings:save-settings",
"app:open-video-window",
"app:close-video-window",
+ "app:open-graph-window",
+ "app:close-graph-window",
+ "app:update-graph-windows",
"app:start-rtsp-stream",
"app:stop-rtsp-stream",
"app:get-current-stream-url",
@@ -54,6 +57,7 @@ const ALLOWED_SEND_CHANNELS = [
"window:update-title",
// drone state updates (connectedToDrone, isArmed, isFlying)
"app:drone-state",
+ "app:graph-window:ready",
]
const ALLOWED_ON_CHANNELS = [
@@ -67,6 +71,9 @@ const ALLOWED_ON_CHANNELS = [
"settings:open",
"mavlink-forwarding:open",
"app:send-fla-params",
+ "app:graph-window:init",
+ "app:send-graph-point",
+ "app:graph-window:closed",
]
contextBridge.exposeInMainWorld("ipcRenderer", {
@@ -94,6 +101,13 @@ contextBridge.exposeInMainWorld("ipcRenderer", {
throw new Error(`IPC on channel '${channel}' is not allowed`)
},
+ removeListener: (channel, callback) => {
+ if (ALLOWED_ON_CHANNELS.includes(channel)) {
+ return ipcRenderer.removeListener(channel, callback)
+ }
+ throw new Error(`IPC removeListener channel '${channel}' is not allowed`)
+ },
+
// Secure removeAllListeners - only for whitelisted channels
removeAllListeners: (channel) => {
if (ALLOWED_ON_CHANNELS.includes(channel)) {
diff --git a/gcs/graphWindow.html b/gcs/graphWindow.html
new file mode 100644
index 000000000..2059e9716
--- /dev/null
+++ b/gcs/graphWindow.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gcs/src/components/dashboard/resizableInfoBox.jsx b/gcs/src/components/dashboard/resizableInfoBox.jsx
index a22d34c5c..38f2e1bf8 100644
--- a/gcs/src/components/dashboard/resizableInfoBox.jsx
+++ b/gcs/src/components/dashboard/resizableInfoBox.jsx
@@ -1,6 +1,6 @@
/*
Resizable information box. This is the left hand side if the screen that moves and will contain
- both the telemetry information and actions, which are both separate components. These
+ both the telemetry information and actions, which are both separate components. These
components are passed in via the props.children which in this case is from dashboard.jsx
*/
diff --git a/gcs/src/components/graphWindow/graphWindow.jsx b/gcs/src/components/graphWindow/graphWindow.jsx
new file mode 100644
index 000000000..6ece18a16
--- /dev/null
+++ b/gcs/src/components/graphWindow/graphWindow.jsx
@@ -0,0 +1,24 @@
+import { useEffect, useState } from "react"
+
+export default function GraphWindow() {
+ const [meta, setMeta] = useState(null)
+
+ useEffect(() => {
+ window.ipcRenderer.on("app:graph-window:init", (_event, data) => {
+ setMeta(data)
+ })
+ }, [])
+
+ return (
+
+
{meta?.title ?? "Graph"}
+
+ {meta?.id ?? ""}
+
+
+ {meta?.description ?? ""}
+
+
Graph goes here later.
+
+ )
+}
diff --git a/gcs/src/components/graphs/graphPanel.jsx b/gcs/src/components/graphs/graphPanel.jsx
deleted file mode 100644
index c5562ba62..000000000
--- a/gcs/src/components/graphs/graphPanel.jsx
+++ /dev/null
@@ -1,75 +0,0 @@
-/*
- GraphPanel Component
-
- This component is responsible for rendering a group of resizable panels, each containing a RealtimeGraph component.
- The graphs are displayed based on the selected values passed as props. If no value is selected for a graph,
- a message prompts the user to select a value for that graph.
-
- Props:
- - selectValues: An object containing the selected values for each graph.
- - graphRefs: An object containing the refs for each graph.
- - graphColors: An object containing the colors for each graph.
-*/
-
-// 3rd Party Imports
-import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"
-import RealtimeGraph from "../realtimeGraph.jsx"
-import { graphOptions } from "../../helpers/realTimeGraphOptions.js"
-
-// Helper function to extract graph information
-const getGraphInfo = (selectValue) => {
- if (!selectValue) return null
- const [category, value] = selectValue.split(".")
- return {
- value,
- label: `${value} ${graphOptions[category][value]}`,
- }
-}
-
-// GraphPanel component
-export default function GraphPanel({ selectValues, graphRefs, graphColors }) {
- const renderGraph = (graphKey) => {
- const graphInfo = getGraphInfo(selectValues[graphKey])
-
- if (graphInfo) {
- return (
-
- )
- }
-
- return (
-
- Select a value to plot on graph{" "}
- {graphKey.charAt(graphKey.length - 1).toUpperCase()}
-
- )
- }
-
- return (
-
-
-
- {renderGraph("graph_a")}
-
- {renderGraph("graph_b")}
-
-
-
-
-
- {renderGraph("graph_c")}
-
- {renderGraph("graph_d")}
-
-
-
- )
-}
diff --git a/gcs/src/components/graphs/messageSelector.jsx b/gcs/src/components/graphs/messageSelector.jsx
deleted file mode 100644
index a7d36c0cc..000000000
--- a/gcs/src/components/graphs/messageSelector.jsx
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- MessageSelector Component
-
- This component is a custom Select component that allows users to select a message from a list of options.
- The options are grouped by message name and are searchable. The selected value is stored in the currentValues object.
-
- Props:
- - graphOptions: An object containing the available options for each message.
- - label: The label for the Select component.
- - labelColor: The color for the label.
- - valueKey: The key used to store the selected value in the currentValues object.
- - currentValues: An object containing the currently selected values.
- - setValue: A function used to set the selected value.
-*/
-
-// 3rd Party Imports
-import { Select } from "@mantine/core"
-
-export default function MessageSelector({
- graphOptions,
- label,
- labelColor,
- valueKey,
- currentValues,
- setValue,
-}) {
- return (
-