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' ? ( @@ -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,