feat: Add PathRecorder component for recording and managing turtle paths with UI and functionality

This commit is contained in:
MayaTheShy
2026-02-19 22:58:14 -05:00
parent 7b3ae34cfa
commit 8bcee0b16c
3 changed files with 919 additions and 1 deletions

View File

@@ -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()}

View 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;
}
}

View 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;