diff --git a/CHANGELOG.md b/CHANGELOG.md
index ebdc50a1..c1533c67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
be shown if any pair of takeoff positions or any pair of landing positions are
too close to each other.
+- Make the "Flash lights" button latchable by holding Shift while activating it.
+
### Changed
- When setting the start time based on a time offset from the current time,
diff --git a/src/components/uavs/UAVOperationsButtonGroup.jsx b/src/components/uavs/UAVOperationsButtonGroup.jsx
index 84d05fd5..36cb0492 100644
--- a/src/components/uavs/UAVOperationsButtonGroup.jsx
+++ b/src/components/uavs/UAVOperationsButtonGroup.jsx
@@ -1,8 +1,9 @@
import isEmpty from 'lodash-es/isEmpty';
import PropTypes from 'prop-types';
-import React from 'react';
+import React, { useCallback, useState } from 'react';
import { withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
+import { useInterval } from 'react-use';
import { bindActionCreators } from '@reduxjs/toolkit';
import Button from '@material-ui/core/Button';
@@ -27,6 +28,7 @@ import Colors from '~/components/colors';
import ToolbarDivider from '~/components/ToolbarDivider';
import Bolt from '~/icons/Bolt';
+import { UAV_SIGNAL_DURATION } from '~/features/settings/constants';
import {
requestRemovalOfUAVsByIds,
requestRemovalOfUAVsMarkedAsGone,
@@ -89,16 +91,33 @@ const UAVOperationsButtonGroup = ({
dispatch
);
+ const [keepFlashing, setKeepFlashing] = useState(false);
+ const flashLightsButtonOnClick = useCallback(
+ (event) => {
+ if (keepFlashing) {
+ setKeepFlashing(false);
+ } else if (event.shiftKey) {
+ setKeepFlashing(true);
+ flashLight();
+ } else {
+ flashLight();
+ }
+ },
+ [flashLight, keepFlashing, setKeepFlashing]
+ );
+
+ useInterval(flashLight, keepFlashing ? UAV_SIGNAL_DURATION * 1000 : null);
+
const fontSize = size === 'small' ? 'small' : 'medium';
const iconSize = size;
const flashLightsButton =
size === 'small' ? (
}
+ startIcon={}
disabled={isSelectionEmpty}
size={iconSize}
- onClick={flashLight}
+ onClick={flashLightsButtonOnClick}
>
{t('UAVOpButtonGrp.flashLights')}
@@ -107,9 +126,12 @@ const UAVOperationsButtonGroup = ({
-
+
);
diff --git a/src/features/settings/constants.ts b/src/features/settings/constants.ts
index 2bce16f2..a2817ee9 100644
--- a/src/features/settings/constants.ts
+++ b/src/features/settings/constants.ts
@@ -1,4 +1,9 @@
/**
- * Timeout length for broadcast mode.
+ * Timeout length for broadcast mode, in seconds.
*/
export const BROADCAST_MODE_TIMEOUT_LENGTH = 5;
+
+/**
+ * Duration of the `UAV-SIGNAL` (flash light) command, in seconds.
+ */
+export const UAV_SIGNAL_DURATION = 5;
diff --git a/src/features/uavs/selectors.js b/src/features/uavs/selectors.js
index f10e77a8..37608cb1 100644
--- a/src/features/uavs/selectors.js
+++ b/src/features/uavs/selectors.js
@@ -41,6 +41,7 @@ import { convertRGB565ToCSSNotation } from '~/flockwave/parsing';
import { globalIdToUavId } from '~/model/identifiers';
import { UAVAge } from '~/model/uav';
import { selectionForSubset } from '~/selectors/selection';
+import { selectOrdered } from '~/utils/collections';
import { euclideanDistance2D, getMeanAngle } from '~/utils/math';
import { EMPTY_ARRAY } from '~/utils/redux';
import { createDeepResultSelector } from '~/utils/selectors';
@@ -68,6 +69,15 @@ export const getUAVIdToStateMapping = (state) => state.uavs.byId;
*/
export const getUAVById = (state, uavId) => state.uavs.byId[uavId];
+/**
+ * Selector that calculates and caches the list of all the UAVs in the
+ * state object.
+ */
+export const getUAVsInOrder = createSelector(
+ (state) => state.uavs,
+ selectOrdered
+);
+
/**
* Returns the current position of the UAV with the given ID, given the current
* state.
diff --git a/src/model/layers.ts b/src/model/layers.ts
index 63aa606d..1e79a370 100644
--- a/src/model/layers.ts
+++ b/src/model/layers.ts
@@ -19,6 +19,7 @@ import Map from '@material-ui/icons/Map';
import MyLocation from '@material-ui/icons/MyLocation';
import Timeline from '@material-ui/icons/Timeline';
import TrackChanges from '@material-ui/icons/TrackChanges';
+import Wifi from '@material-ui/icons/Wifi';
import type BaseLayer from 'ol/layer/Base';
import type OLLayer from 'ol/layer/Layer';
@@ -49,6 +50,8 @@ export enum LayerType {
UAV_TRACE = 'uavTrace',
UNAVAILABLE = 'unavailable',
UNTYPED = 'untyped',
+
+ RSSI = 'rssi',
}
export type Layer = {
@@ -76,6 +79,7 @@ export const LayerTypes = [
LayerType.OWN_LOCATION,
LayerType.GEOJSON,
LayerType.HEATMAP,
+ LayerType.RSSI,
] as const;
export const ProLayerTypes = [LayerType.IMAGE] as const;
@@ -251,6 +255,10 @@ const propertiesForLayerTypes: Record<
label: 'Untyped layer',
icon: HelpOutline,
},
+ [LayerType.RSSI]: {
+ label: 'RSSI',
+ icon: Wifi,
+ },
} as const;
/**
diff --git a/src/model/uav.ts b/src/model/uav.ts
index dc69abd9..603d771c 100644
--- a/src/model/uav.ts
+++ b/src/model/uav.ts
@@ -64,6 +64,10 @@ export default class UAV {
// to the output later on, thus the object spread can be avoided.
_positionMemoizer: (position: GPSPosition) => GPSPosition;
+ // TODO: This should be unnecessary if we can ensure that no mutation happens
+ // to the output later on, thus the object spread can be avoided.
+ _positionMemoizer: (position: GPSPosition) => GPSPosition;
+
/**
* Constructor.
*
diff --git a/src/utils/messaging.js b/src/utils/messaging.js
index 015a6c29..f0b79d86 100644
--- a/src/utils/messaging.js
+++ b/src/utils/messaging.js
@@ -9,6 +9,7 @@ import mapValues from 'lodash-es/mapValues';
import values from 'lodash-es/values';
import { showConfirmationDialog } from '~/features/prompt/actions';
+import { UAV_SIGNAL_DURATION } from '~/features/settings/constants';
import { shouldConfirmUAVOperation } from '~/features/settings/selectors';
import { showNotification } from '~/features/snackbar/actions';
import { MessageSemantics } from '~/features/snackbar/types';
@@ -148,7 +149,7 @@ export const flashLightOnUAVsAndHideFailures = performMassOperation({
name: 'Light signal command',
mapper: (options) => ({
signals: ['light'],
- duration: 5000,
+ duration: UAV_SIGNAL_DURATION * 1000,
...options,
}),
reportSuccess: false,
diff --git a/src/views/map/layers/index.js b/src/views/map/layers/index.js
index 22b267e6..73479437 100644
--- a/src/views/map/layers/index.js
+++ b/src/views/map/layers/index.js
@@ -16,6 +16,7 @@ import { TileServerLayerSettings, TileServerLayer } from './tileserver';
import { UAVsLayerSettings, UAVsLayer } from './uavs';
import { UAVTraceLayerSettings, UAVTraceLayer } from './uavtrace';
import { UntypedLayerSettings, UntypedLayer } from './untyped';
+import { RSSILayer } from './rssi';
import { LayerType } from '~/model/layers';
@@ -76,6 +77,7 @@ export const Layers = {
[LayerType.UAVS]: UAVsLayer,
[LayerType.UAV_TRACE]: UAVTraceLayer,
[LayerType.UNTYPED]: UntypedLayer,
+ [LayerType.RSSI]: RSSILayer,
};
export const stateObjectToLayer = (layer, props) => {
diff --git a/src/views/map/layers/rssi.jsx b/src/views/map/layers/rssi.jsx
new file mode 100644
index 00000000..04dc2afb
--- /dev/null
+++ b/src/views/map/layers/rssi.jsx
@@ -0,0 +1,83 @@
+import { RegularShape, Style } from 'ol/style';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+
+import { Feature, geom, layer, source } from '@collmot/ol-react';
+
+import { getUAVsInOrder } from '~/features/uavs/selectors';
+import { mapViewCoordinateFromLonLat } from '~/utils/geography';
+import { dockIdToGlobalId } from '~/model/identifiers';
+import { shadowVeryThinOutline, fill } from '~/utils/styles';
+
+// === Helper functions ===
+
+const createRSSIStyle = (rssi) => [
+ new Style({
+ image: new RegularShape({
+ points: 4,
+ fill: fill(`hsl(${rssi}, 100%, 50%)`),
+ stroke: shadowVeryThinOutline,
+ radius: 15,
+ angle: Math.PI / 4,
+ }),
+ }),
+];
+
+// === A single feature representing a docking station ===
+
+const RSSIFeature = React.memo(({ uav, ...rest }) => {
+ const { id, position, rssi } = uav;
+
+ if (!position) {
+ return null;
+ }
+
+ const style = createRSSIStyle(rssi[0]);
+
+ return (
+
+
+
+ );
+});
+
+RSSIFeature.propTypes = {
+ selected: PropTypes.bool,
+ uav: PropTypes.shape({
+ id: PropTypes.string,
+ position: PropTypes.shape({
+ lat: PropTypes.number.required,
+ lon: PropTypes.number.required,
+ }),
+ rssi: PropTypes.arrayOf(PropTypes.number),
+ }),
+};
+
+// === The actual layer to be rendered ===
+
+const RSSILayerPresentation = ({ uavs, zIndex }) => (
+
+
+ {uavs.map((uav) => (
+
+ ))}
+
+
+);
+
+RSSILayerPresentation.propTypes = {
+ uavs: PropTypes.arrayOf(PropTypes.object).isRequired,
+ zIndex: PropTypes.number,
+};
+
+export const RSSILayer = connect(
+ // mapStateToProps
+ (state) => ({
+ uavs: getUAVsInOrder(state),
+ }),
+ // mapDispatchToProps
+ null
+)(RSSILayerPresentation);
diff --git a/src/views/uavs/DroneStatusLine.jsx b/src/views/uavs/DroneStatusLine.jsx
index 4c212e98..c113d80d 100644
--- a/src/views/uavs/DroneStatusLine.jsx
+++ b/src/views/uavs/DroneStatusLine.jsx
@@ -29,7 +29,9 @@ import {
getSemanticsForRSSI,
} from '~/model/enums';
import { getPreferredCoordinateFormatter } from '~/selectors/formatting';
-import { formatCoordinateArray, formatRSSI } from '~/utils/formatting';
+import { formatCoordinateArray, formatRSSI, shortTimeAgoFormatter } from '~/utils/formatting';
+
+import TimeAgo from 'react-timeago';
/**
* Converts the absolute value of a heading deviation, in degrees, to the
@@ -60,6 +62,7 @@ const useStyles = makeStyles(
fontVariantNumeric: 'lining-nums tabular-nums',
marginTop: [-2, '!important'],
marginBottom: [-4, '!important'],
+ userSelect: 'none',
whiteSpace: 'pre',
},
gone: {
@@ -122,6 +125,7 @@ const DroneStatusLine = ({
secondaryLabel,
text,
textSemantics,
+ lastUpdated,
}) => {
const classes = useStyles();
const { amsl, ahl, agl } = position || {};
@@ -168,13 +172,6 @@ const DroneStatusLine = ({
>
{abbreviateGPSFixType(gpsFixType)}
- {localPosition ? (
- padEnd(localCoordinateFormatter(localPosition), 25)
- ) : position ? (
- padEnd(coordinateFormatter([position.lon, position.lat]), 25)
- ) : (
- {padEnd('no position', 25)}
- )}
{!isNil(amsl) ? (
padStart(position.amsl.toFixed(1), 6) + 'm'
) : (
@@ -193,6 +190,14 @@ const DroneStatusLine = ({
{padStart(!isNil(heading) ? Math.round(heading) + '°' : '', 5)}
+ padStart(padEnd(shortTimeAgoFormatter(...args), 5), 6)} date={lastUpdated} />
+ {localPosition ? (
+ padEnd(localCoordinateFormatter(localPosition), 25)
+ ) : position ? (
+ padEnd(coordinateFormatter([position.lon, position.lat]), 25)
+ ) : (
+ {padEnd('no position', 25)}
+ )}
{debugString ? ' ' + debugString : ''}
@@ -274,6 +279,7 @@ export default connect(
mode: uav ? uav.mode : undefined,
position: uav ? uav.position : undefined,
rssi: uav ? uav.rssi?.[0] : undefined,
+ lastUpdated: uav ? uav.lastUpdated : undefined,
...statusSummarySelector(state, ownProps.id),
};
};
diff --git a/src/views/uavs/SortAndFilterHeader.jsx b/src/views/uavs/SortAndFilterHeader.jsx
index 224efc55..25e80457 100644
--- a/src/views/uavs/SortAndFilterHeader.jsx
+++ b/src/views/uavs/SortAndFilterHeader.jsx
@@ -200,13 +200,6 @@ const COMMON_HEADER_TEXT_PARTS = Object.freeze([
width: 40,
},
},
- {
- label: 'Position',
- style: {
- textAlign: 'left',
- width: 198,
- },
- },
{
label: 'AMSL',
sortKey: UAVSortKey.ALTITUDE_MSL,
@@ -239,6 +232,20 @@ const COMMON_HEADER_TEXT_PARTS = Object.freeze([
width: 40,
},
},
+ {
+ label: 'Seen',
+ style:{
+ textAlign: 'left',
+ width: 40,
+ }
+ },
+ {
+ label: 'Position',
+ style: {
+ textAlign: 'left',
+ width: 198,
+ },
+ },
{
label: 'Details',
style: {
diff --git a/src/views/uavs/UAVList.jsx b/src/views/uavs/UAVList.jsx
index 872edd69..83efa61e 100644
--- a/src/views/uavs/UAVList.jsx
+++ b/src/views/uavs/UAVList.jsx
@@ -223,10 +223,12 @@ const createGridItems = (
const createListItems = (
items,
{
+ ids,
isInEditMode,
mappingSlotBeingEdited,
onDropped,
onSelectedUAV,
+ onSelectedUAVs,
onSelectedMissionSlot,
onStartEditing,
selectedUAVIds,
@@ -250,7 +252,25 @@ const createListItems = (
/* prettier-ignore */
onClick:
isInEditMode ? (_event) => onStartEditing(missionIndex) :
- uavId ? (event) => onSelectedUAV(event, uavId) :
+ uavId ? (event) => {
+ if (event.shiftKey) {
+ event.preventDefault();
+ if (selectedUAVIds.length === 1) {
+ const selId = selectedUAVIds[0];
+ const uIds = ids.map(([u, _m]) => u);
+ const selIndex = uIds.indexOf(selId);
+ const uavIndex = uIds.indexOf(uavId);
+ onSelectedUAVs(
+ uIds.slice(
+ Math.min(selIndex, uavIndex),
+ Math.max(selIndex, uavIndex) + 1
+ )
+ );
+ }
+ } else {
+ onSelectedUAV(event, uavId);
+ }
+ } :
missionSlotId ? (event) => onSelectedMissionSlot(event, missionSlotId) :
undefined,
onDrop: onDropped ? onDropped(missionIndex) : undefined,
@@ -303,7 +323,7 @@ const UAVListSection = ({
<>
- {itemFactory(ids, itemFactoryOptions)}
+ {itemFactory(ids, { ...itemFactoryOptions, ids })}
>
);
@@ -329,6 +349,7 @@ const UAVListPresentation = ({
onEditMappingSlot,
onMappingAdjusted,
onSelectUAV,
+ onSelectUAVs,
onSelectMissionSlot,
onSelectSection,
selectedUAVIds,
@@ -363,6 +384,7 @@ const UAVListPresentation = ({
mappingSlotBeingEdited,
onDropped: editingMapping && onDropped,
onSelectedUAV: onSelectUAV,
+ onSelectedUAVs: onSelectUAVs,
onSelectedMissionSlot: onSelectMissionSlot,
onStartEditing: onEditMappingSlot,
selectedUAVIds,
@@ -580,6 +602,7 @@ const UAVList = connect(
getSelection: getSelectedUAVIds,
setSelection: setSelectedUAVIds,
}),
+ onSelectUAVs: setSelectedUAVIds,
onSelectMissionSlot: createSelectionHandlerThunk({
getSelection: getSelectedMissionSlotIds,
setSelection: setSelectedMissionSlotIds,