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 StatsPanel from './components/StatsPanel';
|
||||||
import GroupsPanel from './components/GroupsPanel';
|
import GroupsPanel from './components/GroupsPanel';
|
||||||
import TaskPanel from './components/TaskPanel';
|
import TaskPanel from './components/TaskPanel';
|
||||||
|
import PathRecorder from './components/PathRecorder';
|
||||||
import { useTurtleStore } from './store/turtleStore';
|
import { useTurtleStore } from './store/turtleStore';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const connect = useTurtleStore((state) => state.connect);
|
const connect = useTurtleStore((state) => state.connect);
|
||||||
const [view, setView] = useState('split'); // 'split', 'map', 'panel'
|
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 turtles = useTurtleStore((state) => state.getTurtleArray());
|
||||||
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
||||||
|
|
||||||
@@ -34,6 +35,8 @@ function App() {
|
|||||||
return <GroupsPanel turtles={turtles} apiUrl={apiUrl} wsUrl={wsUrl} />;
|
return <GroupsPanel turtles={turtles} apiUrl={apiUrl} wsUrl={wsUrl} />;
|
||||||
case 'tasks':
|
case 'tasks':
|
||||||
return <TaskPanel turtles={turtles} apiUrl={apiUrl} />;
|
return <TaskPanel turtles={turtles} apiUrl={apiUrl} />;
|
||||||
|
case 'paths':
|
||||||
|
return <PathRecorder turtles={turtles} selectedTurtle={selectedTurtle} apiUrl={apiUrl} />;
|
||||||
default:
|
default:
|
||||||
return <ControlPanel />;
|
return <ControlPanel />;
|
||||||
}
|
}
|
||||||
@@ -106,6 +109,13 @@ function App() {
|
|||||||
>
|
>
|
||||||
📋 Tasks
|
📋 Tasks
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={panelTab === 'paths' ? 'active' : ''}
|
||||||
|
onClick={() => setPanelTab('paths')}
|
||||||
|
title="Path Recording"
|
||||||
|
>
|
||||||
|
🛤️ Paths
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-content-wrapper">
|
<div className="panel-content-wrapper">
|
||||||
{renderPanelContent()}
|
{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