feat: Add PathRecorder component for recording and managing turtle paths with UI and functionality
This commit is contained in:
@@ -5,13 +5,14 @@ import VoiceControl from './components/VoiceControl';
|
||||
import StatsPanel from './components/StatsPanel';
|
||||
import GroupsPanel from './components/GroupsPanel';
|
||||
import TaskPanel from './components/TaskPanel';
|
||||
import PathRecorder from './components/PathRecorder';
|
||||
import { useTurtleStore } from './store/turtleStore';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const connect = useTurtleStore((state) => state.connect);
|
||||
const [view, setView] = useState('split'); // 'split', 'map', 'panel'
|
||||
const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks'
|
||||
const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks', 'paths'
|
||||
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
||||
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
||||
|
||||
@@ -34,6 +35,8 @@ function App() {
|
||||
return <GroupsPanel turtles={turtles} apiUrl={apiUrl} wsUrl={wsUrl} />;
|
||||
case 'tasks':
|
||||
return <TaskPanel turtles={turtles} apiUrl={apiUrl} />;
|
||||
case 'paths':
|
||||
return <PathRecorder turtles={turtles} selectedTurtle={selectedTurtle} apiUrl={apiUrl} />;
|
||||
default:
|
||||
return <ControlPanel />;
|
||||
}
|
||||
@@ -106,6 +109,13 @@ function App() {
|
||||
>
|
||||
📋 Tasks
|
||||
</button>
|
||||
<button
|
||||
className={panelTab === 'paths' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('paths')}
|
||||
title="Path Recording"
|
||||
>
|
||||
🛤️ Paths
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-content-wrapper">
|
||||
{renderPanelContent()}
|
||||
|
||||
532
client/src/components/PathRecorder.css
Normal file
532
client/src/components/PathRecorder.css
Normal file
@@ -0,0 +1,532 @@
|
||||
.path-recorder {
|
||||
padding: 1.5rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recorder-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.recorder-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #f9fafb;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.selected-turtle-badge {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #10b981;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
animation: slideIn 0.3s;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #10b98133;
|
||||
color: #10b981;
|
||||
border: 1px solid #10b981;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #ef444433;
|
||||
color: #ef4444;
|
||||
border: 1px solid #ef4444;
|
||||
}
|
||||
|
||||
.message.info {
|
||||
background: #3b82f633;
|
||||
color: #3b82f6;
|
||||
border: 1px solid #3b82f6;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Recording Section */
|
||||
.recording-section {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.recording-section h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f9fafb;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.record-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.record-form input,
|
||||
.record-form textarea {
|
||||
padding: 0.75rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.record-form input:focus,
|
||||
.record-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: #ef4444;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.record-btn:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.record-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.recording-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.waypoint-counter {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.recording-info {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.375rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recording-info p {
|
||||
margin: 0.25rem 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.recording-info strong {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.stop-btn {
|
||||
padding: 0.875rem 2rem;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stop-btn:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
/* Paths Section */
|
||||
.paths-section h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f9fafb;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.paths-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.path-card {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.path-card:hover {
|
||||
background: #334155;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.path-card-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.path-info h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.path-description {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
margin: 0.5rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.path-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.path-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.path-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.path-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background: #059669;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #dc2626;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Path Details Modal */
|
||||
.path-details-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1e293b;
|
||||
border-radius: 0.75rem;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: slideUp 0.3s;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #f9fafb;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #e5e7eb;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-description {
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.waypoints-list h4,
|
||||
.path-visualization h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #f9fafb;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.waypoints-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.waypoint-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.waypoint-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.waypoint-coords {
|
||||
color: #e5e7eb;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.waypoint-action {
|
||||
margin-left: auto;
|
||||
color: #10b981;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.path-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 1rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.path-recorder {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.recorder-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.path-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.waypoints-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.path-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-height: 95vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.recorder-header h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.path-actions button {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
376
client/src/components/PathRecorder.jsx
Normal file
376
client/src/components/PathRecorder.jsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './PathRecorder.css';
|
||||
|
||||
const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
||||
const [paths, setPaths] = useState([]);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [currentPath, setCurrentPath] = useState(null);
|
||||
const [pathName, setPathName] = useState('');
|
||||
const [pathDescription, setPathDescription] = useState('');
|
||||
const [selectedPath, setSelectedPath] = useState(null);
|
||||
const [message, setMessage] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadPaths();
|
||||
}, []);
|
||||
|
||||
const loadPaths = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/paths`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPaths(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load paths:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = () => {
|
||||
if (!selectedTurtle) {
|
||||
showMessage('Please select a turtle first', 'error');
|
||||
return;
|
||||
}
|
||||
if (!pathName.trim()) {
|
||||
showMessage('Please enter a path name', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setRecording(true);
|
||||
setCurrentPath({
|
||||
name: pathName.trim(),
|
||||
description: pathDescription.trim(),
|
||||
turtleId: selectedTurtle.turtleID,
|
||||
waypoints: []
|
||||
});
|
||||
showMessage('Recording started! Move the turtle to record path.', 'success');
|
||||
};
|
||||
|
||||
const stopRecording = async () => {
|
||||
if (!currentPath || currentPath.waypoints.length === 0) {
|
||||
showMessage('No waypoints recorded', 'error');
|
||||
setRecording(false);
|
||||
setCurrentPath(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the path
|
||||
const pathResponse = await fetch(`${apiUrl}/api/paths`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: currentPath.name,
|
||||
turtleId: currentPath.turtleId,
|
||||
description: currentPath.description
|
||||
})
|
||||
});
|
||||
|
||||
if (!pathResponse.ok) {
|
||||
throw new Error('Failed to create path');
|
||||
}
|
||||
|
||||
const pathData = await pathResponse.json();
|
||||
const pathId = pathData.pathId;
|
||||
|
||||
// Add all waypoints
|
||||
for (const waypoint of currentPath.waypoints) {
|
||||
await fetch(`${apiUrl}/api/paths/${pathId}/waypoints`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(waypoint)
|
||||
});
|
||||
}
|
||||
|
||||
showMessage(`Path saved with ${currentPath.waypoints.length} waypoints!`, 'success');
|
||||
setRecording(false);
|
||||
setCurrentPath(null);
|
||||
setPathName('');
|
||||
setPathDescription('');
|
||||
loadPaths();
|
||||
} catch (error) {
|
||||
showMessage('Failed to save path', 'error');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePath = async (pathId) => {
|
||||
if (!confirm('Are you sure you want to delete this path?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/paths/${pathId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Path deleted', 'success');
|
||||
loadPaths();
|
||||
if (selectedPath?.pathId === pathId) {
|
||||
setSelectedPath(null);
|
||||
}
|
||||
} else {
|
||||
showMessage('Failed to delete path', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error deleting path', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const viewPathDetails = async (path) => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/paths/${path.pathId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSelectedPath(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load path details:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const playbackPath = (path) => {
|
||||
showMessage(`Playback not yet implemented for path: ${path.name}`, 'info');
|
||||
// TODO: Implement playback by sending commands to turtle
|
||||
};
|
||||
|
||||
const showMessage = (text, type) => {
|
||||
setMessage({ text, type });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
// Simulate recording waypoints when turtle moves
|
||||
useEffect(() => {
|
||||
if (recording && selectedTurtle && currentPath) {
|
||||
const newWaypoint = {
|
||||
x: selectedTurtle.position?.x || 0,
|
||||
y: selectedTurtle.position?.y || 0,
|
||||
z: selectedTurtle.position?.z || 0,
|
||||
action: selectedTurtle.lastAction || 'move'
|
||||
};
|
||||
|
||||
// Only add if position changed
|
||||
const lastWaypoint = currentPath.waypoints[currentPath.waypoints.length - 1];
|
||||
if (!lastWaypoint ||
|
||||
lastWaypoint.x !== newWaypoint.x ||
|
||||
lastWaypoint.y !== newWaypoint.y ||
|
||||
lastWaypoint.z !== newWaypoint.z) {
|
||||
setCurrentPath(prev => ({
|
||||
...prev,
|
||||
waypoints: [...prev.waypoints, newWaypoint]
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [selectedTurtle?.position, recording]);
|
||||
|
||||
const getTurtleById = (turtleId) => {
|
||||
return turtles.find(t => t.turtleID === turtleId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="path-recorder">
|
||||
<div className="recorder-header">
|
||||
<h2>🛤️ Path Recording</h2>
|
||||
{selectedTurtle && (
|
||||
<div className="selected-turtle-badge">
|
||||
🐢 {selectedTurtle.name || `Turtle ${selectedTurtle.turtleID}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`message ${message.type}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recording Controls */}
|
||||
<div className="recording-section">
|
||||
<h3>{recording ? '🔴 Recording...' : '⚪ Ready to Record'}</h3>
|
||||
|
||||
{!recording ? (
|
||||
<div className="record-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Path name..."
|
||||
value={pathName}
|
||||
onChange={(e) => setPathName(e.target.value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Description (optional)..."
|
||||
value={pathDescription}
|
||||
onChange={(e) => setPathDescription(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
<button
|
||||
onClick={startRecording}
|
||||
disabled={!selectedTurtle || !pathName.trim()}
|
||||
className="record-btn"
|
||||
>
|
||||
🔴 Start Recording
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="recording-controls">
|
||||
<div className="waypoint-counter">
|
||||
📍 {currentPath?.waypoints.length || 0} waypoints recorded
|
||||
</div>
|
||||
<div className="recording-info">
|
||||
<p>Path: <strong>{currentPath?.name}</strong></p>
|
||||
<p>Turtle: <strong>🐢 {selectedTurtle?.name || `Turtle ${selectedTurtle?.turtleID}`}</strong></p>
|
||||
</div>
|
||||
<button onClick={stopRecording} className="stop-btn">
|
||||
⏹️ Stop & Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paths List */}
|
||||
<div className="paths-section">
|
||||
<h3>📋 Saved Paths ({paths.length})</h3>
|
||||
|
||||
{loading && <div className="loading">Loading paths...</div>}
|
||||
|
||||
{!loading && paths.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">🛤️</div>
|
||||
<div className="empty-title">No Paths Recorded</div>
|
||||
<div className="empty-text">Record a path by selecting a turtle and clicking "Start Recording"</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="paths-list">
|
||||
{paths.map(path => {
|
||||
const turtle = getTurtleById(path.turtleId);
|
||||
return (
|
||||
<div key={path.pathId} className="path-card">
|
||||
<div className="path-card-header">
|
||||
<div className="path-info">
|
||||
<h4>{path.name}</h4>
|
||||
{path.description && (
|
||||
<p className="path-description">{path.description}</p>
|
||||
)}
|
||||
<div className="path-meta">
|
||||
<span>🐢 {turtle?.name || `Turtle ${path.turtleId}`}</span>
|
||||
<span>📍 {path.waypointCount || 0} waypoints</span>
|
||||
<span>📅 {new Date(path.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="path-actions">
|
||||
<button
|
||||
onClick={() => viewPathDetails(path)}
|
||||
className="view-btn"
|
||||
title="View details"
|
||||
>
|
||||
👁️ View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => playbackPath(path)}
|
||||
className="play-btn"
|
||||
title="Playback path"
|
||||
>
|
||||
▶️ Play
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deletePath(path.pathId)}
|
||||
className="delete-btn"
|
||||
title="Delete path"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path Details Modal */}
|
||||
{selectedPath && (
|
||||
<div className="path-details-modal" onClick={() => setSelectedPath(null)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>📍 {selectedPath.name}</h3>
|
||||
<button onClick={() => setSelectedPath(null)} className="close-btn">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{selectedPath.description && (
|
||||
<p className="modal-description">{selectedPath.description}</p>
|
||||
)}
|
||||
|
||||
<div className="waypoints-list">
|
||||
<h4>Waypoints ({selectedPath.waypoints?.length || 0})</h4>
|
||||
<div className="waypoints-grid">
|
||||
{selectedPath.waypoints?.map((waypoint, idx) => (
|
||||
<div key={idx} className="waypoint-item">
|
||||
<span className="waypoint-number">{idx + 1}</span>
|
||||
<span className="waypoint-coords">
|
||||
({waypoint.x}, {waypoint.y}, {waypoint.z})
|
||||
</span>
|
||||
{waypoint.action && (
|
||||
<span className="waypoint-action">{waypoint.action}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="path-visualization">
|
||||
<h4>Path Preview</h4>
|
||||
<div className="path-stats">
|
||||
<div className="stat">
|
||||
<span className="stat-label">Total Distance:</span>
|
||||
<span className="stat-value">
|
||||
{selectedPath.waypoints?.length > 1
|
||||
? Math.floor(
|
||||
selectedPath.waypoints.reduce((total, waypoint, i) => {
|
||||
if (i === 0) return 0;
|
||||
const prev = selectedPath.waypoints[i - 1];
|
||||
return total + Math.sqrt(
|
||||
Math.pow(waypoint.x - prev.x, 2) +
|
||||
Math.pow(waypoint.y - prev.y, 2) +
|
||||
Math.pow(waypoint.z - prev.z, 2)
|
||||
);
|
||||
}, 0)
|
||||
)
|
||||
: 0} blocks
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Start:</span>
|
||||
<span className="stat-value">
|
||||
({selectedPath.waypoints?.[0]?.x}, {selectedPath.waypoints?.[0]?.y}, {selectedPath.waypoints?.[0]?.z})
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">End:</span>
|
||||
<span className="stat-value">
|
||||
{selectedPath.waypoints?.length > 0 && (
|
||||
<>
|
||||
({selectedPath.waypoints[selectedPath.waypoints.length - 1].x}, {' '}
|
||||
{selectedPath.waypoints[selectedPath.waypoints.length - 1].y}, {' '}
|
||||
{selectedPath.waypoints[selectedPath.waypoints.length - 1].z})
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PathRecorder;
|
||||
Reference in New Issue
Block a user