diff --git a/client/src/components/ConfigurableLayout/ConfigurableLayout.tsx b/client/src/components/ConfigurableLayout/ConfigurableLayout.tsx index a50a8e3d7..d53ab9f8c 100644 --- a/client/src/components/ConfigurableLayout/ConfigurableLayout.tsx +++ b/client/src/components/ConfigurableLayout/ConfigurableLayout.tsx @@ -23,6 +23,7 @@ import TelemetryView from '@/components/views/TelemetryView'; import CameraView from '@/components/views/CameraView'; import OpModeView from '@/components/views/OpModeView'; import LoggingView from '@/components/views/LoggingView/LoggingView'; +import RecorderView from '@/components/views/RecorderView'; import RadialFab from './RadialFab/RadialFab'; import RadialFabChild from './RadialFab/RadialFabChild'; @@ -68,6 +69,7 @@ const VIEW_MAP: { [key in ConfigurableView]: ReactElement } = { [ConfigurableView.CAMERA_VIEW]: , [ConfigurableView.OPMODE_VIEW]: , [ConfigurableView.LOGGING_VIEW]: , + [ConfigurableView.RECORDER_VIEW]: , }; const LOCAL_STORAGE_LAYOUT_KEY = 'configurableLayoutStorage'; diff --git a/client/src/components/ConfigurableLayout/ViewPicker.tsx b/client/src/components/ConfigurableLayout/ViewPicker.tsx index 47136d8e4..4fee67042 100644 --- a/client/src/components/ConfigurableLayout/ViewPicker.tsx +++ b/client/src/components/ConfigurableLayout/ViewPicker.tsx @@ -114,6 +114,13 @@ const listContent = [ customStyles: 'focus:ring-purple-600', iconBg: 'bg-purple-500', }, + { + title: 'Recorder View', + view: ConfigurableView.RECORDER_VIEW, + icon: , + customStyles: 'focus:ring-green-600', + iconBg: 'bg-green-500', + }, ]; const ViewPicker = (props: ViewPickerProps) => { diff --git a/client/src/components/views/FieldView/FieldView.jsx b/client/src/components/views/FieldView/FieldView.jsx index ae132a750..29e6e1df8 100644 --- a/client/src/components/views/FieldView/FieldView.jsx +++ b/client/src/components/views/FieldView/FieldView.jsx @@ -27,15 +27,14 @@ class FieldView extends React.Component { componentDidUpdate(prevProps) { if (this.props.telemetry === prevProps.telemetry) return; - this.overlay = this.props.telemetry.reduce( - (acc, { field, fieldOverlay }) => - fieldOverlay.ops.length === 0 - ? acc - : { - ops: [...field.ops, ...fieldOverlay.ops], - }, - this.overlay, - ); + this.overlay = this.props.telemetry.reduce((acc, { field, replayOverlay, fieldOverlay }) => ({ + ops: [ + ...acc.ops, + ...(field?.ops || []), + ...(replayOverlay?.ops || []), + ...(fieldOverlay?.ops || []), + ], + }), { ops: [] }); this.field.setOverlay(this.overlay); this.renderField(); diff --git a/client/src/components/views/RecorderView.jsx b/client/src/components/views/RecorderView.jsx new file mode 100644 index 000000000..d6e47750d --- /dev/null +++ b/client/src/components/views/RecorderView.jsx @@ -0,0 +1,563 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { setReplayOverlay, receiveTelemetry } from '@/store/actions/telemetry'; + +import BaseView, { BaseViewHeading } from '@/components/views/BaseView'; +import AutoFitCanvas from '@/components/Canvas/AutoFitCanvas'; + +import OpModeStatus from '@/enums/OpModeStatus'; +import { ReactComponent as DeleteSVG } from '@/assets/icons/delete.svg'; +import { ReactComponent as DownloadSVG } from '@/assets/icons/file_download.svg'; +import { ReactComponent as PlaySVG } from '@/assets/icons/play_arrow.svg'; + + +class RecorderView extends React.Component { + constructor(props) { + super(props); + this.canvasRef = React.createRef(); + this.playbackInterval = null; + this.startReplayTime = null; + this.startRecordingTime = null; + this.isRunning = false; + this.isReplaying = false; + + this.telemetryRecording = []; + this.telemetryReplay = []; + this.currOps = []; + + this.state = { + savedReplays: [], + selectedReplays: [], + replayUpdateInterval: 20, + saveReplays: true, + replayOnStart: true, + autoSelect: true, + }; + } + + componentDidMount() { + this.loadSavedReplays(); + } + + loadSavedReplays = () => { + const keys = Object.keys(localStorage).filter((key) => + key.startsWith('field_replay_') + ); + this.setState({ savedReplays: keys }, () => { + if (this.state.autoSelect) { + this.handleLoadTelemetryByFilename({ target: { selectedOptions: Array.from(this.state.savedReplays.map(filename => ({ value: filename }))) } }); + } + }); + }; + + handleDownloadSelectedReplays = () => { + const { selectedReplays } = this.state; + + if (!selectedReplays || selectedReplays.length === 0) { + return; + } + + selectedReplays.forEach((filename) => { + const replayDataString = localStorage.getItem(filename); + + if (!replayDataString) { + return; + } + + const replayData = JSON.parse(replayDataString); + + const blob = new Blob([JSON.stringify(replayData, null, 2)], { type: 'application/json' }); + + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `${filename}.json`; + + link.click(); + }); + }; + + handleSaveToLocalStorage = () => { + if (!this.state.saveReplays) return; + + const currentDate = new Date(); + const formattedDate = currentDate.toISOString().split('.')[0]; + const storageKey = `field_replay_${formattedDate}`; + + let totalSize = 0; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + totalSize += new Blob([localStorage.getItem(key)]).size; + } + + const dataToSave = JSON.stringify(this.telemetryRecording); + const newDataSize = new Blob([dataToSave]).size; + + const maxStorageSize = 5 * 1024 * 1024; + if (totalSize + newDataSize > maxStorageSize) { + alert("Cannot save replay: LocalStorage quota exceeded."); + return; + } else { + localStorage.setItem(storageKey, dataToSave); + } + this.loadSavedReplays(); + }; + + handleLoadTelemetryByFilename = (event) => { + const selectedFiles = Array.from(event.target.selectedOptions, (option) => option.value); + if (selectedFiles.length === 0) return; + + this.setState({ + selectedReplays: selectedFiles, + }); + + this.telemetryReplay = []; + + selectedFiles.forEach((filename) => { + const savedTelemetry = localStorage.getItem(filename); + if (savedTelemetry) { + const parsedTelemetry = JSON.parse(savedTelemetry); + this.telemetryReplay.push(parsedTelemetry); + } + }); + }; + + handleDeleteReplay = () => { + const { selectedReplays } = this.state; + + if (!selectedReplays || selectedReplays.length === 0) return; + + selectedReplays.forEach((filename) => { + localStorage.removeItem(filename); + }); + + this.telemetryReplay = []; + this.currOps = [[]]; + + this.setState((prevState) => ({ + savedReplays: prevState.savedReplays.filter((file) => !selectedReplays.includes(file)), + selectedReplays: [], + })); + }; + + handleDeleteAllReplays = () => { + const { savedReplays } = this.state; + + if (savedReplays.length === 0) return; + + savedReplays.forEach((filename) => localStorage.removeItem(filename)); + this.telemetryReplay = []; + this.currOps = [[]]; + + this.setState({ savedReplays: [], selectedReplays: [] }); + }; + + handleUploadReplay = (event) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + Array.from(files).forEach((file) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const parsedData = JSON.parse(e.target.result); + if (!Array.isArray(parsedData)) { + alert(`Invalid file format in ${file.name}. Expected an array of telemetry data.`); + return; + } + + const fileName = `field_replay_${file.name.replace('.json', '')}`; + localStorage.setItem(fileName, JSON.stringify(parsedData)); + this.loadSavedReplays() + } catch (error) { + alert(`Error parsing JSON file: ${file.name}`); + } + }; + reader.readAsText(file); + }); + }; + + handleStartPlayback = () => { + if (this.playbackInterval) { + clearInterval(this.playbackInterval); + this.playbackInterval = null; + } + + this.isReplaying = true; + this.startPlayback(); + }; + + startPlayback = () => { + if (this.telemetryReplay.length === 0) return; + if (!this.state.saveReplays && !this.isReplaying) return; + + let lastIndex = new Array(this.telemetryReplay.length).fill(0); + let playbackComplete = false; + let ops = [[]]; + + this.startReplayTime = Date.now(); + + this.playbackInterval = setInterval(() => { + const elapsedTime = Date.now() - this.startReplayTime; + const timeRangeEnd = elapsedTime + this.state.replayUpdateInterval / 2; + + for (let replayIndex = 0; replayIndex < this.telemetryReplay.length; replayIndex++) { + let isUpdated = false; + for (let i = lastIndex[replayIndex]; i < this.telemetryReplay[replayIndex].length; i++) { + const entry = this.telemetryReplay[replayIndex][i]; + + if (entry.timestamp <= timeRangeEnd) { + if (!isUpdated) { + ops[replayIndex] = []; + isUpdated = true; + } + ops[replayIndex].push(...entry.ops); + lastIndex[replayIndex] = i + 1; + } else { + break; + } + } + } + + this.currOps = ops.flat(); + + if (JSON.stringify(this.currOps).length > 0) { + this.props.setReplayOverlay(this.currOps); + } + + if (lastIndex.every((index, idx) => index >= (this.telemetryReplay[idx]?.length || 0))) { + playbackComplete = true; + } + + if (playbackComplete) { + this.clearPlayback(); + } + }, this.state.replayUpdateInterval); + }; + + clearPlayback() { + this.isReplaying = false; + clearInterval(this.playbackInterval); + this.playbackInterval = null; + } + + compareOverlays = (prevOverlay, currentOverlay) => { + return JSON.stringify(currentOverlay.ops) !== JSON.stringify(prevOverlay.ops); + }; + + componentDidUpdate(prevProps) { + if (this.props.activeOpModeStatus === OpModeStatus.STOPPED && this.isRunning) { + this.isRunning = false; + this.handleSaveToLocalStorage(); + } + + if (this.props.telemetry === prevProps.telemetry) { + return; + } + + const overlay = this.props.telemetry.reduce( + (acc, { fieldOverlay }) => ({ + ops: [...acc.ops, ...(fieldOverlay?.ops || [])], + }), + { ops: [] } + ); + + const prevOverlay = prevProps.telemetry.reduce( + (acc, { fieldOverlay }) => ({ + ops: [...acc.ops, ...(fieldOverlay?.ops || [])], + }), + { ops: [] } + ); + + if (this.compareOverlays(prevOverlay, overlay)) { + if (this.props.activeOpModeStatus === OpModeStatus.INIT && !this.isRunning) { + this.isRunning = true; + this.startRecordingTime = Date.now(); + this.telemetryRecording = []; + this.currOps = []; + + if (this.state.replayOnStart) { + this.handleStartPlayback(); + } + } + } + + if (this.isRunning) { + const overlay = this.props.telemetry.reduce( + (acc, { fieldOverlay }) => ({ + ops: [...acc.ops, ...(fieldOverlay?.ops || [])], + }), + { ops: [] } + ); + + if (overlay.ops.length > 0) { + const relativeTimestamp = Date.now() - this.startRecordingTime; + this.telemetryRecording.push({ + timestamp: relativeTimestamp, + ops: overlay.ops, + }); + } + } + + if (this.isReplaying) { + const replayOps = this.props.telemetry.reduce( + (acc, { replayOverlay }) => ({ + ops: [...(replayOverlay?.ops || [])], + }), + { ops: [] } + ); + const currOpsStr = JSON.stringify(this.currOps); + if (replayOps.ops.length === 0 && currOpsStr !== JSON.stringify(replayOps.ops) && currOpsStr.length > 0) { + this.props.setReplayOverlay(this.currOps); + } + } + } + + handleReplayUpdateIntervalChange = (event) => { + const value = parseInt(event.target.value, 10); + this.setState({ replayUpdateInterval: value }); + }; + + handleReplayOnStartChange = (event) => { + const checked = event.target.checked; + this.setState({ replayOnStart: checked }); + }; + + handleAutoSelectChange = (event) => { + const checked = event.target.checked; + this.setState({ autoSelect: checked }); + }; + + render() { + return ( + + + Field Recorder + + +
+ +
+ +
+
+ + +
+ +
+ (this.fileInput = input)} + style={{ display: 'none' }} + onChange={this.handleUploadReplay} + /> + + + + + + + + + + +
+ +
+ + +
+ +
+ + this.setState({ saveReplays: event.target.checked })} + style={{ + marginRight: '0.5em', + }} + /> +
+
+ + +
+
+ + +
+
+
+ ); + } +} + +RecorderView.propTypes = { + telemetry: PropTypes.array.isRequired, + isUnlocked: PropTypes.bool, + activeOpModeStatus: PropTypes.string, + receiveTelemetry: PropTypes.func.isRequired, + setReplayOverlay: PropTypes.func.isRequired, +}; + +const mapStateToProps = (state) => ({ + telemetry: state.telemetry, + activeOpModeStatus: state.status.activeOpModeStatus, +}); + +const mapDispatchToProps = { + setReplayOverlay, + receiveTelemetry, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(RecorderView); diff --git a/client/src/enums/ConfigurableView.ts b/client/src/enums/ConfigurableView.ts index 3a38d197e..13c5ea1ca 100644 --- a/client/src/enums/ConfigurableView.ts +++ b/client/src/enums/ConfigurableView.ts @@ -6,4 +6,5 @@ export enum ConfigurableView { CAMERA_VIEW, OPMODE_VIEW, LOGGING_VIEW, + RECORDER_VIEW, } diff --git a/client/src/store/actions/telemetry.ts b/client/src/store/actions/telemetry.ts index 3efa713d1..8c37a1644 100644 --- a/client/src/store/actions/telemetry.ts +++ b/client/src/store/actions/telemetry.ts @@ -1,6 +1,11 @@ -import { Telemetry, RECEIVE_TELEMETRY } from '@/store/types'; +import { Telemetry, RECEIVE_TELEMETRY, SET_REPLAY_OVERLAY } from '@/store/types'; export const receiveTelemetry = (telemetry: Telemetry) => ({ type: RECEIVE_TELEMETRY, telemetry, }); + +export const setReplayOverlay = (overlay: DrawOp[]) => ({ + type: SET_REPLAY_OVERLAY, + overlay, +}); diff --git a/client/src/store/reducers/telemetry.ts b/client/src/store/reducers/telemetry.ts index e49b73b55..9aa67ae4e 100644 --- a/client/src/store/reducers/telemetry.ts +++ b/client/src/store/reducers/telemetry.ts @@ -1,7 +1,9 @@ import { ReceiveTelemetryAction, RECEIVE_TELEMETRY, + SET_REPLAY_OVERLAY, Telemetry, + SetReplayOverlayAction, } from '@/store/types'; const initialState: Telemetry = [ @@ -15,19 +17,30 @@ const initialState: Telemetry = [ fieldOverlay: { ops: [], }, + replayOverlay: { + ops: [], + }, }, ]; -const telemetryReducer = ( - state = initialState, - action: ReceiveTelemetryAction, -) => { +const telemetryReducer = (state = initialState, action) => { switch (action.type) { case RECEIVE_TELEMETRY: return action.telemetry; + + case SET_REPLAY_OVERLAY: + return state.map((item) => { + return { + ...item, + replayOverlay: { + ops: action.overlay, + }, + }; + }); + default: return state; } }; -export default telemetryReducer; +export default telemetryReducer; \ No newline at end of file diff --git a/client/src/store/types/index.ts b/client/src/store/types/index.ts index 39ccddded..259fe7af0 100644 --- a/client/src/store/types/index.ts +++ b/client/src/store/types/index.ts @@ -74,9 +74,10 @@ export type { GamepadSupportedStatus, } from './status'; -export { RECEIVE_TELEMETRY } from './telemetry'; +export { RECEIVE_TELEMETRY, SET_REPLAY_OVERLAY } from './telemetry'; export type { Telemetry, TelemetryItem, ReceiveTelemetryAction, + SetReplayOverlayAction, } from './telemetry'; diff --git a/client/src/store/types/telemetry.ts b/client/src/store/types/telemetry.ts index 8d8466ee8..f74584870 100644 --- a/client/src/store/types/telemetry.ts +++ b/client/src/store/types/telemetry.ts @@ -1,4 +1,5 @@ export const RECEIVE_TELEMETRY = 'RECEIVE_TELEMETRY'; +export const SET_REPLAY_OVERLAY = 'SET_REPLAY_OVERLAY'; export type Telemetry = TelemetryItem[]; @@ -73,6 +74,9 @@ export type TelemetryItem = { fieldOverlay: { ops: DrawOp[]; }; + replayOverlay?: { + ops: DrawOp[]; + }; log: string[]; timestamp: number; }; @@ -81,3 +85,10 @@ export type ReceiveTelemetryAction = { type: typeof RECEIVE_TELEMETRY; telemetry: Telemetry; }; + +export type SetReplayOverlayAction = { + type: typeof SET_REPLAY_OVERLAY; + overlay: DrawOp[]; +}; + +export type TelemetryAction = ReceiveTelemetryAction | SetReplayOverlayAction;