diff --git a/gcs/data/default_settings.json b/gcs/data/default_settings.json index 9078b4453..1c6c1af50 100644 --- a/gcs/data/default_settings.json +++ b/gcs/data/default_settings.json @@ -35,6 +35,18 @@ "display": "Sync Dashboard and Missions Map Viewstate" } }, + "Dashboard": { + "altitudeAlerts": { + "description": "Add as many altitude alerts as you want, they'll show when you fall below the set limit", + "default": [100, 90, 80], + "type": "extendableNumber", + "range": [ + 0, + 100000 + ], + "display": "Altitude Alerts" + } + }, "Params": {}, "Config": {}, "FGCS": {} diff --git a/gcs/src/components/dashboard/alert.jsx b/gcs/src/components/dashboard/alert.jsx new file mode 100644 index 000000000..d0d0571ad --- /dev/null +++ b/gcs/src/components/dashboard/alert.jsx @@ -0,0 +1,52 @@ +import { Alert } from "@mantine/core" +import { IconAlertTriangle } from "@tabler/icons-react" +import { useMemo } from "react" +import { useAlerts } from "./alertProvider" + +export const AlertCategory = { + Altitude: "Altitude", + Speed: "Speed", +} + +export const AlertSeverity = { + Yellow: 1, + Orange: 2, + Red: 3, +} + +const SeverityColor = { + [AlertSeverity.Yellow]: "yellow", + [AlertSeverity.Orange]: "orange", + [AlertSeverity.Red]: "red", +} + +export default function AlertSection() { + const { alerts, dismissAlert } = useAlerts() + + const sortedAlerts = useMemo(() => { + return alerts.toSorted((a1, a2) => a2.severity - a1.severity) + }, [alerts]) + + return ( +
+ {sortedAlerts.map((alert) => ( +
+ } + onClose={() => dismissAlert(alert.category, true)} + > + {alert.jsx} + +
+ ))} +
+ ) +} diff --git a/gcs/src/components/dashboard/alertProvider.jsx b/gcs/src/components/dashboard/alertProvider.jsx new file mode 100644 index 000000000..2fa602d52 --- /dev/null +++ b/gcs/src/components/dashboard/alertProvider.jsx @@ -0,0 +1,53 @@ +/* Alert + * { + * category: AlertCategory, + * severity: AlertSeverity, + * jsx: <> + * } + */ + +import { createContext, useContext, useRef, useState } from "react" + +const AlertContext = createContext() + +export default function AlertProvider({ children }) { + const [alerts, setAlerts] = useState([]) + const dismissedAlerts = useRef(new Map()) + + function dispatchAlert(alert) { + if (dismissedAlerts.current.get(alert.category) >= alert.severity) return + dismissedAlerts.current.delete(alert.category) + + const existingAlertIndex = alerts.findIndex( + (existingAlert) => existingAlert.category == alert.category, + ) + if (existingAlertIndex >= 0) { + alerts[existingAlertIndex] = alert + setAlerts([...alerts]) + } else { + setAlerts([...alerts, alert]) + } + } + + function dismissAlert(category, manual) { + setAlerts((prevAlerts) => { + const alert = prevAlerts.find((a) => a.category === category) + + if (manual) { + dismissedAlerts.current.set(category, alert.severity) + } else { + dismissedAlerts.current.delete(category) + } + + return prevAlerts.filter((a) => a.category !== category) + }) + } + + return ( + + {children} + + ) +} + +export const useAlerts = () => useContext(AlertContext) diff --git a/gcs/src/components/dashboard/statusBar.jsx b/gcs/src/components/dashboard/statusBar.jsx index 990327bd0..df5cb9c1a 100644 --- a/gcs/src/components/dashboard/statusBar.jsx +++ b/gcs/src/components/dashboard/statusBar.jsx @@ -15,6 +15,7 @@ import { IconClock, IconNetwork, IconNetworkOff } from "@tabler/icons-react" // Helper imports import { socket } from "../../helpers/socket" import GetOutsideVisibilityColor from "../../helpers/outsideVisibility" +import AlertSection from "./alert" export function StatusSection({ icon, value, tooltip }) { return ( @@ -66,6 +67,9 @@ export default function StatusBar(props) {

Current heading

Desired heading

