From 95eb65a26cd55461477217d7e6623eee8d0f3dc6 Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Mon, 23 Feb 2026 20:08:54 +0000 Subject: [PATCH 01/20] I took computer science because I like computers, I dont like computers anynmore --- gcs/.env_sample | 2 - gcs/electron/main.ts | 5 + gcs/electron/modules/graphWindow.ts | 161 ++++++++++ gcs/electron/preload.js | 15 + gcs/graphWindow.html | 12 + .../components/dashboard/resizableInfoBox.jsx | 2 +- .../components/graphWindow/graphWindow.jsx | 24 ++ gcs/src/components/graphs/graphPanel.jsx | 75 ----- gcs/src/components/graphs/messageSelector.jsx | 45 --- gcs/src/components/mainContent.jsx | 2 - gcs/src/components/navbar.jsx | 9 - gcs/src/components/realtimeGraph.jsx | 14 +- gcs/src/components/toolbar/menus/graphs.jsx | 289 ++++++++++++++++++ gcs/src/components/toolbar/toolbar.jsx | 7 + gcs/src/graphWindow.jsx | 113 +++++++ gcs/src/graphs.jsx | 143 --------- gcs/src/redux/middleware/socketMiddleware.js | 81 +++-- gcs/vite.test.config.ts | 2 +- 18 files changed, 691 insertions(+), 310 deletions(-) delete mode 100644 gcs/.env_sample create mode 100644 gcs/electron/modules/graphWindow.ts create mode 100644 gcs/graphWindow.html create mode 100644 gcs/src/components/graphWindow/graphWindow.jsx delete mode 100644 gcs/src/components/graphs/graphPanel.jsx delete mode 100644 gcs/src/components/graphs/messageSelector.jsx create mode 100644 gcs/src/components/toolbar/menus/graphs.jsx create mode 100644 gcs/src/graphWindow.jsx delete mode 100644 gcs/src/graphs.jsx 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..6b8136f1e 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() // 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..1afa7e402 --- /dev/null +++ b/gcs/electron/modules/graphWindow.ts @@ -0,0 +1,161 @@ +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> = {} + +/** 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 +} + +export function openGraphWindow({ graphKey, meta }: OpenArgs) { + console.log("openGraphWindow called", graphKey) + + // 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 + }) + + // 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")) + } + + // When page finishes loading, send init + win.webContents.once("did-finish-load", () => { + // Guard again just in case it got closed mid-load + const alive = getGraphWin(graphKey) + if (!alive) return + alive.webContents.send("app:graph-window:init", { graphKey, meta }) + }) + } else { + // Window already exists: just update title + init payload + try { + win.setTitle(meta?.title ?? "Graph") + win.webContents.send("app:graph-window:init", { graphKey, meta }) + } 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() + }) +} + +export default function registerGraphWindowIPC() { + ipcMain.removeHandler("app:open-graph-window") + ipcMain.removeHandler("app:close-graph-window") + ipcMain.removeHandler("app:update-graph-windows") + + 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 + } + } + }, + ) +} diff --git a/gcs/electron/preload.js b/gcs/electron/preload.js index bd2f1bc42..867372bdf 100644 --- a/gcs/electron/preload.js +++ b/gcs/electron/preload.js @@ -16,6 +16,11 @@ const ALLOWED_INVOKE_CHANNELS = [ "settings:save-settings", "app:open-video-window", "app:close-video-window", + "app:open-graph-window", + "app:close-graph-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 +59,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 +73,8 @@ const ALLOWED_ON_CHANNELS = [ "settings:open", "mavlink-forwarding:open", "app:send-fla-params", + "app:graph-window:init", + "app:send-graph-point", ] contextBridge.exposeInMainWorld("ipcRenderer", { @@ -94,6 +102,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..1fbdcda56 --- /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 ( - {}} + onClick={(e) => { + e.stopPropagation() + if (!disabled) onToggle() + }} + /> + {label} + + +
+ + ) +} + +function SectionHeader({ title }) { + return ( +
+ {title} +
+ ) +} + +function sortKeysAlpha(obj) { + return Object.keys(obj).sort((a, b) => a.localeCompare(b)) +} + +// Build a nice meta payload for the popout window from an id like "ATTITUDE.pitch" +function buildMetaFromId(id) { + if (!id) return null + const [msg, field] = id.split(".") + const desc = mavlinkMsgParams?.[msg]?.[field] ?? "" + return { + id, + msg, + field, + title: `${msg}.${field}`, + description: desc, + // label appears on the graph dataset; keep it readable + label: desc ? `${field} — ${desc}` : field, + } +} + +export default function GraphsMenu(props) { + const dispatch = useDispatch() + + useEffect(() => { + // Empty on load, fixes bug: + dispatch( + setGraphValues({ + graph_a: null, + graph_b: null, + graph_c: null, + graph_d: null, + }), + ) + }, []) + + //Redux graph slots (graph_a..graph_d -> "MSG.FIELD" | null) + const selectedGraphs = useSelector(selectGraphValues) + + const [query, setQuery] = useState("") + + // ordered list from slots (graph_a then b then c then d) + const selectedList = GRAPH_KEYS.map((k) => selectedGraphs?.[k]).filter( + Boolean, + ) + + const selectedCount = selectedList.length + + // Filter groups/fields by query across msg / field / description + const filteredGroups = useMemo(() => { + const q = query.trim().toLowerCase() + const msgs = sortKeysAlpha(mavlinkMsgParams) + + if (!q) { + return msgs.map((msg) => ({ + msg, + fields: sortKeysAlpha(mavlinkMsgParams[msg]), + })) + } + + const out = [] + for (const msg of msgs) { + const msgMatches = msg.toLowerCase().includes(q) + const fields = sortKeysAlpha(mavlinkMsgParams[msg]).filter((field) => { + if (msgMatches) return true + const desc = mavlinkMsgParams[msg][field] ?? "" + return ( + field.toLowerCase().includes(q) || + String(desc).toLowerCase().includes(q) + ) + }) + if (fields.length) out.push({ msg, fields }) + } + return out + }, [query]) + + // Pack ordered list into slots graph_a..d + const packToSlots = (list) => { + const next = { graph_a: null, graph_b: null, graph_c: null, graph_d: null } + for (let i = 0; i < Math.min(list.length, MAX_SELECTED); i++) { + next[GRAPH_KEYS[i]] = list[i] + } + return next + } + + const safeInvoke = (channel, payload) => { + try { + return window.ipcRenderer.invoke(channel, payload) + } catch (err) { + console.error("[IPC invoke failed]", channel, err) + return Promise.resolve(null) + } + } + + const syncWindowsForSlots = (beforeSlots, afterSlots) => { + for (const key of GRAPH_KEYS) { + const before = beforeSlots?.[key] ?? null + const after = afterSlots?.[key] ?? null + + if (after && after !== before) { + const meta = buildMetaFromId(after) + safeInvoke("app:open-graph-window", { graphKey: key, meta }) + continue + } + + if (!after && before) { + safeInvoke("app:close-graph-window", { graphKey: key }) + } + } + } + + const toggle = (id) => { + const beforeSlots = selectedGraphs + + // remove if already selected + if (selectedList.includes(id)) { + const nextList = selectedList.filter((x) => x !== id) + const afterSlots = packToSlots(nextList) + + dispatch(setGraphValues(afterSlots)) + syncWindowsForSlots(beforeSlots, afterSlots) + return + } + + // add if room + if (selectedList.length >= MAX_SELECTED) return + + const nextList = [...selectedList, id] + const afterSlots = packToSlots(nextList) + + dispatch(setGraphValues(afterSlots)) + syncWindowsForSlots(beforeSlots, afterSlots) + } + + return ( + +
{ + e.stopPropagation() + }} + > + {/* Search */} +
+ setQuery(e.target.value)} + onClick={(e) => e.stopPropagation()} + /> +
+ + + + {/* Scrollable list */} +
+ {filteredGroups.length === 0 ? ( +
+ No matches +
+ ) : ( + filteredGroups.map((group, idx) => ( +
+ + + {group.fields.map((field) => { + const id = `${group.msg}.${field}` + const desc = mavlinkMsgParams[group.msg][field] + const checked = selectedList.includes(id) + const disabled = !checked && selectedCount >= MAX_SELECTED + + return ( + toggle(id)} + /> + ) + })} + + {idx < filteredGroups.length - 1 ? : null} +
+ )) + )} +
+ +
+ +
+ + {/* Counter */} +
+ {selectedCount}/{MAX_SELECTED} selected +
+
+
+ ) +} diff --git a/gcs/src/components/toolbar/toolbar.jsx b/gcs/src/components/toolbar/toolbar.jsx index 4b373c846..670af4517 100644 --- a/gcs/src/components/toolbar/toolbar.jsx +++ b/gcs/src/components/toolbar/toolbar.jsx @@ -14,6 +14,7 @@ import { CloseIcon, MaximizeIcon, MinimizeIcon } from "./icons.jsx" import AdvancedMenu from "./menus/advanced.jsx" import FileMenu from "./menus/file.jsx" import ViewMenu from "./menus/view.jsx" +import GraphsMenu from "./menus/graphs.jsx" import ConfirmExitModal from "./confirmExitModal.jsx" // Redux @@ -70,6 +71,12 @@ export default function Toolbar() { setMenusActive={setMenusActive} /> +
+ +
{ + console.log("ipcRenderer exists?", !!window.ipcRenderer) + }, []) + + const graphRef = useRef(null) + + // store current slot safely without causing re-registers + const activeGraphKeyRef = useRef(null) + + const [meta, setMeta] = useState(null) + + useEffect(() => { + if (!window.ipcRenderer) { + console.error("ipcRenderer not available in this window") + return + } + + const handleInit = (_event, payload) => { + if (!payload) return + + console.log("INIT PAYLOAD", payload) + + setMeta(payload) + activeGraphKeyRef.current = payload.graphKey + + // clear data on init + const ds = graphRef.current?.data?.datasets?.[0] + if (ds) { + ds.data = [] + graphRef.current.update("none") + } + } + + const handlePoint = (_event, result) => { + if (!result) return + if (!graphRef.current?.data?.datasets?.[0]) return + + const myKey = activeGraphKeyRef.current + if (!myKey) return + if (result.graphKey !== myKey) return + + const y = Number(result.data?.y) + if (!Number.isFinite(y)) return + + graphRef.current.data.datasets[0].data.push({ x: Date.now(), y }) + graphRef.current.update("none") // use "none" instead of "quiet" + } + + window.ipcRenderer.on("app:graph-window:init", handleInit) + window.ipcRenderer.on("app:send-graph-point", handlePoint) + + // tell main we are ready *after* listeners exist + window.ipcRenderer.send("app:graph-window:ready") + + return () => { + window.ipcRenderer.removeListener("app:graph-window:init", handleInit) + window.ipcRenderer.removeListener("app:send-graph-point", handlePoint) + } + }, []) + + const graphKey = meta?.graphKey + const m = meta?.meta + + const title = m?.title ?? "Graph" + const desc = m?.description ?? "" + const label = m?.label ?? m?.id ?? "Graph" + const lineColor = graphKey ? SLOT_COLORS[graphKey] : tailwindColors.sky[400] + + return ( +
+
{title}
+ + {desc ? ( +
{desc}
+ ) : null} + +
+ +
+
+ ) +} + +ReactDOM.createRoot(document.getElementById("root")).render( + + + , +) diff --git a/gcs/src/graphs.jsx b/gcs/src/graphs.jsx deleted file mode 100644 index 16d5eff49..000000000 --- a/gcs/src/graphs.jsx +++ /dev/null @@ -1,143 +0,0 @@ -/* - Live graph data screen. - - This shows 4 different graphs following the live data chosen from the user. These graphs can change size will update in real time as new messages are sent to the GCS. -*/ - -// Base imports -import { useEffect, useRef } from "react" - -// 3rd Party Imports -import { usePrevious } from "@mantine/hooks" - -// Custom components and helpers -import GraphPanel from "./components/graphs/graphPanel.jsx" -import MessageSelector from "./components/graphs/messageSelector.jsx" -import Layout from "./components/layout" -import NoDroneConnected from "./components/noDroneConnected.jsx" -import { graphOptions } from "./helpers/realTimeGraphOptions.js" - -// Redux -import { useDispatch, useSelector } from "react-redux" -import { selectConnectedToDrone } from "./redux/slices/droneConnectionSlice.js" -import { - selectGraphValues, - selectLastGraphMessage, - setGraphValues, -} from "./redux/slices/droneInfoSlice.js" - -// Styling imports -import resolveConfig from "tailwindcss/resolveConfig" -import tailwindConfig from "../tailwind.config.js" -const tailwindColors = resolveConfig(tailwindConfig).theme.colors - -const graphLabelColors = { - graph_a: "text-sky-400", - graph_b: "text-pink-400", - graph_c: "text-orange-400", - graph_d: "text-green-400", -} - -const graphColors = { - graph_a: tailwindColors.sky[400], - graph_b: tailwindColors.pink[400], - graph_c: tailwindColors.orange[400], - graph_d: tailwindColors.green[400], -} - -export default function Graphs() { - const dispatch = useDispatch() - const connected = useSelector(selectConnectedToDrone) - const selectValues = useSelector(selectGraphValues) - const lastGraphMessage = useSelector(selectLastGraphMessage) - - const previousSelectValues = usePrevious(selectValues) - - const graphRefs = { - graph_a: useRef(null), - graph_b: useRef(null), - graph_c: useRef(null), - graph_d: useRef(null), - } - - useEffect(() => { - if (lastGraphMessage !== false) { - lastGraphMessage.forEach((graphResult) => { - graphRefs[graphResult.graphKey]?.current.data.datasets[0].data.push( - graphResult.data, - ) - graphRefs[graphResult.graphKey]?.current.update("quiet") - }) - } - }, [lastGraphMessage]) - - useEffect(() => { - if (!previousSelectValues) return - - for (let graphKey in selectValues) { - if ( - graphRefs[graphKey].current !== null && - selectValues[graphKey] !== previousSelectValues[graphKey] && - selectValues[graphKey] !== null - ) { - graphRefs[graphKey].current.data.datasets[0].data = [] - graphRefs[graphKey].current.update("quiet") - } - } - }, [previousSelectValues]) - - function updateSelectValues(values) { - const updatedSelectValues = { ...selectValues, ...values } - dispatch(setGraphValues(updatedSelectValues)) - } - - return ( - - {connected ? ( -
-
- - - - -
- -
- ) : ( - - )} -
- ) -} diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js index 6a58903ec..db411ae2b 100644 --- a/gcs/src/redux/middleware/socketMiddleware.js +++ b/gcs/src/redux/middleware/socketMiddleware.js @@ -471,39 +471,66 @@ const socketMiddleware = (store) => { } // Handle graph messages - // Function to get the graph data from a message function getGraphDataFromMessage(msg, targetMessageKey) { const returnDataArray = [] - for (let graphKey in storeState.droneInfo.graphs.selectedGraphs) { - const messageKey = - storeState.droneInfo.graphs.selectedGraphs[graphKey] - if (messageKey && messageKey.includes(targetMessageKey)) { - const [, valueName] = messageKey.split(".") - - // Applying Data Formatters - let formatted_value = msg[valueName] - if (messageKey in dataFormatters) { - formatted_value = dataFormatters[messageKey]( - msg[valueName].toFixed(3), - ) - } - returnDataArray.push({ - data: { x: Date.now(), y: formatted_value }, - graphKey: graphKey, - }) + // SAFETY: if storeState or selectedGraphs missing, bail cleanly + const selectedGraphs = storeState?.droneInfo?.graphs?.selectedGraphs + if (!selectedGraphs) return false + + for (const graphKey in selectedGraphs) { + const messageKey = selectedGraphs[graphKey] // e.g. "ATTITUDE.yaw" + if (!messageKey) continue + + // Safer than `includes` (prevents weird accidental matches) + if (!messageKey.startsWith(`${targetMessageKey}.`)) continue + + const [, valueName] = messageKey.split(".") + const raw = msg?.[valueName] + + // SAFETY: skip if value isn't present + if (raw === undefined || raw === null) continue + + // Coerce to number if possible + const rawNum = Number(raw) + if (!Number.isFinite(rawNum)) continue + + let formatted_value = rawNum + + // Applying Data Formatters (guarded) + if (messageKey in dataFormatters) { + // formatter expects a string in your original code + formatted_value = dataFormatters[messageKey](rawNum.toFixed(3)) } + + returnDataArray.push({ + data: { x: Date.now(), y: formatted_value }, + graphKey, + }) } - if (returnDataArray.length) { - return returnDataArray - } - return false + + return returnDataArray.length ? returnDataArray : false + } + + let graphData = false + try { + graphData = getGraphDataFromMessage(msg, msg.mavpackettype) + } catch (e) { + console.error("Graph extraction crashed:", e, { + mavpackettype: msg?.mavpackettype, + }) + graphData = false + } + + // keep existing tab behaviour + store.dispatch(setLastGraphMessage(graphData)) + + // forward to any open graph windows + if (graphData) { + window.ipcRenderer + .invoke("app:update-graph-windows", graphData) + .catch((e) => console.error("update-graph-windows failed:", e)) } - store.dispatch( - setLastGraphMessage( - getGraphDataFromMessage(msg, msg.mavpackettype), - ), - ) // Handle Flight Mode incoming data if ( diff --git a/gcs/vite.test.config.ts b/gcs/vite.test.config.ts index 0c5b49377..18877b1b8 100644 --- a/gcs/vite.test.config.ts +++ b/gcs/vite.test.config.ts @@ -8,4 +8,4 @@ export default defineConfig({ plugins: [ react(), ], -}) \ No newline at end of file +}) From 28b4f1a36d19e87593840d3b4df09be89a2236ef Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Mon, 23 Feb 2026 22:53:31 +0000 Subject: [PATCH 02/20] github comments --- .gitattributes | 2 +- CONTRIBUTING.md | 14 +++++++------- gcs/electron/modules/graphWindow.ts | 1 - gcs/electron/preload.js | 2 -- gcs/src/components/realtimeGraph.jsx | 2 +- gcs/src/components/toolbar/menus/graphs.jsx | 6 ++---- gcs/src/graphWindow.jsx | 2 -- radio/app/controllers/paramsController.py | 12 ++++++------ run.bash | 4 ++-- run_docker.bash | 2 +- 10 files changed, 20 insertions(+), 27 deletions(-) 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/electron/modules/graphWindow.ts b/gcs/electron/modules/graphWindow.ts index 1afa7e402..6458670fc 100644 --- a/gcs/electron/modules/graphWindow.ts +++ b/gcs/electron/modules/graphWindow.ts @@ -49,7 +49,6 @@ function getGraphWin(graphKey: GraphKey): BrowserWindow | null { } export function openGraphWindow({ graphKey, meta }: OpenArgs) { - console.log("openGraphWindow called", graphKey) // Reuse existing window if it's alive let win = getGraphWin(graphKey) diff --git a/gcs/electron/preload.js b/gcs/electron/preload.js index 867372bdf..fb434a65d 100644 --- a/gcs/electron/preload.js +++ b/gcs/electron/preload.js @@ -18,8 +18,6 @@ const ALLOWED_INVOKE_CHANNELS = [ "app:close-video-window", "app:open-graph-window", "app:close-graph-window", - "app:open-graph-window", - "app:close-graph-window", "app:update-graph-windows", "app:start-rtsp-stream", "app:stop-rtsp-stream", diff --git a/gcs/src/components/realtimeGraph.jsx b/gcs/src/components/realtimeGraph.jsx index 190fb26c6..5be99ae31 100644 --- a/gcs/src/components/realtimeGraph.jsx +++ b/gcs/src/components/realtimeGraph.jsx @@ -98,7 +98,7 @@ const RealtimeGraph = forwardRef(function RealtimeGraph( ds.backgroundColor = hexToRgba(lineColor, 0.5) ref.current.update("none") - }, [datasetLabel, lineColor]) + }, [datasetLabel, lineColor, ref]) return (

