diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d5f128903..72f938988 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -31,7 +31,7 @@ If applicable, add screenshots to help explain your problem. - What version of ArduPilot is running on the flight controller? **Error Log** -Please share the error's found in the console tab of inspect element (found by doing `ctrl + shift + i` and clicking the `console` tab). Either a screenshot or a copy paste of the error would suffice. If there is no error found in the console tab you can leave this blank. If you are struggling to find the console tab click [here](../../help/how_to_find_error_console.png). +If you received an error message within FGCS, please click on 'Show stack log', then copy and paste it here using the button in the top right. If you don't see the stack log, please share the errors found in the console tab of inspect element (found by doing `ctrl + shift + i` and clicking the `console` tab). Either a screenshot or a copy paste of the error would suffice. If there is no error found in the console tab you can leave this blank. If you are struggling to find the console tab click [here](../../help/how_to_find_error_console.png). **Additional context** Add any other context about the problem here. diff --git a/.github/workflows/windows_build.yml b/.github/workflows/windows_build.yml index 237be515d..e7b01c709 100644 --- a/.github/workflows/windows_build.yml +++ b/.github/workflows/windows_build.yml @@ -35,5 +35,5 @@ jobs: - name: Run build script working-directory: building\windows - run: build.ps1 -Version "${{ github.event.inputs.version }}" + run: ./build.ps1 -Version "${{ github.event.inputs.version }}" diff --git a/.vscode/settings.json b/.vscode/settings.json index 7229cda57..519424e1f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,6 +13,7 @@ "RSSI", "serialutil", "SITL", - "statustext" + "statustext", + "SUAS" ] } diff --git a/gcs/data/default_settings.json b/gcs/data/default_settings.json index fc1ccbcca..9078b4453 100644 --- a/gcs/data/default_settings.json +++ b/gcs/data/default_settings.json @@ -1,34 +1,41 @@ { - "General": { - "maptilerAPIKey": { - "default": "", - "type": "string", - "display": "Maptiler API Key", - "hidden": true - }, - "port": { - "default": "4237", - "type": "number", - "range": [ - 0, - 65535 - ], - "display": "Backend Port" - }, - "interfaceLanguage": { - "default": "English", - "type": "option", - "options": ["English"], - "display": "Interface Language" - }, - "autoCheckForUpdates": { - "default": true, - "type": "boolean", - "display": "Automatically Check for Updates" - } + "General": { + "maptilerAPIKey": { + "default": "", + "type": "string", + "display": "Maptiler API Key", + "hidden": true }, - "Params": {}, - "Config": {}, - "FGCS": { + "mapStyle": { + "default": "hybrid", + "type": "option", + "options": ["satellite", "hybrid", "streets", "outdoor"], + "display": "Map Style" + }, + "port": { + "default": "4237", + "type": "number", + "range": [0, 65535], + "display": "Backend Port" + }, + "interfaceLanguage": { + "default": "English", + "type": "option", + "options": ["English"], + "display": "Interface Language" + }, + "autoCheckForUpdates": { + "default": true, + "type": "boolean", + "display": "Automatically Check for Updates" + }, + "syncMapViews": { + "default": false, + "type": "boolean", + "display": "Sync Dashboard and Missions Map Viewstate" } + }, + "Params": {}, + "Config": {}, + "FGCS": {} } diff --git a/gcs/electron/main.ts b/gcs/electron/main.ts index ff62dfa3a..b2cd179b1 100644 --- a/gcs/electron/main.ts +++ b/gcs/electron/main.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, Menu, MenuItemConstructorOptions, MessageBoxOptions, app, dialog, ipcMain, nativeImage, shell } from 'electron' +import { BrowserWindow, Event, Menu, MenuItemConstructorOptions, MessageBoxOptions, Rectangle, app, dialog, ipcMain, nativeImage, shell } from 'electron' import { ChildProcessWithoutNullStreams, spawn, spawnSync } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' @@ -26,6 +26,7 @@ app.commandLine.appendSwitch('force-device-scale-factor', '1') let win: BrowserWindow | null let loadingWin: BrowserWindow | null +let webcamPopoutWin: BrowserWindow | null // 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'] @@ -35,6 +36,8 @@ function getWindow() { return BrowserWindow.getFocusedWindow() } +// Settings logic + interface Settings { version: string, settings: object @@ -98,10 +101,68 @@ function getUserConfiguration(){ ipcMain.handle("getSettings", () => {return getUserConfiguration(); }) ipcMain.handle("setSettings", (_, settings) => {saveUserConfiguration(settings)}) +// Webcam popout window + +const MIN_WEBCAM_HEIGHT: number = 100 +const WEBCAM_TITLEBAR_HEIGHT: number = 28 + +// Disable unused vars because they are needed for TS function type +// eslint-disable-next-line no-unused-vars +type ResizeCallback = (event: Event, arg1: Rectangle) => void; + +let currentResizeHandler: ResizeCallback | null = null + +function openWebcamPopout(videoStreamId: string, name: string, aspect: number){ + + if (webcamPopoutWin === null) return; + + webcamPopoutWin.loadURL("http://localhost:5173/#/webcam?deviceId=" + videoStreamId + "&deviceName=" + name); + webcamPopoutWin.setTitle(name); + + // Remove previous resize handler + if (currentResizeHandler) + webcamPopoutWin.off("will-resize", currentResizeHandler) + + // Create resize handler to maintain aspect ratio + currentResizeHandler = function(event, newBounds){ + event.preventDefault(); + + const newWidth = newBounds.width; + const newHeight = Math.round((newWidth / aspect) + WEBCAM_TITLEBAR_HEIGHT); + + webcamPopoutWin?.setBounds({ + x: newBounds.x, + y: newBounds.y, + width: newWidth, + height: newHeight + }); + } + + webcamPopoutWin.on('will-resize', currentResizeHandler); + + // Ensure initial size fits the aspect ratio () + webcamPopoutWin.setSize(webcamPopoutWin.getBounds().width, Math.round(webcamPopoutWin.getBounds().width / aspect) + WEBCAM_TITLEBAR_HEIGHT); + + webcamPopoutWin.setMinimumSize(Math.round(aspect * (MIN_WEBCAM_HEIGHT-28)), MIN_WEBCAM_HEIGHT); + webcamPopoutWin.show(); + +} + +function closeWebcamPopout(){ + webcamPopoutWin?.hide() + webcamPopoutWin?.loadURL("http://localhost:5173/#/webcam") + win?.webContents.send("webcam-closed"); +} + +ipcMain.handle("openWebcamWindow", (_, videoStreamId, name, aspect) => {openWebcamPopout(videoStreamId, name, aspect)}) +ipcMain.handle("closeWebcamWindow", () => closeWebcamPopout()) + + ipcMain.handle("isMac", () => { return process.platform == "darwin" }) ipcMain.on('close', () => {closeWithBackend()}) ipcMain.on('minimise', () => {getWindow()?.minimize()}) ipcMain.on('maximise', () => {getWindow()?.isMaximized() ? getWindow()?.unmaximize() : getWindow()?.maximize()}) + ipcMain.on("reload", () => {getWindow()?.reload()}) ipcMain.on("force_reload", () => {getWindow()?.webContents.reloadIgnoringCache()}) ipcMain.on("toggle_developer_tools", () => {getWindow()?.webContents.toggleDevTools()}) @@ -132,6 +193,25 @@ function createWindow() { frame: false, }) + // Create webcam window keep it hidden to avoid delay between popping out windows + webcamPopoutWin = new BrowserWindow({ + width: 400, + height: 300, + frame: false, + alwaysOnTop: true, + icon: path.join(process.env.VITE_PUBLIC, 'app_icon.ico'), + show: false, + title: "Webcam", + webPreferences: { + nodeIntegration: true, + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true + }, + fullscreen: false, + fullscreenable: false, + }); + webcamPopoutWin.loadURL("http://localhost:5173/#/webcam") + // Open links in browser, not within the electron window. // Note, links must have target="_blank" win.webContents.setWindowOpenHandler((details) => { @@ -287,6 +367,8 @@ function closeWithBackend() { if (process.platform !== 'darwin') { app.quit() win = null + webcamPopoutWin?.close() + webcamPopoutWin = null } console.log('Killing backend') @@ -306,6 +388,7 @@ app.on('before-quit', () => { spawnSync('pkill', ['-f', 'fgcs_backend']); pythonBackend = null } + webcamPopoutWin?.close(); }); app.on('activate', () => { diff --git a/gcs/electron/preload.js b/gcs/electron/preload.js index 111560580..c52ab7d36 100644 --- a/gcs/electron/preload.js +++ b/gcs/electron/preload.js @@ -9,7 +9,10 @@ contextBridge.exposeInMainWorld('ipcRenderer', { getNodeEnv: () => ipcRenderer.invoke('app:get-node-env'), getVersion: () => ipcRenderer.invoke('app:get-version'), getSettings: () => ipcRenderer.invoke('getSettings'), - saveSettings: (settings) => ipcRenderer.invoke('setSettings', settings) + saveSettings: (settings) => ipcRenderer.invoke('setSettings', settings), + openWebcamWindow: (id, name, aspect) => ipcRenderer.invoke("openWebcamWindow", id, name, aspect), + closeWebcamWindow: () => ipcRenderer.invoke('closeWebcamWindow'), + onCameraWindowClose: (callback) => ipcRenderer.on("webcam-closed", () => callback()) }) // `exposeInMainWorld` can't detect attributes and methods of `prototype`, manually patching it. diff --git a/gcs/package.json b/gcs/package.json index 76f94dfab..ae71c4c11 100644 --- a/gcs/package.json +++ b/gcs/package.json @@ -6,7 +6,7 @@ "name": "Avis-Drone-Labs" }, "private": true, - "version": "0.1.8-alpha", + "version": "0.1.9-alpha", "license": "GPL-3.0-only", "homepage": "https://fgcs.projectfalcon.uk", "bugs": { @@ -26,14 +26,20 @@ }, "dependencies": { "@headlessui/react": "2.1.4", - "@mantine/core": "^7.3.2", - "@mantine/hooks": "^7.3.2", + "@mantine/code-highlight": "^7.17.1", + "@mantine/core": "^7.17.3", + "@mantine/hooks": "^7.17.3", "@mantine/notifications": "^7.4.0", "@mantine/spotlight": "^7.15.3", + "@mantine/tiptap": "^7.17.4", "@reduxjs/toolkit": "^2.2.7", "@robloche/chartjs-plugin-streaming": "^3.1.0", "@tabler/icons-react": "^2.44.0", "@tailwindcss/container-queries": "^0.1.1", + "@tiptap/extension-link": "^2.11.6", + "@tiptap/pm": "^2.11.6", + "@tiptap/react": "^2.11.6", + "@tiptap/starter-kit": "^2.11.6", "@tremor/react": "^3.12.1", "@turf/turf": "^7.2.0", "chart.js": "^4.4.2", @@ -52,6 +58,7 @@ "react": "^18.2.0", "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", + "react-error-boundary": "^5.0.0", "react-map-gl": "^7.1.6", "react-redux": "^9.1.2", "react-resizable": "^3.0.5", @@ -66,6 +73,7 @@ "tailwind-merge": "^2.1.0", "tailwindcss": "^3.3.6", "use-sound": "^4.0.1", + "uuid": "^11.1.0", "vitest": "^2.0.5" }, "devDependencies": { diff --git a/gcs/src/components/customMantineTheme.jsx b/gcs/src/components/customMantineTheme.jsx index d44efe320..ee32fa6c1 100644 --- a/gcs/src/components/customMantineTheme.jsx +++ b/gcs/src/components/customMantineTheme.jsx @@ -27,4 +27,5 @@ export const CustomMantineTheme = createTheme({ tailwindColors.falcongrey[950], ], }, + cursorType: "pointer", }) diff --git a/gcs/src/components/dashboard/floatingToolbar.jsx b/gcs/src/components/dashboard/floatingToolbar.jsx index c0ae587e9..6bc815a07 100644 --- a/gcs/src/components/dashboard/floatingToolbar.jsx +++ b/gcs/src/components/dashboard/floatingToolbar.jsx @@ -5,6 +5,7 @@ // 3rd Party Imports import { ActionIcon, Tooltip } from "@mantine/core" import { useLocalStorage } from "@mantine/hooks" +import { centerOfMass, polygon } from "@turf/turf" import { IconAnchor, IconAnchorOff, @@ -47,10 +48,17 @@ export default function FloatingToolbar({ ) } - function centerMapOnFirstMissionItem() { + function centerMapOnMission() { if (filteredMissionItems.length > 0) { - let lat = parseFloat(filteredMissionItems[0].x * 1e-7) - let lon = parseFloat(filteredMissionItems[0].y * 1e-7) + let points = filteredMissionItems.map((item) => [ + item.x * 1e-7, + item.y * 1e-7, + ]) + points.push(points[0]) // Close the polygon + let geo = polygon([points]) + let center = centerOfMass(geo).geometry.coordinates + let lat = parseFloat(center[0]) + let lon = parseFloat(center[1]) mapRef.current.getMap().flyTo({ center: [lon, lat], }) @@ -95,7 +103,7 @@ export default function FloatingToolbar({ - {/* Center Map on first mission item */} + {/* Center Map on full mission */} 0 ? "No mission" : "Center on mission" @@ -103,7 +111,7 @@ export default function FloatingToolbar({ > diff --git a/gcs/src/components/dashboard/map.jsx b/gcs/src/components/dashboard/map.jsx index afd615882..a0baff042 100644 --- a/gcs/src/components/dashboard/map.jsx +++ b/gcs/src/components/dashboard/map.jsx @@ -28,23 +28,25 @@ import { showNotification, showSuccessNotification, } from "../../helpers/notification" +import { useSettings } from "../../helpers/settings" import { socket } from "../../helpers/socket" // Other dashboard imports +import ContextMenuItem from "../mapComponents/contextMenuItem" import DrawLineCoordinates from "../mapComponents/drawLineCoordinates" import DroneMarker from "../mapComponents/droneMarker" -import HomeMarker from "../mapComponents/homeMarker" import MarkerPin from "../mapComponents/markerPin" import MissionItems from "../mapComponents/missionItems" -import ContextMenuItem from "./contextMenuItem" -import useContextMenu from "./useContextMenu" +import useContextMenu from "../mapComponents/useContextMenu" // Tailwind styling import resolveConfig from "tailwindcss/resolveConfig" import tailwindConfig from "../../../tailwind.config" -import { useSettings } from "../../helpers/settings" +import HomeMarker from "../mapComponents/homeMarker" const tailwindColors = resolveConfig(tailwindConfig).theme.colors +const coordsFractionDigits = 7 + function MapSectionNonMemo({ passedRef, data, @@ -54,6 +56,7 @@ function MapSectionNonMemo({ homePosition, onDragstart, getFlightMode, + mapId = "dashboard", }) { const [connected] = useSessionStorage({ key: "connectedToDrone", @@ -62,11 +65,27 @@ function MapSectionNonMemo({ const [position, setPosition] = useState(null) const [firstCenteredToDrone, setFirstCenteredToDrone] = useState(false) + const { getSetting } = useSettings() + + // Check if maps should be synchronized (from settings) + const syncMaps = getSetting("General.syncMapViews") || false + + // Use either a shared key or a unique key based on the setting + const viewStateKey = syncMaps + ? "initialViewState" + : `initialViewState_${mapId}` + const [initialViewState, setInitialViewState] = useLocalStorage({ - key: "initialViewState", + key: viewStateKey, defaultValue: { latitude: 53.381655, longitude: -1.481434, zoom: 17 }, getInitialValueInEffect: false, }) + + const [repositionAltitude, setRepositionAltitude] = useLocalStorage({ + key: "repositionAltitude", + defaultValue: 30, + }) + const [filteredMissionItems, setFilteredMissionItems] = useState([]) const contextMenuRef = useRef() @@ -80,19 +99,11 @@ function MapSectionNonMemo({ const [opened, { open, close }] = useDisclosure(false) const clipboard = useClipboard({ timeout: 500 }) - const { getSetting } = useSettings() - - const [repositionAltitude, setRepositionAltitude] = useLocalStorage({ - key: "repositionAltitude", - defaultValue: 30, - }) const [guidedModePinData, setGuidedModePinData] = useSessionStorage({ key: "guidedModePinData", defaultValue: null, }) - const coordsFractionDigits = 7 - useEffect(() => { socket.on("nav_reposition_result", (msg) => { if (!msg.success) { @@ -172,7 +183,7 @@ function MapSectionNonMemo({
)} - {/* Show home position */} - {homePosition !== null && ( - 0 && [ - intToCoord(filteredMissionItems[0].y), - intToCoord(filteredMissionItems[0].x), - ] - } - /> - )} - {/* Show mission geo-fence MARKERS */} @@ -283,6 +280,20 @@ function MapSectionNonMemo({ /> )} + {/* Show home position */} + {homePosition !== null && ( + 0 && [ + intToCoord(filteredMissionItems[0].y), + intToCoord(filteredMissionItems[0].x), + ] + } + /> + )} +
{ + final += "
  • " + element.name + "

  • " + }) + + final += "" + if (set) { + setCheckboxListString(final) + } + + return final + } + + function generateCheckboxList(defaultCheck = false) { + // Go from string to list, does not return + console.log(checkBoxListString) + var final = [] + checkBoxListString + .split("
  • ") + .splice(1) + .map((element) => { + var text = element.split("

    ")[0].trim() + if (text !== "") { + final.push({ + checked: defaultCheck, + name: element.split("

    ")[0].trim(), + }) + } + }) + setCheckboxList(final) + } + + function toggleCheck() { + generateCheckboxList(lastToggleCheck) + setLastToggleCheck(!lastToggleCheck) + } + + function setChecked(name, value) { + var final = [] + checkBoxListString + .split("
  • ") + .splice(1) + .map((element) => { + var elementName = element.split("

    ")[0].trim() + final.push({ + checked: + elementName == name + ? value + : checkBoxList.find((e) => e.name == elementName).checked, + name: elementName, + }) + }) + setCheckboxList(final) + } + + function generateMappedItems() { + return checkBoxList.map((element) => { + return ( + setChecked(element.name, !element.checked)} + /> + ) + }) + } + + useEffect(() => { + setMappedItems(generateMappedItems()) + saveItems(checkBoxList) + }, [checkBoxList]) + + return ( + <> + {/* Checkbox area */} +
    +
    +
    + + toggleCheck()} + > + + + + + setEditCheckListModal(true)} + > + + + +
    + + + setDeleteModal(true)} + > + + + +
    + + {mappedItems} +
    + + {/* Edit mode */} + setEditCheckListModal(false)} + nameSet={[checkListName, setChecklistName, (e) => setName(e)]} + checkListSet={[checkBoxListString, setCheckboxListString]} + generateCheckboxListString={generateCheckboxListString} + generateCheckboxList={generateCheckboxList} + /> + + {/* Generic "are you sure" modal */} + setDeleteModal(false)} + title="Are you sure you want to delete this checklist?" + centered + styles={{ + content: { + borderRadius: "0.5rem", + }, + }} + withCloseButton={false} + > +
    + + +
    +
    + + ) +} diff --git a/gcs/src/components/dashboard/preFlightChecklist/checkListEdit.jsx b/gcs/src/components/dashboard/preFlightChecklist/checkListEdit.jsx new file mode 100644 index 000000000..ec1f97d71 --- /dev/null +++ b/gcs/src/components/dashboard/preFlightChecklist/checkListEdit.jsx @@ -0,0 +1,136 @@ +/* + The modal to edit a checklist +*/ + +// 3rd Party Imports +import { Button, Modal, TextInput } from "@mantine/core" +import { useEditor } from "@tiptap/react" +import BulletList from "@tiptap/extension-bullet-list" +import ListItem from "@tiptap/extension-list-item" +import { RichTextEditor } from "@mantine/tiptap" +import { Node } from "@tiptap/core" + +// Styling imports +import resolveConfig from "tailwindcss/resolveConfig" +import tailwindConfig from "../../../../tailwind.config.js" +const tailwindColors = resolveConfig(tailwindConfig).theme.colors + +export default function EditCheckList({ + opened, + close, + nameSet, + checkListSet, + generateCheckboxListString, + generateCheckboxList, +}) { + const [name, setName, finaliseName] = nameSet // Finalise changes it in the selected accordion (ik annoying...) + const [checkboxList, setCheckboxList] = checkListSet + + const Document = Node.create({ + name: "doc", + topNode: true, + content: "list+", + }) + + const Paragraph = Node.create({ + name: "paragraph", + group: "block", + content: "inline*", + parseHTML() { + return [{ tag: "p" }] + }, + renderHTML({ HTMLAttributes }) { + return ["p", HTMLAttributes, 0] + }, + }) + + const Text = Node.create({ + name: "text", + group: "inline", + }) + + const editor = useEditor({ + extensions: [Document, Text, Paragraph, BulletList, ListItem], + content: checkboxList, + onUpdate: ({ editor }) => { + setCheckboxList(editor.getHTML()) + }, + autofocus: "end", + }) + + return ( + close()} + styles={{ + content: { + borderRadius: "0.5rem", + }, + }} + size={"xl"} + centered + > + { + e.preventDefault() + finaliseName(name) + generateCheckboxList() + close() + }} + > +
    + {/* Inputs */} +

    Name

    + setName(event.currentTarget.value)} + /> + +
    +

    Items

    +

    + Bullet point list of items +

    +
    + + {/* + Going to keep this for future use with code blocks, no need to delete. + + + + + + */} + + + + + {/* Controls */} +
    + + +
    +
    + +
    + ) +} diff --git a/gcs/src/components/dashboard/resizableInfoBox.jsx b/gcs/src/components/dashboard/resizableInfoBox.jsx index 24e2c7b77..a22d34c5c 100644 --- a/gcs/src/components/dashboard/resizableInfoBox.jsx +++ b/gcs/src/components/dashboard/resizableInfoBox.jsx @@ -34,7 +34,7 @@ export default function ResizableInfoBox(props) { }} className="h-full" > -
    +
    {props.children}
    diff --git a/gcs/src/components/dashboard/tabsSection.jsx b/gcs/src/components/dashboard/tabsSection.jsx index 9128515e6..6940e071d 100644 --- a/gcs/src/components/dashboard/tabsSection.jsx +++ b/gcs/src/components/dashboard/tabsSection.jsx @@ -7,10 +7,11 @@ import { Tabs } from "@mantine/core" // Tab Componenents -import CameraTabsSection from "./tabsSectionTabs/CameraTabsSection" +import CameraTabsSection from "./tabsSectionTabs/cameraTabsSection" import ActionTabsSection from "./tabsSectionTabs/actionTabsSection" import MissionTabsSection from "./tabsSectionTabs/missionTabsSection" import DataTabsSection from "./tabsSectionTabs/dataTabsSection" +import PreFlightChecklistTab from "./tabsSectionTabs/preFlightChecklistSection" export default function TabsSection({ connected, @@ -22,16 +23,18 @@ export default function TabsSection({ displayedData, setDisplayedData, }) { - const tabPadding = "pt-6" + const tabPadding = "pt-6 pb-4" return ( - + Data Actions Mission Camera + Pre-Flight Checklist + {/* Data */} + + {/* Pre Flight Checklist */} + ) } diff --git a/gcs/src/components/dashboard/tabsSectionTabs/cameraTabsSection.jsx b/gcs/src/components/dashboard/tabsSectionTabs/cameraTabsSection.jsx index d1a3451fe..3cd449540 100644 --- a/gcs/src/components/dashboard/tabsSectionTabs/cameraTabsSection.jsx +++ b/gcs/src/components/dashboard/tabsSectionTabs/cameraTabsSection.jsx @@ -4,7 +4,7 @@ */ // Native -import { useCallback, useState, useEffect } from "react" +import { useCallback, useState, useEffect, useRef } from "react" // Mantine import { useSessionStorage } from "@mantine/hooks" @@ -12,6 +12,7 @@ import { Tabs, Select } from "@mantine/core" // Helper import Webcam from "react-webcam" +import { IconExternalLink, IconVideoOff } from "@tabler/icons-react" export default function CameraTabsSection({ tabPadding }) { // Camera devices @@ -19,8 +20,17 @@ export default function CameraTabsSection({ tabPadding }) { key: "deviceId", defaultValue: null, }) + + window.ipcRenderer.onCameraWindowClose(() => setPictureInPicture(false)) + + // Ref used to get video capture stream to send to new electron window + const videoRef = useRef(null) const [devices, setDevices] = useState([]) + const [streamLoaded, setStreamLoaded] = useState(false) + const [invalidStream, setInvalidStream] = useState(false) + const [pictureInPicture, setPictureInPicture] = useState(false) + const handleDevices = useCallback( (mediaDevices) => setDevices(mediaDevices.filter(({ kind }) => kind === "videoinput")), @@ -31,9 +41,29 @@ export default function CameraTabsSection({ tabPadding }) { navigator.mediaDevices.enumerateDevices().then(handleDevices) }, [handleDevices]) + function toggleWebcamPopout() { + const streamTrack = videoRef.current.video.srcObject.getTracks()[0] + const streamAspect = + streamTrack.getSettings().width / streamTrack.getSettings().height + + pictureInPicture + ? window.ipcRenderer.closeWebcamWindow() + : window.ipcRenderer.openWebcamWindow( + deviceId, + streamTrack.label, + streamAspect, + ) + setPictureInPicture(!pictureInPicture) + } + + function onStreamLoaded() { + setInvalidStream(false) + setStreamLoaded(true) + } + return ( -
    +
    + updateMissionItemData("command", parseInt(value)) + } + allowDeselect={false} + /> + + + updateMissionItemData("param1", val)} + hideControls + /> + + + updateMissionItemData("param2", val)} + hideControls + /> + + + updateMissionItemData("param3", val)} + hideControls + /> + + + updateMissionItemData("param4", val)} + hideControls + /> + + + updateMissionItemData("x", coordToInt(val))} + hideControls + /> + + + updateMissionItemData("y", coordToInt(val))} + hideControls + /> + + + updateMissionItemData("z", val)} + hideControls + /> + + {getFrameName(missionItemData.frame)} + + ) +} diff --git a/gcs/src/components/missions/missionsMap.jsx b/gcs/src/components/missions/missionsMap.jsx new file mode 100644 index 000000000..523f93331 --- /dev/null +++ b/gcs/src/components/missions/missionsMap.jsx @@ -0,0 +1,339 @@ +/* + The missions map. + + This uses maplibre to load the map, currently (as of 16/03/2025) this needs an internet + connection to load but this will be addressed in later versions of FGCS. Please check + docs/changelogs if this description has not been updated. +*/ + +// Base imports +import React, { useEffect, useRef, useState } from "react" + +// Maplibre and mantine imports +import { + useClipboard, + useLocalStorage, + usePrevious, + useSessionStorage, +} from "@mantine/hooks" +import "maplibre-gl/dist/maplibre-gl.css" +import Map from "react-map-gl/maplibre" + +// Helper scripts +import { intToCoord } from "../../helpers/dataFormatters" +import { filterMissionItems } from "../../helpers/filterMissions" +import { showNotification } from "../../helpers/notification" +import { useSettings } from "../../helpers/settings" + +// Other dashboard imports +import ContextMenuItem from "../mapComponents/contextMenuItem" +import DrawLineCoordinates from "../mapComponents/drawLineCoordinates" +import DroneMarker from "../mapComponents/droneMarker" +import HomeMarker from "../mapComponents/homeMarker" +import MarkerPin from "../mapComponents/markerPin" +import MissionItems from "../mapComponents/missionItems" +import useContextMenu from "../mapComponents/useContextMenu" + +// Tailwind styling +import resolveConfig from "tailwindcss/resolveConfig" +import tailwindConfig from "../../../tailwind.config" +const tailwindColors = resolveConfig(tailwindConfig).theme.colors + +const coordsFractionDigits = 7 + +function MapSectionNonMemo({ + passedRef, + data, + heading, + desiredBearing, + missionItems, + homePosition, + onDragstart, + getFlightMode, + currentTab, + markerDragEndCallback, + rallyDragEndCallback, + mapId = "dashboard", +}) { + const [connected] = useSessionStorage({ + key: "connectedToDrone", + defaultValue: false, + }) + const [guidedModePinData] = useSessionStorage({ + key: "guidedModePinData", + defaultValue: null, + }) + + const [position, setPosition] = useState(null) + const { getSetting } = useSettings() + + // Check if maps should be synchronized (from settings) + const syncMaps = getSetting("General.syncMapViews") || false + + // Use either a shared key or a unique key based on the setting + const viewStateKey = syncMaps + ? "initialViewState" + : `initialViewState_${mapId}` + + const [initialViewState, setInitialViewState] = useLocalStorage({ + key: viewStateKey, + defaultValue: { latitude: 53.381655, longitude: -1.481434, zoom: 17 }, + getInitialValueInEffect: false, + }) + const previousHomePositionValue = usePrevious(homePosition) + + const [missionItemsList, setMissionItemsList] = useState( + missionItems.mission_items, + ) + const [filteredMissionItems, setFilteredMissionItems] = useState([]) + + const contextMenuRef = useRef() + const { clicked, setClicked, points, setPoints } = useContextMenu() + const [ + contextMenuPositionCalculationInfo, + setContextMenuPositionCalculationInfo, + ] = useState() + const [clickedGpsCoords, setClickedGpsCoords] = useState({ lng: 0, lat: 0 }) + + const clipboard = useClipboard({ timeout: 500 }) + + useEffect(() => { + return () => {} + }, [connected]) + + useEffect(() => { + // Check latest data point is valid + if (isNaN(data.lat) || isNaN(data.lon) || data.lon === 0 || data.lat === 0) + return + + // Move drone icon on map + let lat = intToCoord(data.lat) + let lon = intToCoord(data.lon) + setPosition({ latitude: lat, longitude: lon }) + }, [data]) + + useEffect(() => { + setFilteredMissionItems(filterMissionItems(missionItemsList)) + }, [missionItemsList]) + + useEffect(() => { + setMissionItemsList(missionItems.mission_items) + }, [missionItems]) + + useEffect(() => { + if (contextMenuRef.current) { + const contextMenuWidth = Math.round( + contextMenuRef.current.getBoundingClientRect().width, + ) + const contextMenuHeight = Math.round( + contextMenuRef.current.getBoundingClientRect().height, + ) + let x = contextMenuPositionCalculationInfo.clickedPoint.x + let y = contextMenuPositionCalculationInfo.clickedPoint.y + + if ( + contextMenuWidth + contextMenuPositionCalculationInfo.clickedPoint.x > + contextMenuPositionCalculationInfo.canvasSize.width + ) { + x = contextMenuPositionCalculationInfo.clickedPoint.x - contextMenuWidth + } + if ( + contextMenuHeight + contextMenuPositionCalculationInfo.clickedPoint.y > + contextMenuPositionCalculationInfo.canvasSize.height + ) { + y = + contextMenuPositionCalculationInfo.clickedPoint.y - contextMenuHeight + } + + setPoints({ x, y }) + } + }, [contextMenuPositionCalculationInfo]) + + useEffect(() => { + // center map on home point only on first instance of home point being + // received from the drone + if ( + passedRef.current && + homePosition !== null && + previousHomePositionValue === null + ) { + setInitialViewState({ + latitude: intToCoord(homePosition.lat), + longitude: intToCoord(homePosition.lon), + zoom: initialViewState.zoom, + }) + passedRef.current.getMap().flyTo({ + center: [intToCoord(homePosition.lon), intToCoord(homePosition.lat)], + zoom: initialViewState.zoom, + }) + } + }, [homePosition]) + + return ( +
    + + setInitialViewState({ + latitude: newViewState.viewState.latitude, + longitude: newViewState.viewState.longitude, + zoom: newViewState.viewState.zoom, + }) + } + onDragStart={onDragstart} + onContextMenu={(e) => { + e.preventDefault() + setClicked(true) + setClickedGpsCoords(e.lngLat) + setContextMenuPositionCalculationInfo({ + clickedPoint: e.point, + canvasSize: { + height: e.originalEvent.target.clientHeight, + width: e.originalEvent.target.clientWidth, + }, + }) + }} + cursor="default" + > + {/* Show marker on map if the position is set */} + {position !== null && + !isNaN(position?.latitude) && + !isNaN(position?.longitude) && ( + + )} + + + + {/* Show mission geo-fence MARKERS */} + {missionItems.fence_items.map((item, index) => { + return ( + + ) + })} + + {/* Show geo-fence outlines */} + {missionItems.fence_items.length > 0 && ( + [ + intToCoord(item.y), + intToCoord(item.x), + ]), + [ + intToCoord(missionItems.fence_items[0].y), + intToCoord(missionItems.fence_items[0].x), + ], + ]} + colour={tailwindColors.blue[200]} + lineProps={{ "line-dasharray": [2, 2] }} + /> + )} + + {/* Show mission rally point */} + {missionItems.rally_items.map((item, index) => { + return ( + + ) + })} + + {getFlightMode() === "Guided" && guidedModePinData !== null && ( + + )} + + {/* Show home position */} + {homePosition !== null && ( + 0 && [ + intToCoord(filteredMissionItems[0].y), + intToCoord(filteredMissionItems[0].x), + ] + } + /> + )} + + {clicked && ( +
    + { + clipboard.copy( + `${clickedGpsCoords.lat}, ${clickedGpsCoords.lng}`, + ) + showNotification("Copied to clipboard") + }} + > +
    +

    + {clickedGpsCoords.lat.toFixed(coordsFractionDigits)},{" "} + {clickedGpsCoords.lng.toFixed(coordsFractionDigits)} +

    + + + +
    +
    +
    + )} +
    +
    + ) +} + +function propsAreEqual(prev, next) { + return JSON.stringify(prev) === JSON.stringify(next) +} +const MissionsMapSection = React.memo(MapSectionNonMemo, propsAreEqual) + +export default MissionsMapSection diff --git a/gcs/src/components/missions/rallyItemsTable.jsx b/gcs/src/components/missions/rallyItemsTable.jsx new file mode 100644 index 000000000..d1b73a02d --- /dev/null +++ b/gcs/src/components/missions/rallyItemsTable.jsx @@ -0,0 +1,46 @@ +/* + This table displays all the rally items. +*/ +import { Table } from "@mantine/core" +import React from "react" +import RallyItemsTableRow from "./rallyItemsTableRow" + +function RallyItemsTableNonMemo({ rallyItems, updateRallyItem }) { + return ( + + + + + Command + Param 1 + Param 2 + Param 3 + Param 4 + Lat + Long + Alt + Frame + + + + {rallyItems.map((rallyItem, idx) => { + return ( + + ) + })} + +
    + ) +} + +function propsAreEqual(prev, next) { + return JSON.stringify(prev) === JSON.stringify(next) +} +const RallyItemsTable = React.memo(RallyItemsTableNonMemo, propsAreEqual) + +export default RallyItemsTable diff --git a/gcs/src/components/missions/rallyItemsTableRow.jsx b/gcs/src/components/missions/rallyItemsTableRow.jsx new file mode 100644 index 000000000..65ca5d129 --- /dev/null +++ b/gcs/src/components/missions/rallyItemsTableRow.jsx @@ -0,0 +1,90 @@ +/* + This component displays the row for a rally item in a table. +*/ + +import { NumberInput, Select, TableTd, TableTr } from "@mantine/core" +import { useEffect, useState } from "react" +import { coordToInt, intToCoord } from "../../helpers/dataFormatters" +import { MAV_FRAME_LIST } from "../../helpers/mavlinkConstants" +const coordsFractionDigits = 9 + +export default function RallyItemsTableRow({ + index, + rallyItem, + updateRallyItem, +}) { + const [rallyItemData, setRallyItemData] = useState(rallyItem) + + useEffect(() => { + setRallyItemData(rallyItem) + }, [rallyItem]) + + useEffect(() => { + updateRallyItem(rallyItemData) + }, [rallyItemData]) + + function getFrameName(frameId) { + var frameName = MAV_FRAME_LIST[frameId] + + if (frameName.startsWith("MAV_FRAME_")) { + frameName = frameName.replace("MAV_FRAME_", "") + } + + return frameName || "UNKNOWN" + } + + function updateRallyItemData(key, newVal) { + setRallyItemData({ + ...rallyItemData, + [key]: newVal, + }) + } + + return ( + + {index} + +