+
+ +
) } diff --git a/gcs/src/components/dashboard/telemetry.jsx b/gcs/src/components/dashboard/telemetry.jsx index 3fc86de24..b9bb9c647 100644 --- a/gcs/src/components/dashboard/telemetry.jsx +++ b/gcs/src/components/dashboard/telemetry.jsx @@ -1,5 +1,5 @@ /* - Telemetry. This file holds all the telemetry indicators and is part of the resizable info box + Telemetry. This file holds all the telemetry indicators and is part of the resizable info box section, found in the top half. */ diff --git a/gcs/src/components/mainContent.jsx b/gcs/src/components/mainContent.jsx index 4551c13ef..484a5095f 100644 --- a/gcs/src/components/mainContent.jsx +++ b/gcs/src/components/mainContent.jsx @@ -21,6 +21,7 @@ import Dashboard from "../dashboard" import { store } from "../redux/store" import { Provider } from "react-redux" import { ErrorBoundary } from "react-error-boundary" +import AlertProvider from "./dashboard/alertProvider" import ErrorBoundaryFallback from "./error/errorBoundary" export default function AppContent() { @@ -34,7 +35,14 @@ export default function AppContent() { - } /> + + + + } + /> } /> } /> } /> @@ -48,7 +56,6 @@ export default function AppContent() { } /> - } /> {renderUI && } diff --git a/gcs/src/components/settingsModal.jsx b/gcs/src/components/settingsModal.jsx index 6e4977f71..35642fd1b 100644 --- a/gcs/src/components/settingsModal.jsx +++ b/gcs/src/components/settingsModal.jsx @@ -1,8 +1,25 @@ -import { Checkbox, Input, Modal, NativeSelect, Tabs } from "@mantine/core" +import { + Button, + Checkbox, + Input, + Modal, + NativeSelect, + NumberInput, + Tabs, +} from "@mantine/core" import { useSettings } from "../helpers/settings" +import { IconTrash } from "@tabler/icons-react" +import { memo, useEffect, useState } from "react" import DefaultSettings from "../../data/default_settings.json" -import { memo } from "react" + +const isValidNumber = (num, range) => { + return ( + num && + parseInt(num) && + (range === null || (range[0] <= num && num <= range[1])) + ) +} function TextSetting({ settingName, hidden }) { const { getSetting, setSetting } = useSettings() @@ -38,31 +55,94 @@ function OptionSetting({ settingName, options }) { function NumberSetting({ settingName, range }) { const { getSetting, setSetting } = useSettings() - const setIfValid = (num) => { - if ( - !num || - (parseInt(num) && - (range === null || (range[0] <= num && num <= range[1]))) - ) - setSetting(settingName, num) - } return ( setIfValid(e.currentTarget.value)} + onChange={(e) => { + const num = e.currentTarget.value + if (isValidNumber(num, range)) setSetting(settingName, num) + }} /> ) } +const generateId = () => Math.random().toString(36).slice(8) + +function ExtendableNumberSetting({ settingName, range }) { + const { getSetting, setSetting } = useSettings() + + const [values, setValues] = useState( + getSetting(settingName).map((val) => ({ id: generateId(), value: val })), + ) + + useEffect(() => { + setSetting( + settingName, + values.map((a) => a.value), + ) + }, [values]) + + const updateValue = (id, value) => { + setValues((prev) => prev.map((a) => (a.id === id ? { ...a, value } : a))) + } + + const removeValue = (id) => { + setValues((prev) => prev.filter((a) => a.id !== id)) + } + + return ( +
+ {values.map(({ id, value }) => ( +
+ + { + if (!isValidNumber(num, range)) return + updateValue(id, parseInt(num)) + }} + suffix="m" + /> +
+ ))} +
+ +
+
+ ) +} + function Setting({ settingName, df }) { return ( -
-
{df.display}:
- {df.type == "number" ? ( +
+
+
{df.display}:
+

{df.description}

+
+ {df.type == "extendableNumber" ? ( + + ) : df.type == "number" ? ( ) : df.type == "boolean" ? ( - + ) : df.type == "option" ? ( ) : ( @@ -111,7 +191,7 @@ function SettingsModal() { {settingTabs.map((t) => { return ( - + {Object.keys(DefaultSettings[t]).map((s) => { return ( highestAltitudeRef.current) + return (highestAltitudeRef.current = msg.alt) + const altitudes = getSetting("Dashboard.altitudeAlerts") + altitudes.sort((a1, a2) => a1 - a2) + + for (const [i, altitude] of altitudes.entries()) { + if (highestAltitudeRef.current > altitude && msg.alt < altitude) { + dispatchAlert({ + category: AlertCategory.Altitude, + severity: + i == 0 + ? AlertSeverity.Red + : i == altitudes.length - 1 + ? AlertSeverity.Yellow + : AlertSeverity.Orange, + jsx: <>Caution! You've fallen below {altitude}m, + }) + return + } + } + + dismissAlert(AlertCategory.Altitude) + } + const incomingMessageHandler = useCallback( () => ({ - VFR_HUD: (msg) => setTelemetryData(msg), + VFR_HUD: (msg) => { + setTelemetryData(msg) + updateAltitudeAlert(msg) + }, BATTERY_STATUS: (msg) => { const battery = localBatteryData.filter( (battery) => battery.id == msg.id, diff --git a/gcs/src/missions.jsx b/gcs/src/missions.jsx index 052bb6be5..9970027ea 100644 --- a/gcs/src/missions.jsx +++ b/gcs/src/missions.jsx @@ -1,5 +1,5 @@ /* - The missions screen. + The missions screen. */ // Base imports