diff --git a/gcs/src/components/toolbar/menus/graphs.jsx b/gcs/src/components/toolbar/menus/graphs.jsx index fbf6de394..20774fece 100644 --- a/gcs/src/components/toolbar/menus/graphs.jsx +++ b/gcs/src/components/toolbar/menus/graphs.jsx @@ -94,7 +94,7 @@ export default function GraphsMenu(props) { graph_d: null, }), ) - }, []) + }, [dispatch]) //Redux graph slots (graph_a..graph_d -> "MSG.FIELD" | null) const selectedGraphs = useSelector(selectGraphValues) @@ -269,9 +269,7 @@ export default function GraphsMenu(props) { dispatch(setGraphValues(cleared)) // close any open windows too for (const key of ["graph_a", "graph_b", "graph_c", "graph_d"]) { - window.ipcRenderer - .invoke("app:close-graph-window", { graphKey: key }) - .catch(console.error) + safeInvoke("app:close-graph-window", { graphKey: key }).catch(console.error) } }} > diff --git a/gcs/src/graphWindow.jsx b/gcs/src/graphWindow.jsx index 75bbbba96..87fda7b0a 100644 --- a/gcs/src/graphWindow.jsx +++ b/gcs/src/graphWindow.jsx @@ -39,8 +39,6 @@ function GraphWindowApp() { const handleInit = (_event, payload) => { if (!payload) return - console.log("INIT PAYLOAD", payload) - setMeta(payload) activeGraphKeyRef.current = payload.graphKey diff --git a/radio/app/controllers/paramsController.py b/radio/app/controllers/paramsController.py index f00e14f24..dbe7575cd 100644 --- a/radio/app/controllers/paramsController.py +++ b/radio/app/controllers/paramsController.py @@ -217,14 +217,14 @@ def setMultipleParams( } if not done: params_could_not_set.append(param) - progress_update_callback_data["message"] = ( - f"Failed to write {param_id}" - ) + progress_update_callback_data[ + "message" + ] = f"Failed to write {param_id}" else: params_set_successfully.append(param) - progress_update_callback_data["message"] = ( - f"Wrote {param_id} successfully" - ) + progress_update_callback_data[ + "message" + ] = f"Wrote {param_id} successfully" if progress_update_callback: progress_update_callback(progress_update_callback_data) diff --git a/run.bash b/run.bash index a9771429d..1f04f7129 100755 --- a/run.bash +++ b/run.bash @@ -3,7 +3,7 @@ source $1/bin/activate # Check for updates -if [ -z "$2" ]; then +if [ -z "$2" ]; then concurrently "python radio/app.py" "cd gcs && yarn dev" -n "backend,frontend" -c "red,blue" else cd radio @@ -12,4 +12,4 @@ else yarn cd ../ concurrently "python radio/app.py" "cd gcs && yarn dev" -n "backend,frontend" -c "red,blue" -fi \ No newline at end of file +fi diff --git a/run_docker.bash b/run_docker.bash index 2f381bb9a..9e0032a4f 100755 --- a/run_docker.bash +++ b/run_docker.bash @@ -16,4 +16,4 @@ sudo systemctl stop docker.socket # Goodybye message echo "" -echo "Stopped!" \ No newline at end of file +echo "Stopped!" From a62404cd1e65663ad081a559a3c0de0687a8c1f0 Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Mon, 23 Feb 2026 23:08:05 +0000 Subject: [PATCH 03/20] linters again --- gcs/src/graphWindow.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gcs/src/graphWindow.jsx b/gcs/src/graphWindow.jsx index 87fda7b0a..12ab1637c 100644 --- a/gcs/src/graphWindow.jsx +++ b/gcs/src/graphWindow.jsx @@ -18,7 +18,7 @@ const SLOT_COLORS = { graph_d: tailwindColors.green[400], } -function GraphWindowApp() { +export default function GraphWindowApp() { useEffect(() => { console.log("ipcRenderer exists?", !!window.ipcRenderer) }, []) From 325977a74692a6b69a46734b25cc19a9054eafdc Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Mon, 23 Feb 2026 23:11:36 +0000 Subject: [PATCH 04/20] more linters, silly me --- gcs/electron/modules/graphWindow.ts | 1 - gcs/src/components/toolbar/menus/graphs.jsx | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/gcs/electron/modules/graphWindow.ts b/gcs/electron/modules/graphWindow.ts index 6458670fc..a02a941e3 100644 --- a/gcs/electron/modules/graphWindow.ts +++ b/gcs/electron/modules/graphWindow.ts @@ -49,7 +49,6 @@ function getGraphWin(graphKey: GraphKey): BrowserWindow | null { } export function openGraphWindow({ graphKey, meta }: OpenArgs) { - // Reuse existing window if it's alive let win = getGraphWin(graphKey) diff --git a/gcs/src/components/toolbar/menus/graphs.jsx b/gcs/src/components/toolbar/menus/graphs.jsx index 20774fece..3e3e988e1 100644 --- a/gcs/src/components/toolbar/menus/graphs.jsx +++ b/gcs/src/components/toolbar/menus/graphs.jsx @@ -269,7 +269,9 @@ export default function GraphsMenu(props) { dispatch(setGraphValues(cleared)) // close any open windows too for (const key of ["graph_a", "graph_b", "graph_c", "graph_d"]) { - safeInvoke("app:close-graph-window", { graphKey: key }).catch(console.error) + safeInvoke("app:close-graph-window", { graphKey: key }).catch( + console.error, + ) } }} > From 3ab4fe511e1fa2000d9406abf3bd5f6651d0c153 Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Thu, 26 Feb 2026 12:23:41 +0000 Subject: [PATCH 05/20] replaced custom checkbox with mantime checkbox with label prop --- gcs/src/components/toolbar/menus/graphs.jsx | 41 +++++++-------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/gcs/src/components/toolbar/menus/graphs.jsx b/gcs/src/components/toolbar/menus/graphs.jsx index 3e3e988e1..7e33d6f34 100644 --- a/gcs/src/components/toolbar/menus/graphs.jsx +++ b/gcs/src/components/toolbar/menus/graphs.jsx @@ -13,42 +13,27 @@ import { selectGraphValues, setGraphValues, } from "../../../redux/slices/droneInfoSlice" +import { Checkbox } from "@mantine/core" const MAX_SELECTED = 4 const GRAPH_KEYS = ["graph_a", "graph_b", "graph_c", "graph_d"] -function MenuCheckboxItem({ label, title, checked, disabled, onToggle }) { +function MenuCheckboxItem({label, title, checked, disabled, onToggle}) { return (
{ - e.stopPropagation() - if (!disabled) onToggle() - }} + className="flex flex-row w-full gap-x-3 justify-between rounded-md px-3 text-sm" + title={title} > -
- {}} - onClick={(e) => { - e.stopPropagation() - if (!disabled) onToggle() - }} - /> - {label} -
- -
+ onToggle()} + /> + +
) } From 7972fe201431f8cf8d11f45f334d80bd7dbdbaee Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Thu, 26 Feb 2026 12:32:03 +0000 Subject: [PATCH 06/20] fixed an invalid tailwind class name --- gcs/src/components/graphWindow/graphWindow.jsx | 4 ++-- gcs/src/components/toolbar/menus/graphs.jsx | 10 +++++----- gcs/src/components/toolbar/menus/menuItem.jsx | 2 +- gcs/src/graphWindow.jsx | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/gcs/src/components/graphWindow/graphWindow.jsx b/gcs/src/components/graphWindow/graphWindow.jsx index 1fbdcda56..6ece18a16 100644 --- a/gcs/src/components/graphWindow/graphWindow.jsx +++ b/gcs/src/components/graphWindow/graphWindow.jsx @@ -12,10 +12,10 @@ export default function GraphWindow() { return (
{meta?.title ?? "Graph"}
-
+
{meta?.id ?? ""}
-
+
{meta?.description ?? ""}
Graph goes here later.
diff --git a/gcs/src/components/toolbar/menus/graphs.jsx b/gcs/src/components/toolbar/menus/graphs.jsx index 7e33d6f34..5a8f0eb24 100644 --- a/gcs/src/components/toolbar/menus/graphs.jsx +++ b/gcs/src/components/toolbar/menus/graphs.jsx @@ -33,14 +33,14 @@ function MenuCheckboxItem({label, title, checked, disabled, onToggle}) { onChange={() => onToggle()} /> -
+
) } function SectionHeader({ title }) { return ( -
+
{title}
) @@ -207,7 +207,7 @@ export default function GraphsMenu(props) { {/* Scrollable list */}
{filteredGroups.length === 0 ? ( -
+
No matches
) : ( @@ -241,7 +241,7 @@ export default function GraphsMenu(props) {
{/* Counter */} -
+
{selectedCount}/{MAX_SELECTED} selected
diff --git a/gcs/src/components/toolbar/menus/menuItem.jsx b/gcs/src/components/toolbar/menus/menuItem.jsx index 45afad9d7..3fd1f537c 100644 --- a/gcs/src/components/toolbar/menus/menuItem.jsx +++ b/gcs/src/components/toolbar/menus/menuItem.jsx @@ -15,7 +15,7 @@ export default function MenuItem(props) { ) : (
{props.name}
)} -
{props.shortcut}
+
{props.shortcut}
) } diff --git a/gcs/src/graphWindow.jsx b/gcs/src/graphWindow.jsx index 12ab1637c..435563c57 100644 --- a/gcs/src/graphWindow.jsx +++ b/gcs/src/graphWindow.jsx @@ -90,7 +90,7 @@ export default function GraphWindowApp() {
{title}
{desc ? ( -
{desc}
+
{desc}
) : null}
From e406e755237f5c466e1b6b286c0cbd717afe269f Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Thu, 26 Feb 2026 12:36:25 +0000 Subject: [PATCH 07/20] made function name more descriptive --- gcs/src/components/toolbar/menus/graphs.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gcs/src/components/toolbar/menus/graphs.jsx b/gcs/src/components/toolbar/menus/graphs.jsx index 5a8f0eb24..a3745f254 100644 --- a/gcs/src/components/toolbar/menus/graphs.jsx +++ b/gcs/src/components/toolbar/menus/graphs.jsx @@ -46,7 +46,7 @@ function SectionHeader({ title }) { ) } -function sortKeysAlpha(obj) { +function sortKeysAlphabetically(obj) { return Object.keys(obj).sort((a, b) => a.localeCompare(b)) } @@ -96,19 +96,19 @@ export default function GraphsMenu(props) { // Filter groups/fields by query across msg / field / description const filteredGroups = useMemo(() => { const q = query.trim().toLowerCase() - const msgs = sortKeysAlpha(mavlinkMsgParams) + const msgs = sortKeysAlphabetically(mavlinkMsgParams) if (!q) { return msgs.map((msg) => ({ msg, - fields: sortKeysAlpha(mavlinkMsgParams[msg]), + fields: sortKeysAlphabetically(mavlinkMsgParams[msg]), })) } const out = [] for (const msg of msgs) { const msgMatches = msg.toLowerCase().includes(q) - const fields = sortKeysAlpha(mavlinkMsgParams[msg]).filter((field) => { + const fields = sortKeysAlphabetically(mavlinkMsgParams[msg]).filter((field) => { if (msgMatches) return true const desc = mavlinkMsgParams[msg][field] ?? "" return ( From 2689547833d4e7c562547fe0c7231b4babb8adff Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Thu, 26 Feb 2026 12:40:24 +0000 Subject: [PATCH 08/20] clarified comment --- gcs/src/components/toolbar/menus/graphs.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gcs/src/components/toolbar/menus/graphs.jsx b/gcs/src/components/toolbar/menus/graphs.jsx index a3745f254..72b16da7e 100644 --- a/gcs/src/components/toolbar/menus/graphs.jsx +++ b/gcs/src/components/toolbar/menus/graphs.jsx @@ -70,7 +70,7 @@ export default function GraphsMenu(props) { const dispatch = useDispatch() useEffect(() => { - // Empty on load, fixes bug: + // Reset graph selections on mount to prevent old graph selection persisting. dispatch( setGraphValues({ graph_a: null, From 72e2a55b1209be77abb34f3e2fb034292cfd6562 Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Thu, 26 Feb 2026 12:45:45 +0000 Subject: [PATCH 09/20] switched search bar to mantine component --- gcs/src/components/toolbar/menus/graphs.jsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/gcs/src/components/toolbar/menus/graphs.jsx b/gcs/src/components/toolbar/menus/graphs.jsx index 72b16da7e..ab9fe0ac3 100644 --- a/gcs/src/components/toolbar/menus/graphs.jsx +++ b/gcs/src/components/toolbar/menus/graphs.jsx @@ -14,6 +14,7 @@ import { setGraphValues, } from "../../../redux/slices/droneInfoSlice" import { Checkbox } from "@mantine/core" +import { TextInput } from "@mantine/core" const MAX_SELECTED = 4 @@ -193,12 +194,12 @@ export default function GraphsMenu(props) { > {/* Search */}
- setQuery(e.target.value)} + onChange={(e) => setQuery(e.currentTarget.value)} onClick={(e) => e.stopPropagation()} + size="xs" />
From 0384de9a2d35b7f9dc1eb1b1d1ca9187677b6473 Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Thu, 26 Feb 2026 12:47:23 +0000 Subject: [PATCH 10/20] kush change --- gcs/src/components/toolbar/menus/graphs.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gcs/src/components/toolbar/menus/graphs.jsx b/gcs/src/components/toolbar/menus/graphs.jsx index ab9fe0ac3..717e8707a 100644 --- a/gcs/src/components/toolbar/menus/graphs.jsx +++ b/gcs/src/components/toolbar/menus/graphs.jsx @@ -234,7 +234,7 @@ export default function GraphsMenu(props) { ) })} - {idx < filteredGroups.length - 1 ? : null} + {idx < filteredGroups.length - 1 && }
)) )} From d9f33bcf469caad000572e64d2c756ab53c4a456 Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Thu, 26 Feb 2026 12:50:52 +0000 Subject: [PATCH 11/20] changed button to mantine component and tidied up mantine imports --- gcs/src/components/toolbar/menus/graphs.jsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/gcs/src/components/toolbar/menus/graphs.jsx b/gcs/src/components/toolbar/menus/graphs.jsx index 717e8707a..b18576c72 100644 --- a/gcs/src/components/toolbar/menus/graphs.jsx +++ b/gcs/src/components/toolbar/menus/graphs.jsx @@ -13,8 +13,9 @@ import { selectGraphValues, setGraphValues, } from "../../../redux/slices/droneInfoSlice" -import { Checkbox } from "@mantine/core" -import { TextInput } from "@mantine/core" + +import { Button, TextInput, Checkbox } from "@mantine/core" + const MAX_SELECTED = 4 @@ -241,8 +242,9 @@ export default function GraphsMenu(props) {
- +
{/* Counter */} From 5d96d6ada4133dcb4a1cd2d73a9768b8904b6a8a Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Thu, 26 Feb 2026 19:58:47 +0000 Subject: [PATCH 12/20] fix popout electron window resizing to match graph size --- gcs/src/components/realtimeGraph.jsx | 6 ++++-- gcs/src/graphWindow.jsx | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/gcs/src/components/realtimeGraph.jsx b/gcs/src/components/realtimeGraph.jsx index 5be99ae31..e6baf2039 100644 --- a/gcs/src/components/realtimeGraph.jsx +++ b/gcs/src/components/realtimeGraph.jsx @@ -101,8 +101,10 @@ const RealtimeGraph = forwardRef(function RealtimeGraph( }, [datasetLabel, lineColor, ref]) return ( -
- +
+
+ +
) }) diff --git a/gcs/src/graphWindow.jsx b/gcs/src/graphWindow.jsx index 435563c57..dd79c68fc 100644 --- a/gcs/src/graphWindow.jsx +++ b/gcs/src/graphWindow.jsx @@ -86,14 +86,15 @@ export default function GraphWindowApp() { const lineColor = graphKey ? SLOT_COLORS[graphKey] : tailwindColors.sky[400] return ( -
+
{title}
{desc ? ( -
{desc}
+
{desc}
) : null} -
+ {/* Min-h-0 prevents overflow sizing messing up */} +
Date: Thu, 26 Feb 2026 20:29:36 +0000 Subject: [PATCH 13/20] fixed checbox so that it 'unticks' when graph popout is closed manually --- gcs/electron/main.ts | 2 +- gcs/electron/modules/graphWindow.ts | 23 ++++++++++++- gcs/electron/preload.js | 1 + gcs/src/components/toolbar/toolbar.jsx | 46 +++++++++++++++++++++++++- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/gcs/electron/main.ts b/gcs/electron/main.ts index 6b8136f1e..5049f94ae 100644 --- a/gcs/electron/main.ts +++ b/gcs/electron/main.ts @@ -280,7 +280,7 @@ function createWindow() { registerFFmpegBinaryIPC() registerRTSPStreamIPC(win) registerFlaParamsIPC() - registerGraphWindowIPC() + registerGraphWindowIPC(win) // Open links in browser, not within the electron window. // Note, links must have target="_blank" diff --git a/gcs/electron/modules/graphWindow.ts b/gcs/electron/modules/graphWindow.ts index a02a941e3..54f5680f3 100644 --- a/gcs/electron/modules/graphWindow.ts +++ b/gcs/electron/modules/graphWindow.ts @@ -30,6 +30,9 @@ type GraphPoint = { const graphWins: 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] @@ -48,6 +51,17 @@ function getGraphWin(graphKey: GraphKey): BrowserWindow | 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 + } +} + export function openGraphWindow({ graphKey, meta }: OpenArgs) { // Reuse existing window if it's alive let win = getGraphWin(graphKey) @@ -75,6 +89,8 @@ export function openGraphWindow({ graphKey, meta }: OpenArgs) { // IMPORTANT: clean up reference when user closes window win.on("closed", () => { graphWins[graphKey] = undefined + // Tell the main window so Redux can untick the checkbox + notifyMainGraphClosed(graphKey) }) // Load content only on first creation @@ -125,7 +141,10 @@ export function destroyAllGraphWindows() { }) } -export default function registerGraphWindowIPC() { +// 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") @@ -152,6 +171,8 @@ export default function registerGraphWindowIPC() { } catch { // If it errors during close, drop it graphWins[result.graphKey] = undefined + // 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 fb434a65d..3e5b04cb6 100644 --- a/gcs/electron/preload.js +++ b/gcs/electron/preload.js @@ -73,6 +73,7 @@ const ALLOWED_ON_CHANNELS = [ "app:send-fla-params", "app:graph-window:init", "app:send-graph-point", + "app:graph-window:closed" ] contextBridge.exposeInMainWorld("ipcRenderer", { diff --git a/gcs/src/components/toolbar/toolbar.jsx b/gcs/src/components/toolbar/toolbar.jsx index 670af4517..ac10b212f 100644 --- a/gcs/src/components/toolbar/toolbar.jsx +++ b/gcs/src/components/toolbar/toolbar.jsx @@ -6,7 +6,7 @@ */ // Native Imports -import { useEffect, useState } from "react" +import { useEffect, useState, useRef } from "react" // Custom Imports import SpotlightComponent from "../spotlight/spotlight.jsx" @@ -21,6 +21,7 @@ import ConfirmExitModal from "./confirmExitModal.jsx" import { useDispatch, useSelector } from "react-redux" import { selectConnectedToDrone } from "../../redux/slices/droneConnectionSlice.js" import { setConfirmExitModalOpen } from "../../redux/slices/applicationSlice.js" +import { selectGraphValues, setGraphValues } from "../../redux/slices/droneInfoSlice.js" export default function Toolbar() { const dispatch = useDispatch() @@ -29,12 +30,55 @@ export default function Toolbar() { const connectedToDrone = useSelector(selectConnectedToDrone) + const selectedGraphs = useSelector(selectGraphValues) + const selectedGraphsRef = useRef(selectedGraphs) + + useEffect(() => { + selectedGraphsRef.current = selectedGraphs + }, [selectedGraphs]) + useEffect(() => { window.ipcRenderer.invoke("app:is-mac").then((result) => { setIsMac(result) }) }, []) + const GRAPH_KEYS = ["graph_a", "graph_b", "graph_c", "graph_d"] + + const packToSlots = (list) => { + const next = { graph_a: null, graph_b: null, graph_c: null, graph_d: null } + for (let i = 0; i < Math.min(list.length, GRAPH_KEYS.length); i++) { + next[GRAPH_KEYS[i]] = list[i] + } + return next + } + + useEffect(() => { + if (!window.ipcRenderer) return + + const handleClosed = (_event, payload) => { + const graphKey = payload?.graphKey + if (!graphKey) return + + const current = selectedGraphsRef.current + if (!current) return + + const closedId = current[graphKey] + if (!closedId) return // already unticked / nothing to do + + // Build ordered list from slots, excluding the closed one + const remaining = GRAPH_KEYS.map((k) => current[k]).filter(Boolean) + const nextList = remaining.filter((id) => id !== closedId) + + dispatch(setGraphValues(packToSlots(nextList))) + } + + window.ipcRenderer.on("app:graph-window:closed", handleClosed) + return () => { + window.ipcRenderer.removeListener("app:graph-window:closed", handleClosed) + } + }, [dispatch]) + const onClose = () => { if (connectedToDrone) { dispatch(setConfirmExitModalOpen(true)) From 5fd8827aa4f60564b10ca462a7b7375b2ef16add Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Thu, 26 Feb 2026 20:49:01 +0000 Subject: [PATCH 14/20] added a ready handshake to ensure the graph window init is never missed ie the graph never pops out without plotting anything --- gcs/electron/modules/graphWindow.ts | 42 +++++++++++++++++++++++++---- gcs/src/graphWindow.jsx | 6 ++++- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/gcs/electron/modules/graphWindow.ts b/gcs/electron/modules/graphWindow.ts index 54f5680f3..3593b9430 100644 --- a/gcs/electron/modules/graphWindow.ts +++ b/gcs/electron/modules/graphWindow.ts @@ -29,6 +29,7 @@ type GraphPoint = { } const graphWins: Partial> = {} +const lastMeta: Partial> = {} // Reference to the main FGCS window (the one with Redux + toolbar) let mainWin: BrowserWindow | null = null @@ -62,7 +63,21 @@ function notifyMainGraphClosed(graphKey: GraphKey) { } } +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) @@ -89,6 +104,7 @@ export function openGraphWindow({ graphKey, meta }: OpenArgs) { // 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) }) @@ -100,18 +116,17 @@ export function openGraphWindow({ graphKey, meta }: OpenArgs) { win.loadFile(path.join(process.env.DIST!, "graphWindow.html")) } - // When page finishes loading, send init + // Best-effort init on load (ready-handshake below is the real guarantee) win.webContents.once("did-finish-load", () => { - // Guard again just in case it got closed mid-load const alive = getGraphWin(graphKey) if (!alive) return - alive.webContents.send("app:graph-window:init", { graphKey, meta }) + sendInit(graphKey) }) } else { // Window already exists: just update title + init payload try { win.setTitle(meta?.title ?? "Graph") - win.webContents.send("app:graph-window:init", { graphKey, meta }) + sendInit(graphKey) } catch (e) { // If it threw, treat it as dead and try again once graphWins[graphKey] = undefined @@ -129,7 +144,6 @@ export function openGraphWindow({ graphKey, meta }: OpenArgs) { 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() } @@ -149,6 +163,23 @@ export default function registerGraphWindowIPC(appWin?: BrowserWindow) { 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) }) @@ -171,6 +202,7 @@ export default function registerGraphWindowIPC(appWin?: BrowserWindow) { } 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/src/graphWindow.jsx b/gcs/src/graphWindow.jsx index dd79c68fc..0827de3b3 100644 --- a/gcs/src/graphWindow.jsx +++ b/gcs/src/graphWindow.jsx @@ -23,6 +23,7 @@ export default function GraphWindowApp() { console.log("ipcRenderer exists?", !!window.ipcRenderer) }, []) + const sentReadyRef = useRef(false) const graphRef = useRef(null) // store current slot safely without causing re-registers @@ -69,7 +70,10 @@ export default function GraphWindowApp() { window.ipcRenderer.on("app:send-graph-point", handlePoint) // tell main we are ready *after* listeners exist - window.ipcRenderer.send("app:graph-window:ready") + if (!sentReadyRef.current) { + window.ipcRenderer.send("app:graph-window:ready") + sentReadyRef.current = true + } return () => { window.ipcRenderer.removeListener("app:graph-window:init", handleInit) From 75804affb6be089502d1b175351a4ef044e353cf Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Thu, 26 Feb 2026 21:46:17 +0000 Subject: [PATCH 15/20] linters --- gcs/electron/preload.js | 2 +- gcs/src/components/toolbar/menus/graphs.jsx | 35 +++++++++++---------- gcs/src/components/toolbar/toolbar.jsx | 5 ++- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/gcs/electron/preload.js b/gcs/electron/preload.js index 3e5b04cb6..76430db2c 100644 --- a/gcs/electron/preload.js +++ b/gcs/electron/preload.js @@ -73,7 +73,7 @@ const ALLOWED_ON_CHANNELS = [ "app:send-fla-params", "app:graph-window:init", "app:send-graph-point", - "app:graph-window:closed" + "app:graph-window:closed", ] contextBridge.exposeInMainWorld("ipcRenderer", { diff --git a/gcs/src/components/toolbar/menus/graphs.jsx b/gcs/src/components/toolbar/menus/graphs.jsx index b18576c72..ccbf6b422 100644 --- a/gcs/src/components/toolbar/menus/graphs.jsx +++ b/gcs/src/components/toolbar/menus/graphs.jsx @@ -16,23 +16,22 @@ import { import { Button, TextInput, Checkbox } from "@mantine/core" - const MAX_SELECTED = 4 const GRAPH_KEYS = ["graph_a", "graph_b", "graph_c", "graph_d"] -function MenuCheckboxItem({label, title, checked, disabled, onToggle}) { +function MenuCheckboxItem({ label, title, checked, disabled, onToggle }) { return (
onToggle()} + className="min-w-0" + label={label} + checked={checked} + disabled={disabled} + onChange={() => onToggle()} />
@@ -110,14 +109,16 @@ export default function GraphsMenu(props) { const out = [] for (const msg of msgs) { const msgMatches = msg.toLowerCase().includes(q) - const fields = sortKeysAlphabetically(mavlinkMsgParams[msg]).filter((field) => { - if (msgMatches) return true - const desc = mavlinkMsgParams[msg][field] ?? "" - return ( - field.toLowerCase().includes(q) || - String(desc).toLowerCase().includes(q) - ) - }) + const fields = sortKeysAlphabetically(mavlinkMsgParams[msg]).filter( + (field) => { + if (msgMatches) return true + const desc = mavlinkMsgParams[msg][field] ?? "" + return ( + field.toLowerCase().includes(q) || + String(desc).toLowerCase().includes(q) + ) + }, + ) if (fields.length) out.push({ msg, fields }) } return out diff --git a/gcs/src/components/toolbar/toolbar.jsx b/gcs/src/components/toolbar/toolbar.jsx index ac10b212f..98a9203b1 100644 --- a/gcs/src/components/toolbar/toolbar.jsx +++ b/gcs/src/components/toolbar/toolbar.jsx @@ -21,7 +21,10 @@ import ConfirmExitModal from "./confirmExitModal.jsx" import { useDispatch, useSelector } from "react-redux" import { selectConnectedToDrone } from "../../redux/slices/droneConnectionSlice.js" import { setConfirmExitModalOpen } from "../../redux/slices/applicationSlice.js" -import { selectGraphValues, setGraphValues } from "../../redux/slices/droneInfoSlice.js" +import { + selectGraphValues, + setGraphValues, +} from "../../redux/slices/droneInfoSlice.js" export default function Toolbar() { const dispatch = useDispatch() From ba7e2dd7b8283417b66fe3e254c684dc2a7a0749 Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Fri, 27 Feb 2026 19:12:34 +0000 Subject: [PATCH 16/20] updated streaming duration to 30s --- gcs/src/components/realtimeGraph.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gcs/src/components/realtimeGraph.jsx b/gcs/src/components/realtimeGraph.jsx index e6baf2039..c19597418 100644 --- a/gcs/src/components/realtimeGraph.jsx +++ b/gcs/src/components/realtimeGraph.jsx @@ -33,6 +33,8 @@ function hexToRgba(hex, alpha) { return `rgba(${r},${g},${b},${alpha})` } +const TIME_WINDOW_MS = 30_000 + const options = { responsive: true, maintainAspectRatio: false, @@ -43,7 +45,7 @@ const options = { position: "top", }, streaming: { - duration: 20000, + duration: TIME_WINDOW_MS, frameRate: 30, }, }, From 52ecbb537eb13f3f4f28346a6ba25e4a64d0aee8 Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Fri, 27 Feb 2026 19:27:57 +0000 Subject: [PATCH 17/20] removed use effect --- gcs/src/components/toolbar/menus/graphs.jsx | 27 +++++++-------------- gcs/src/graphWindow.jsx | 6 +---- 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/gcs/src/components/toolbar/menus/graphs.jsx b/gcs/src/components/toolbar/menus/graphs.jsx index ccbf6b422..adda708ea 100644 --- a/gcs/src/components/toolbar/menus/graphs.jsx +++ b/gcs/src/components/toolbar/menus/graphs.jsx @@ -9,15 +9,11 @@ import Divider from "./divider" import { mavlinkMsgParams } from "../../../helpers/mavllinkDataStreams" -import { - selectGraphValues, - setGraphValues, -} from "../../../redux/slices/droneInfoSlice" +import { selectGraphValues, setGraphValues } from "../../../redux/slices/droneInfoSlice" import { Button, TextInput, Checkbox } from "@mantine/core" const MAX_SELECTED = 4 - const GRAPH_KEYS = ["graph_a", "graph_b", "graph_c", "graph_d"] function MenuCheckboxItem({ label, title, checked, disabled, onToggle }) { @@ -33,7 +29,6 @@ function MenuCheckboxItem({ label, title, checked, disabled, onToggle }) { disabled={disabled} onChange={() => onToggle()} /> -
) @@ -82,16 +77,13 @@ export default function GraphsMenu(props) { ) }, [dispatch]) - //Redux graph slots (graph_a..graph_d -> "MSG.FIELD" | null) + // Redux graph slots (graph_a..graph_d -> "MSG.FIELD" | null) const selectedGraphs = useSelector(selectGraphValues) const [query, setQuery] = useState("") // ordered list from slots (graph_a then b then c then d) - const selectedList = GRAPH_KEYS.map((k) => selectedGraphs?.[k]).filter( - Boolean, - ) - + const selectedList = GRAPH_KEYS.map((k) => selectedGraphs?.[k]).filter(Boolean) const selectedCount = selectedList.length // Filter groups/fields by query across msg / field / description @@ -169,6 +161,7 @@ export default function GraphsMenu(props) { dispatch(setGraphValues(afterSlots)) syncWindowsForSlots(beforeSlots, afterSlots) + props.setMenusActive(false) return } @@ -180,6 +173,7 @@ export default function GraphsMenu(props) { dispatch(setGraphValues(afterSlots)) syncWindowsForSlots(beforeSlots, afterSlots) + props.setMenusActive(false) } return ( @@ -188,12 +182,7 @@ export default function GraphsMenu(props) { areMenusActive={props.areMenusActive} setMenusActive={props.setMenusActive} > -
{ - e.stopPropagation() - }} - > +
{/* Search */}
Clear diff --git a/gcs/src/graphWindow.jsx b/gcs/src/graphWindow.jsx index 0827de3b3..fb838ed1f 100644 --- a/gcs/src/graphWindow.jsx +++ b/gcs/src/graphWindow.jsx @@ -19,10 +19,6 @@ const SLOT_COLORS = { } export default function GraphWindowApp() { - useEffect(() => { - console.log("ipcRenderer exists?", !!window.ipcRenderer) - }, []) - const sentReadyRef = useRef(false) const graphRef = useRef(null) @@ -63,7 +59,7 @@ export default function GraphWindowApp() { if (!Number.isFinite(y)) return graphRef.current.data.datasets[0].data.push({ x: Date.now(), y }) - graphRef.current.update("none") // use "none" instead of "quiet" + graphRef.current.update("none") } window.ipcRenderer.on("app:graph-window:init", handleInit) From 634cb2ffe09aa3a861aa1b0a9323ebfa74922b49 Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Fri, 27 Feb 2026 19:31:01 +0000 Subject: [PATCH 18/20] removed title from graph windows --- gcs/src/graphWindow.jsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/gcs/src/graphWindow.jsx b/gcs/src/graphWindow.jsx index fb838ed1f..1c09d78e6 100644 --- a/gcs/src/graphWindow.jsx +++ b/gcs/src/graphWindow.jsx @@ -87,14 +87,7 @@ export default function GraphWindowApp() { return (
-
{title}
- - {desc ? ( -
{desc}
- ) : null} - - {/* Min-h-0 prevents overflow sizing messing up */} -
+
Date: Fri, 27 Feb 2026 19:33:14 +0000 Subject: [PATCH 19/20] linters --- gcs/src/components/toolbar/menus/graphs.jsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/gcs/src/components/toolbar/menus/graphs.jsx b/gcs/src/components/toolbar/menus/graphs.jsx index adda708ea..55e5cbd9d 100644 --- a/gcs/src/components/toolbar/menus/graphs.jsx +++ b/gcs/src/components/toolbar/menus/graphs.jsx @@ -9,7 +9,10 @@ import Divider from "./divider" import { mavlinkMsgParams } from "../../../helpers/mavllinkDataStreams" -import { selectGraphValues, setGraphValues } from "../../../redux/slices/droneInfoSlice" +import { + selectGraphValues, + setGraphValues, +} from "../../../redux/slices/droneInfoSlice" import { Button, TextInput, Checkbox } from "@mantine/core" @@ -83,7 +86,9 @@ export default function GraphsMenu(props) { const [query, setQuery] = useState("") // ordered list from slots (graph_a then b then c then d) - const selectedList = GRAPH_KEYS.map((k) => selectedGraphs?.[k]).filter(Boolean) + const selectedList = GRAPH_KEYS.map((k) => selectedGraphs?.[k]).filter( + Boolean, + ) const selectedCount = selectedList.length // Filter groups/fields by query across msg / field / description From 6021ceef71108f516ca5c1cbf35facfa69c3708c Mon Sep 17 00:00:00 2001 From: Matthew-Dobson Date: Fri, 27 Feb 2026 19:36:24 +0000 Subject: [PATCH 20/20] linters again --- gcs/src/graphWindow.jsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/gcs/src/graphWindow.jsx b/gcs/src/graphWindow.jsx index 1c09d78e6..aec34f9d8 100644 --- a/gcs/src/graphWindow.jsx +++ b/gcs/src/graphWindow.jsx @@ -80,8 +80,6 @@ export default function GraphWindowApp() { const graphKey = meta?.graphKey const m = meta?.meta - const title = m?.title ?? "Graph" - const desc = m?.description ?? "" const label = m?.label ?? m?.id ?? "Graph" const lineColor = graphKey ? SLOT_COLORS[graphKey] : tailwindColors.sky[400]